// This file defines a number of table-related commands. import { Command, EditorState, TextSelection, Transaction, } from 'prosemirror-state'; import { Fragment, Node, NodeType, ResolvedPos } from 'prosemirror-model'; import { Rect, TableMap } from './tablemap'; import { CellSelection } from './cellselection'; import { addColSpan, cellAround, cellWrapping, columnIsHeader, isInTable, moveCellForward, removeColSpan, selectionCell, _setAttr, } from './util'; import { tableNodeTypes } from './schema'; import type { Direction } from './input'; /** * @public */ export type TableRect = Rect & { tableStart: number; map: TableMap; table: Node; }; /** * Helper to get the selected rectangle in a table, if any. Adds table * map, table node, and table start offset to the object for * convenience. * * @public */ export function selectedRect(state: EditorState): TableRect { const sel = state.selection, $pos = selectionCell(state); const table = $pos.node(-1), tableStart = $pos.start(-1), map = TableMap.get(table); let rect; if (sel instanceof CellSelection) rect = map.rectBetween( sel.$anchorCell.pos - tableStart, sel.$headCell.pos - tableStart, ); else rect = map.findCell($pos.pos - tableStart); rect.tableStart = tableStart; rect.map = map; rect.table = table; return rect; } /** * Add a column at the given position in a table. * * @public */ export function addColumn( tr: Transaction, { map, tableStart, table }: TableRect, col: number, ): Transaction { let refColumn = col > 0 ? -1 : 0; if (columnIsHeader(map, table, col + refColumn)) refColumn = col == 0 || col == map.width ? null : 0; for (let row = 0; row < map.height; row++) { const index = row * map.width + col; // If this position falls inside a col-spanning cell if (col > 0 && col < map.width && map.map[index - 1] == map.map[index]) { const pos = map.map[index], cell = table.nodeAt(pos); tr.setNodeMarkup( tr.mapping.map(tableStart + pos), null, addColSpan(cell.attrs, col - map.colCount(pos)), ); // Skip ahead if rowspan > 1 row += cell.attrs.rowspan - 1; } else { const type = refColumn == null ? tableNodeTypes(table.type.schema).cell : table.nodeAt(map.map[index + refColumn]).type; const pos = map.positionAt(row, col, table); tr.insert(tr.mapping.map(tableStart + pos), type.createAndFill()); } } return tr; } /** * Command to add a column before the column with the selection. * * @public */ export function addColumnBefore( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addColumn(state.tr, rect, rect.left)); } return true; } /** * Command to add a column after the column with the selection. * * @public */ export function addColumnAfter( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addColumn(state.tr, rect, rect.right)); } return true; } /** * @public */ export function removeColumn( tr: Transaction, { map, table, tableStart }: TableRect, col: number, ) { const mapStart = tr.mapping.maps.length; for (let row = 0; row < map.height; ) { const index = row * map.width + col, pos = map.map[index], cell = table.nodeAt(pos); // If this is part of a col-spanning cell if ( (col > 0 && map.map[index - 1] == pos) || (col < map.width - 1 && map.map[index + 1] == pos) ) { tr.setNodeMarkup( tr.mapping.slice(mapStart).map(tableStart + pos), null, removeColSpan(cell.attrs, col - map.colCount(pos)), ); } else { const start = tr.mapping.slice(mapStart).map(tableStart + pos); tr.delete(start, start + cell.nodeSize); } row += cell.attrs.rowspan; } } /** * Command function that removes the selected columns from a table. * * @public */ export function deleteColumn( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state), tr = state.tr; if (rect.left == 0 && rect.right == rect.map.width) return false; for (let i = rect.right - 1; ; i--) { removeColumn(tr, rect, i); if (i == rect.left) break; rect.table = rect.tableStart ? tr.doc.nodeAt(rect.tableStart - 1) : tr.doc; rect.map = TableMap.get(rect.table); } dispatch(tr); } return true; } /** * @public */ export function rowIsHeader(map: TableMap, table: Node, row: number): boolean { const headerCell = tableNodeTypes(table.type.schema).header_cell; for (let col = 0; col < map.width; col++) if (table.nodeAt(map.map[col + row * map.width]).type != headerCell) return false; return true; } /** * @public */ export function addRow( tr: Transaction, { map, tableStart, table }: TableRect, row: number, ): Transaction { let rowPos = tableStart; for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize; const cells = []; let refRow = row > 0 ? -1 : 0; if (rowIsHeader(map, table, row + refRow)) refRow = row == 0 || row == map.height ? null : 0; for (let col = 0, index = map.width * row; col < map.width; col++, index++) { // Covered by a rowspan cell if ( row > 0 && row < map.height && map.map[index] == map.map[index - map.width] ) { const pos = map.map[index], attrs = table.nodeAt(pos).attrs; tr.setNodeMarkup( tableStart + pos, null, _setAttr(attrs, 'rowspan', attrs.rowspan + 1), ); col += attrs.colspan - 1; } else { const type = refRow == null ? tableNodeTypes(table.type.schema).cell : table.nodeAt(map.map[index + refRow * map.width]).type; cells.push(type.createAndFill()); } } tr.insert(rowPos, tableNodeTypes(table.type.schema).row.create(null, cells)); return tr; } /** * Add a table row before the selection. * * @public */ export function addRowBefore( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addRow(state.tr, rect, rect.top)); } return true; } /** * Add a table row after the selection. * * @public */ export function addRowAfter( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state); dispatch(addRow(state.tr, rect, rect.bottom)); } return true; } /** * @public */ export function removeRow( tr: Transaction, { map, table, tableStart }: TableRect, row: number, ): void { let rowPos = 0; for (let i = 0; i < row; i++) rowPos += table.child(i).nodeSize; const nextRow = rowPos + table.child(row).nodeSize; const mapFrom = tr.mapping.maps.length; tr.delete(rowPos + tableStart, nextRow + tableStart); for (let col = 0, index = row * map.width; col < map.width; col++, index++) { const pos = map.map[index]; if (row > 0 && pos == map.map[index - map.width]) { // If this cell starts in the row above, simply reduce its rowspan const attrs = table.nodeAt(pos).attrs; tr.setNodeMarkup( tr.mapping.slice(mapFrom).map(pos + tableStart), null, _setAttr(attrs, 'rowspan', attrs.rowspan - 1), ); col += attrs.colspan - 1; } else if (row < map.width && pos == map.map[index + map.width]) { // Else, if it continues in the row below, it has to be moved down const cell = table.nodeAt(pos); const copy = cell.type.create( _setAttr(cell.attrs, 'rowspan', cell.attrs.rowspan - 1), cell.content, ); const newPos = map.positionAt(row + 1, col, table); tr.insert(tr.mapping.slice(mapFrom).map(tableStart + newPos), copy); col += cell.attrs.colspan - 1; } } } /** * Remove the selected rows from a table. * * @public */ export function deleteRow( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { if (!isInTable(state)) return false; if (dispatch) { const rect = selectedRect(state), tr = state.tr; if (rect.top == 0 && rect.bottom == rect.map.height) return false; for (let i = rect.bottom - 1; ; i--) { removeRow(tr, rect, i); if (i == rect.top) break; rect.table = rect.tableStart ? tr.doc.nodeAt(rect.tableStart - 1) : tr.doc; rect.map = TableMap.get(rect.table); } dispatch(tr); } return true; } function isEmpty(cell: Node): boolean { const c = cell.content; return ( c.childCount == 1 && c.firstChild.isTextblock && c.firstChild.childCount == 0 ); } function cellsOverlapRectangle({ width, height, map }: TableMap, rect: Rect) { let indexTop = rect.top * width + rect.left, indexLeft = indexTop; let indexBottom = (rect.bottom - 1) * width + rect.left, indexRight = indexTop + (rect.right - rect.left - 1); for (let i = rect.top; i < rect.bottom; i++) { if ( (rect.left > 0 && map[indexLeft] == map[indexLeft - 1]) || (rect.right < width && map[indexRight] == map[indexRight + 1]) ) return true; indexLeft += width; indexRight += width; } for (let i = rect.left; i < rect.right; i++) { if ( (rect.top > 0 && map[indexTop] == map[indexTop - width]) || (rect.bottom < height && map[indexBottom] == map[indexBottom + width]) ) return true; indexTop++; indexBottom++; } return false; } /** * Merge the selected cells into a single cell. Only available when * the selected cells' outline forms a rectangle. * * @public */ export function mergeCells( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { const sel = state.selection; if ( !(sel instanceof CellSelection) || sel.$anchorCell.pos == sel.$headCell.pos ) return false; const rect = selectedRect(state), { map } = rect; if (cellsOverlapRectangle(map, rect)) return false; if (dispatch) { const tr = state.tr; const seen = {}; let content = Fragment.empty; let mergedPos; let mergedCell; for (let row = rect.top; row < rect.bottom; row++) { for (let col = rect.left; col < rect.right; col++) { const cellPos = map.map[row * map.width + col], cell = rect.table.nodeAt(cellPos); if (seen[cellPos]) continue; seen[cellPos] = true; if (mergedPos == null) { mergedPos = cellPos; mergedCell = cell; } else { if (!isEmpty(cell)) content = content.append(cell.content); const mapped = tr.mapping.map(cellPos + rect.tableStart); tr.delete(mapped, mapped + cell.nodeSize); } } } tr.setNodeMarkup( mergedPos + rect.tableStart, null, _setAttr( addColSpan( mergedCell.attrs, mergedCell.attrs.colspan, rect.right - rect.left - mergedCell.attrs.colspan, ), 'rowspan', rect.bottom - rect.top, ), ); if (content.size) { const end = mergedPos + 1 + mergedCell.content.size; const start = isEmpty(mergedCell) ? mergedPos + 1 : end; tr.replaceWith(start + rect.tableStart, end + rect.tableStart, content); } tr.setSelection( new CellSelection(tr.doc.resolve(mergedPos + rect.tableStart)), ); dispatch(tr); } return true; } /** * Split a selected cell, whose rowpan or colspan is greater than one, * into smaller cells. Use the first cell type for the new cells. * * @public */ export function splitCell( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { const nodeTypes = tableNodeTypes(state.schema); return splitCellWithType(({ node }) => { return nodeTypes[node.type.spec.tableRole]; })(state, dispatch); } /** * @public */ export interface GetCellTypeOptions { node: Node; row: number; col: number; } /** * Split a selected cell, whose rowpan or colspan is greater than one, * into smaller cells with the cell type (th, td) returned by getType function. * * @public */ export function splitCellWithType( getCellType: (options: GetCellTypeOptions) => NodeType, ): Command { return (state, dispatch) => { const sel = state.selection; let cellNode, cellPos; if (!(sel instanceof CellSelection)) { cellNode = cellWrapping(sel.$from); if (!cellNode) return false; cellPos = cellAround(sel.$from).pos; } else { if (sel.$anchorCell.pos != sel.$headCell.pos) return false; cellNode = sel.$anchorCell.nodeAfter; cellPos = sel.$anchorCell.pos; } if (cellNode.attrs.colspan == 1 && cellNode.attrs.rowspan == 1) { return false; } if (dispatch) { let baseAttrs = cellNode.attrs; const attrs = []; const colwidth = baseAttrs.colwidth; if (baseAttrs.rowspan > 1) baseAttrs = _setAttr(baseAttrs, 'rowspan', 1); if (baseAttrs.colspan > 1) baseAttrs = _setAttr(baseAttrs, 'colspan', 1); const rect = selectedRect(state), tr = state.tr; for (let i = 0; i < rect.right - rect.left; i++) attrs.push( colwidth ? _setAttr( baseAttrs, 'colwidth', colwidth && colwidth[i] ? [colwidth[i]] : null, ) : baseAttrs, ); let lastCell; for (let row = rect.top; row < rect.bottom; row++) { let pos = rect.map.positionAt(row, rect.left, rect.table); if (row == rect.top) pos += cellNode.nodeSize; for (let col = rect.left, i = 0; col < rect.right; col++, i++) { if (col == rect.left && row == rect.top) continue; tr.insert( (lastCell = tr.mapping.map(pos + rect.tableStart, 1)), getCellType({ node: cellNode, row, col }).createAndFill(attrs[i]), ); } } tr.setNodeMarkup( cellPos, getCellType({ node: cellNode, row: rect.top, col: rect.left }), attrs[0], ); if (sel instanceof CellSelection) tr.setSelection( new CellSelection( tr.doc.resolve(sel.$anchorCell.pos), lastCell && tr.doc.resolve(lastCell), ), ); dispatch(tr); } return true; }; } /** * Returns a command that sets the given attribute to the given value, * and is only available when the currently selected cell doesn't * already have that attribute set to that value. * * @public */ export function setCellAttr(name: string, value: unknown): Command { return function (state, dispatch) { if (!isInTable(state)) return false; const $cell = selectionCell(state); if ($cell.nodeAfter.attrs[name] === value) return false; if (dispatch) { const tr = state.tr; if (state.selection instanceof CellSelection) state.selection.forEachCell((node, pos) => { if (node.attrs[name] !== value) tr.setNodeMarkup(pos, null, _setAttr(node.attrs, name, value)); }); else tr.setNodeMarkup( $cell.pos, null, _setAttr($cell.nodeAfter.attrs, name, value), ); dispatch(tr); } return true; }; } function deprecated_toggleHeader(type: ToggleHeaderType): Command { return function (state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const types = tableNodeTypes(state.schema); const rect = selectedRect(state), tr = state.tr; const cells = rect.map.cellsInRect( type == 'column' ? { left: rect.left, top: 0, right: rect.right, bottom: rect.map.height, } : type == 'row' ? { left: 0, top: rect.top, right: rect.map.width, bottom: rect.bottom, } : rect, ); const nodes = cells.map((pos) => rect.table.nodeAt(pos)); for ( let i = 0; i < cells.length; i++ // Remove headers, if any ) if (nodes[i].type == types.header_cell) tr.setNodeMarkup( rect.tableStart + cells[i], types.cell, nodes[i].attrs, ); if (tr.steps.length == 0) for ( let i = 0; i < cells.length; i++ // No headers removed, add instead ) tr.setNodeMarkup( rect.tableStart + cells[i], types.header_cell, nodes[i].attrs, ); dispatch(tr); } return true; }; } function isHeaderEnabledByType( type: 'row' | 'column', rect: TableRect, types: Record, ): boolean { // Get cell positions for first row or first column const cellPositions = rect.map.cellsInRect({ left: 0, top: 0, right: type == 'row' ? rect.map.width : 1, bottom: type == 'column' ? rect.map.height : 1, }); for (let i = 0; i < cellPositions.length; i++) { const cell = rect.table.nodeAt(cellPositions[i]); if (cell && cell.type !== types.header_cell) { return false; } } return true; } /** * @public */ export type ToggleHeaderType = 'column' | 'row' | 'cell'; /** * Toggles between row/column header and normal cells (Only applies to first row/column). * For deprecated behavior pass `useDeprecatedLogic` in options with true. * * @public */ export function toggleHeader( type: ToggleHeaderType, options?: { useDeprecatedLogic: boolean } | undefined, ): Command { options = options || { useDeprecatedLogic: false }; if (options.useDeprecatedLogic) return deprecated_toggleHeader(type); return function (state, dispatch) { if (!isInTable(state)) return false; if (dispatch) { const types = tableNodeTypes(state.schema); const rect = selectedRect(state), tr = state.tr; const isHeaderRowEnabled = isHeaderEnabledByType('row', rect, types); const isHeaderColumnEnabled = isHeaderEnabledByType( 'column', rect, types, ); const isHeaderEnabled = type === 'column' ? isHeaderRowEnabled : type === 'row' ? isHeaderColumnEnabled : false; const selectionStartsAt = isHeaderEnabled ? 1 : 0; const cellsRect = type == 'column' ? { left: 0, top: selectionStartsAt, right: 1, bottom: rect.map.height, } : type == 'row' ? { left: selectionStartsAt, top: 0, right: rect.map.width, bottom: 1, } : rect; const newType = type == 'column' ? isHeaderColumnEnabled ? types.cell : types.header_cell : type == 'row' ? isHeaderRowEnabled ? types.cell : types.header_cell : types.cell; rect.map.cellsInRect(cellsRect).forEach((relativeCellPos) => { const cellPos = relativeCellPos + rect.tableStart; const cell = tr.doc.nodeAt(cellPos); if (cell) { tr.setNodeMarkup(cellPos, newType, cell.attrs); } }); dispatch(tr); } return true; }; } /** * Toggles whether the selected row contains header cells. * * @public */ export const toggleHeaderRow: Command = toggleHeader('row', { useDeprecatedLogic: true, }); /** * Toggles whether the selected column contains header cells. * * @public */ export const toggleHeaderColumn: Command = toggleHeader('column', { useDeprecatedLogic: true, }); /** * Toggles whether the selected cells are header cells. * * @public */ export const toggleHeaderCell: Command = toggleHeader('cell', { useDeprecatedLogic: true, }); function findNextCell($cell: ResolvedPos, dir: Direction): number { if (dir < 0) { const before = $cell.nodeBefore; if (before) return $cell.pos - before.nodeSize; for ( let row = $cell.index(-1) - 1, rowEnd = $cell.before(); row >= 0; row-- ) { const rowNode = $cell.node(-1).child(row); if (rowNode.childCount) return rowEnd - 1 - rowNode.lastChild.nodeSize; rowEnd -= rowNode.nodeSize; } } else { if ($cell.index() < $cell.parent.childCount - 1) return $cell.pos + $cell.nodeAfter.nodeSize; const table = $cell.node(-1); for ( let row = $cell.indexAfter(-1), rowStart = $cell.after(); row < table.childCount; row++ ) { const rowNode = table.child(row); if (rowNode.childCount) return rowStart + 1; rowStart += rowNode.nodeSize; } } } /** * Returns a command for selecting the next (direction=1) or previous * (direction=-1) cell in a table. * * @public */ export function goToNextCell(direction: Direction): Command { return function (state, dispatch) { if (!isInTable(state)) return false; const cell = findNextCell(selectionCell(state), direction); if (cell == null) return false; if (dispatch) { const $cell = state.doc.resolve(cell); dispatch( state.tr .setSelection(TextSelection.between($cell, moveCellForward($cell))) .scrollIntoView(), ); } return true; }; } /** * Deletes the table around the selection, if any. * * @public */ export function deleteTable( state: EditorState, dispatch?: (tr: Transaction) => void, ): boolean { const $pos = state.selection.$anchor; for (let d = $pos.depth; d > 0; d--) { const node = $pos.node(d); if (node.type.spec.tableRole == 'table') { if (dispatch) dispatch( state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView(), ); return true; } } return false; }