import { Fragment, Node, ResolvedPos, Slice } from "prosemirror-model"; import { EditorState, Selection, TextSelection } from "prosemirror-state"; import { UxCommand } from "../constants"; import { CellAttrs, EditorSchema, NodeTypeName } from "../schema"; import { Bias, CartesianAxis, Command, Dispatch, TableColumnIndex, TableNode, TablePosOfCell, TableRowIndex, TableStart } from "../types"; import { isTextSelection } from "../util"; import { CellSelection } from "./CellSelection"; import { EdgeRect } from "./EdgeRect"; import { addColumn, addRow, deleteTable as deleteTableOp, removeColumns, removeRows, selectAll as selectAllOp } from "./operations"; import { TableMap } from "./TableMap"; import { atEndOfCell, expandColSpan, isInTable, moveCellForward, nextCell, selectionCell } from "./util"; // 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. function edgeRectFromSelection( state: EditorState ): Readonly<{ table: TableNode; start: TableStart; map: TableMap; edgeRect: EdgeRect }> { const sel = state.selection; const $pos = selectionCell(state)!; const table = $pos.node(-1) as TableNode; const start = $pos.start(-1) as TableStart; const map = TableMap.get(table); return { edgeRect: sel instanceof CellSelection ? map.rectBetween((sel.$anchorCell.pos - start) as TablePosOfCell, (sel.$headCell.pos - start) as TablePosOfCell) : map.findCell(($pos.pos - start) as TablePosOfCell), start: start, map: map, table: table }; } /** * Command to add a column before the column with the selection. */ export function addColumnBefore(state: EditorState, dispatch?: Dispatch): boolean { if (isInTable(state)) { if (dispatch !== undefined) { const { table, start, map, edgeRect } = edgeRectFromSelection(state); dispatch(addColumn(state.tr, table, start, map, edgeRect.left)); } return true; } return false; } /** * Command to add a column after the column with the selection. */ export function addColumnAfter(state: EditorState, dispatch?: Dispatch): boolean { if (isInTable(state)) { if (dispatch !== undefined) { const { table, start, map, edgeRect } = edgeRectFromSelection(state); dispatch(addColumn(state.tr, table, start, map, edgeRect.right)); } return true; } return false; } /** * Command function that removes the selected columns from a table. */ export function deleteColumn(state: EditorState, dispatch?: Dispatch): boolean { if (!isInTable(state)) { return false; } if (dispatch !== undefined) { const { start, edgeRect } = edgeRectFromSelection(state); const tr = state.tr; if (!removeColumns(tr, start, edgeRect.left as TableColumnIndex, (edgeRect.right - 1) as TableColumnIndex)) { return false; } dispatch(tr); } return true; } /** * Add a table row before the selection. */ export function addRowBefore(state: EditorState, dispatch?: Dispatch): boolean { if (!isInTable(state)) { return false; } if (dispatch !== undefined) { const { table, start, map, edgeRect } = edgeRectFromSelection(state); dispatch(addRow(state.tr, table, start, map, edgeRect.top)); } return true; } /** * Add a table row after the selection. */ export function addRowAfter(state: EditorState, dispatch?: Dispatch): boolean { if (!isInTable(state)) { return false; } if (dispatch !== undefined) { const { table, start, map, edgeRect } = edgeRectFromSelection(state); dispatch(addRow(state.tr, table, start, map, edgeRect.bottom)); } return true; } /** * Remove the selected rows from a table. */ export function deleteRow(state: EditorState, dispatch?: Dispatch) { if (!isInTable(state)) { return false; } if (dispatch !== undefined) { const { start, edgeRect } = edgeRectFromSelection(state); const tr = state.tr; if (!removeRows(tr, start, edgeRect.top as TableRowIndex, (edgeRect.bottom - 1) as TableRowIndex)) { return false; } dispatch(tr); } return true; } function isEmpty(cell: Node) { const c = cell.content; return c.firstChild != null && c.firstChild.isTextblock && c.firstChild.childCount == 0; } function cellsOverlapRectangle({ width, height, map }: TableMap, rect: EdgeRect) { let indexTop = rect.top * width + rect.left; let indexLeft = indexTop; let indexBottom = (rect.bottom - 1) * width + rect.left; let 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. */ export function mergeCells(state: EditorState, dispatch?: Dispatch): boolean { const sel = state.selection; if (!(sel instanceof CellSelection) || sel.$anchorCell.pos == sel.$headCell.pos) { return false; } const { edgeRect, table, start: tableStart, map } = edgeRectFromSelection(state); if (cellsOverlapRectangle(map, edgeRect)) { return false; } if (dispatch !== undefined) { const tr = state.tr; const seen = []; let content = Fragment.empty; let mergedPos; let mergedCell; for (let row = edgeRect.top; row < edgeRect.bottom; row++) { for (let col = edgeRect.left; col < edgeRect.right; col++) { const cellPos = map.map[row * map.width + col]; const cell = table.nodeAt(cellPos)!; if (seen.indexOf(cellPos) > -1) { continue; } seen.push(cellPos); if (mergedPos === undefined) { mergedPos = cellPos; mergedCell = cell; } else { if (!isEmpty(cell)) { content = content.append(cell.content); } const mapped = tr.mapping.map(tableStart + cellPos); tr.delete(mapped, mapped + cell.nodeSize); } } } if (mergedCell !== undefined && mergedPos !== undefined) { const attrs = mergedCell.attrs as CellAttrs; tr.setNodeMarkup(tableStart + mergedPos, undefined, { ...expandColSpan(attrs, attrs.colspan, edgeRect.right - edgeRect.left - attrs.colspan), rowspan: edgeRect.bottom - edgeRect.top }); if (content.size > 0) { const end = mergedPos + 1 + mergedCell.content.size; const start = isEmpty(mergedCell) ? mergedPos + 1 : end; tr.replaceWith(tableStart + start, tableStart + end, content); } tr.setSelection(new CellSelection(tr.doc.resolve(tableStart + mergedPos))); dispatch(tr); } } return true; } /** * Split a selected cell, whose rowpan or colspan is greater than one, into smaller cells. */ export function splitCell(state: EditorState, dispatch?: Dispatch): boolean { const sel = state.selection; if (!(sel instanceof CellSelection) || sel.$anchorCell.pos != sel.$headCell.pos) { return false; } const cellNode = sel.$anchorCell.nodeAfter!; if (cellNode.attrs.colspan == 1 && cellNode.attrs.rowspan == 1) { return false; } if (dispatch !== undefined) { let baseAttrs = cellNode.attrs as CellAttrs; const attrs = []; const colwidth = baseAttrs.colwidth; if (baseAttrs.rowspan > 1) { baseAttrs = { ...baseAttrs, rowspan: 1 }; } if (baseAttrs.colspan > 1) { baseAttrs = { ...baseAttrs, colspan: 1 }; } const { start: tableStart, table, map, edgeRect } = edgeRectFromSelection(state); const tr = state.tr; for (let i = 0; i < edgeRect.right - edgeRect.left; i++) { attrs.push(colwidth != null ? { ...baseAttrs, colwidth: colwidth[i] > 0 ? [colwidth[i]] : null } : baseAttrs); } let lastCell; const cellType = state.schema.nodes.td; for (let row = 0; row < edgeRect.bottom; row++) { if (row >= edgeRect.top) { let pos = map.positionAt(row, edgeRect.left, table); if (row == edgeRect.top) { pos += cellNode.nodeSize; } for (let col = edgeRect.left, i = 0; col < edgeRect.right; col++, i++) { if (col == edgeRect.left && row == edgeRect.top) { continue; } tr.insert((lastCell = tr.mapping.map(tableStart + pos, 1)), cellType.createAndFill(attrs[i])!); } } } tr.setNodeMarkup(sel.$anchorCell.pos, undefined, attrs[0]); tr.setSelection( new CellSelection(tr.doc.resolve(sel.$anchorCell.pos), lastCell !== undefined ? tr.doc.resolve(lastCell) : undefined) ); 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. */ export function setCellAttr(name: T, value: CellAttrs[T]) { return function(state: EditorState, dispatch?: Dispatch): boolean { if (!isInTable(state)) { return false; } const $cell = selectionCell(state)!; if ($cell.nodeAfter!.attrs[name] === value) { return false; } if (dispatch !== undefined) { const tr = state.tr; if (state.selection instanceof CellSelection) { state.selection.forEachCell((node, pos) => { if (node.attrs[name] !== value) { tr.setNodeMarkup(pos, undefined, { ...(node.attrs as CellAttrs), [name]: value }); } }); } else { tr.setNodeMarkup($cell.pos, undefined, { ...($cell.nodeAfter!.attrs as CellAttrs), [name]: value }); } dispatch(tr); } return true; }; } const toggleHeader = (type: "column" | "row" | "cell"): Command => (state, dispatch) => { if (!isInTable(state)) { return false; } if (dispatch !== undefined) { const { start: tableStart, edgeRect, map, table } = edgeRectFromSelection(state); const tr = state.tr; const cells = map.cellsInRect( type == "column" ? new EdgeRect(edgeRect.left, 0, edgeRect.right, map.height) : type == "row" ? new EdgeRect(0, edgeRect.top, map.width, edgeRect.bottom) : edgeRect ); const nodes = cells.map(pos => table.nodeAt(pos)); for ( let i = 0; i < cells.length; i++ // Remove headers, if any ) { if (nodes[i]!.type == state.schema.nodes.th) { tr.setNodeMarkup(tableStart + cells[i], state.schema.nodes.td, nodes[i]!.attrs); } } if (tr.steps.length == 0) { for ( let i = 0; i < cells.length; i++ // No headers removed, add instead ) { tr.setNodeMarkup(tableStart + cells[i], state.schema.nodes.th, nodes[i]!.attrs); } } dispatch(tr); } return true; }; /** * Toggles whether the selected row contains header cells. */ export const toggleHeaderRow = toggleHeader("row"); /** * Toggles whether the selected column contains header cells. */ export const toggleHeaderColumn = toggleHeader("column"); /** * Toggles whether the selected cells are header cells. */ export const toggleHeaderCell = toggleHeader("cell"); function findNextCell($cell: ResolvedPos, bias: Bias) { if (bias == Bias.BACKWARD) { const before = $cell.nodeBefore; if (before != null) { 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 > 0) { 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 > 0) { return rowStart + 1; } rowStart += rowNode.nodeSize; } } return null; } /** * Returns a command for selecting the next or previous cell in a table. */ export function goToNextCell(bias: Bias) { return function(state: EditorState, dispatch?: Dispatch): boolean { if (!isInTable(state)) { return false; } const cell = findNextCell(selectionCell(state)!, bias); if (cell === null) { return false; } if (dispatch !== undefined) { 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. */ export function deleteTable(state: EditorState, dispatch?: Dispatch): boolean { const $pos = state.selection.$anchor; for (let d = $pos.depth; d > 0; d--) { const node = $pos.node(d); if (node.type.name === ("tbl" as NodeTypeName)) { if (dispatch !== undefined) { const tr = state.tr; deleteTableOp(tr, $pos.start(d) as TableStart); tr.scrollIntoView(); dispatch(tr); } return true; } } return false; } /** * Select all cells in the currently active table (if the table isn't already * covered by a total cell selection). * * When the entire table is selected, it's desirable to let the `SelectAll` * command bubble up and select the next-highest container. */ export const selectAll: Command = (state, dispatch) => { const tr = state.tr; if (selectAllOp(tr)) { if (dispatch !== undefined) { dispatch(tr); } return true; } return false; }; /** * Insert a 3x3 table. */ export const insertTable: Command = (state, dispatch) => { const { selection } = state; if (isTextSelection(selection) && selection.$cursor != null) { if (dispatch !== undefined) { const { nodes } = state.schema; const cell = nodes.td.createAndFill()!; const row = nodes.tr.createAndFill(undefined, [cell, cell, cell])!; const table = nodes.tbl.createAndFill(undefined, [row, row, row])!; const tr = state.tr; tr.replaceSelectionWith(table); // Create a text selection in the first cell, by searching forward in the // document (after the origin cursor position). tr.setSelection(Selection.near(tr.doc.resolve(selection.$cursor.pos + 1), 1)); dispatch(tr); } return true; } return false; }; export const jumpCellBarrierUp = jumpCellBarrierCommand(CartesianAxis.VERTICAL, Bias.BACKWARD); export const jumpCellBarrierDown = jumpCellBarrierCommand(CartesianAxis.VERTICAL, Bias.FORWARD); export const jumpCellBarrierLeft = jumpCellBarrierCommand(CartesianAxis.HORIZONTAL, Bias.BACKWARD); export const jumpCellBarrierRight = jumpCellBarrierCommand(CartesianAxis.HORIZONTAL, Bias.FORWARD); export const selectCellWithUp = selectCellWithCommand(CartesianAxis.VERTICAL, Bias.BACKWARD); export const selectCellWithDown = selectCellWithCommand(CartesianAxis.VERTICAL, Bias.FORWARD); export const selectCellWithLeft = selectCellWithCommand(CartesianAxis.HORIZONTAL, Bias.BACKWARD); export const selectCellWithRight = selectCellWithCommand(CartesianAxis.HORIZONTAL, Bias.FORWARD); export const deleteCellSelection: Command = (state, dispatch) => { const sel = state.selection; if (!(sel instanceof CellSelection)) { return false; } const cell = state.schema.nodes.td.createAndFill(); if (cell == null) { throw new Error("Unable to build cell"); } if (dispatch !== undefined) { const tr = state.tr; const baseContent = cell.content; sel.forEachCell((cell, pos) => { if (!cell.content.eq(baseContent)) { tr.replace(tr.mapping.map(pos + 1), tr.mapping.map(pos + cell.nodeSize - 1), new Slice(baseContent, 0, 0)); } }); if (tr.docChanged) { dispatch(tr); } } return true; }; function jumpCellBarrierCommand(axis: CartesianAxis, bias: Bias): Command { return (state, dispatch, view) => { const sel = state.selection; if (sel instanceof CellSelection) { if (dispatch !== undefined) { dispatch(state.tr.setSelection(Selection.near(sel.$headCell, bias == Bias.BACKWARD ? -1 : 1))); } return true; } if (axis != CartesianAxis.HORIZONTAL && !sel.empty) { return false; } const end = view !== undefined ? atEndOfCell(view, axis, bias) : null; if (end === null) { return false; } if (axis == CartesianAxis.HORIZONTAL) { if (dispatch !== undefined) { dispatch( state.tr.setSelection( Selection.near(state.doc.resolve(sel.head + (bias == Bias.BACKWARD ? -1 : 1)), bias == Bias.BACKWARD ? -1 : 1) ) ); } return true; } else { const $cell = state.doc.resolve(end); const $next = nextCell($cell, axis, bias); let newSel: Selection; if ($next !== null) { newSel = Selection.near($next, 1); } else if (bias == Bias.BACKWARD) { newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1); } else { newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1); } if (dispatch !== undefined) { dispatch(state.tr.setSelection(newSel)); } return true; } }; } function selectCellWithCommand(axis: CartesianAxis, dir: Bias): Command { return (state, dispatch, view) => { let sel: CellSelection; if (state.selection instanceof CellSelection) { sel = state.selection; } else { const end = view !== undefined ? atEndOfCell(view, axis, dir) : null; if (end === null) { return false; } sel = new CellSelection(state.doc.resolve(end)); } const $head = nextCell(sel.$headCell, axis, dir); if ($head === null) { return false; } if (dispatch !== undefined) { dispatch(state.tr.setSelection(new CellSelection(sel.$anchorCell, $head))); } return true; }; } export const ux = { [UxCommand.ArrowUp]: jumpCellBarrierUp, [UxCommand.ArrowDown]: jumpCellBarrierDown, [UxCommand.ArrowLeft]: jumpCellBarrierLeft, [UxCommand.ArrowRight]: jumpCellBarrierRight, [UxCommand.InsertTable]: insertTable, [UxCommand.SelectAll]: selectAll, [UxCommand.ShiftArrowUp]: selectCellWithUp, [UxCommand.ShiftArrowDown]: selectCellWithDown, [UxCommand.ShiftArrowLeft]: selectCellWithLeft, [UxCommand.ShiftArrowRight]: selectCellWithRight, [UxCommand.ToggleTableHeaderCell]: toggleHeaderCell, [UxCommand.DeleteBackward]: deleteCellSelection, [UxCommand.DeleteForward]: deleteCellSelection };