diff --git a/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js b/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js index a311fd8dd2d193e9734b292015a3071b4bbd406a..3b5d8418555b9c93f9261b93d4cd9bb65029696f 100644 --- a/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js +++ b/wax-prosemirror-core/src/config/defaultServices/MenuService/MenuService.js @@ -1,3 +1,4 @@ +/* eslint-disable consistent-return */ import { isPlainObject, isFunction } from 'lodash'; import Service from '../../../Service'; import Menu from './Menu'; diff --git a/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js b/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js index 31cda6580d6967ebf6b321b1699bcb91f435e27a..7dbbdf0e4aba0541573f9c399850b1a2dd278c3d 100644 --- a/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js +++ b/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js @@ -155,7 +155,6 @@ const findAllMarksWithSameId = (state, mark) => { } }); }); - console.log(allMarksWithSameId); return allMarksWithSameId; }; diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts b/wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts new file mode 100644 index 0000000000000000000000000000000000000000..e092dfe8e34c5d8af0e27875f68ecbe3b7d9f0d2 --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/cellselection.ts @@ -0,0 +1,464 @@ +// This file defines a ProseMirror selection subclass that models +// table cell selections. The table plugin needs to be active to wire +// in the user interaction part of table selections (so that you +// actually get such selections when you select across cells). + +import { Fragment, Node, ResolvedPos, Slice } from 'prosemirror-model'; +import { + EditorState, + NodeSelection, + Selection, + SelectionRange, + TextSelection, + Transaction, +} from 'prosemirror-state'; +import { Decoration, DecorationSet, DecorationSource } from 'prosemirror-view'; + +import { Mappable } from 'prosemirror-transform'; +import { TableMap } from './tablemap'; +import { CellAttrs, inSameTable, pointsAtCell, removeColSpan } from './util'; + +/** + * @public + */ +export interface CellSelectionJSON { + type: string; + anchor: number; + head: number; +} + +/** + * A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection) + * subclass that represents a cell selection spanning part of a table. + * With the plugin enabled, these will be created when the user + * selects across cells, and will be drawn by giving selected cells a + * `selectedCell` CSS class. + * + * @public + */ +export class CellSelection extends Selection { + // A resolved position pointing _in front of_ the anchor cell (the one + // that doesn't move when extending the selection). + public $anchorCell: ResolvedPos; + + // A resolved position pointing in front of the head cell (the one + // moves when extending the selection). + public $headCell: ResolvedPos; + + // A table selection is identified by its anchor and head cells. The + // positions given to this constructor should point _before_ two + // cells in the same table. They may be the same, to select a single + // cell. + constructor($anchorCell: ResolvedPos, $headCell: ResolvedPos = $anchorCell) { + const table = $anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = $anchorCell.start(-1); + const rect = map.rectBetween( + $anchorCell.pos - tableStart, + $headCell.pos - tableStart, + ); + + const doc = $anchorCell.node(0); + const cells = map + .cellsInRect(rect) + .filter((p) => p != $headCell.pos - tableStart); + // Make the head cell the first range, so that it counts as the + // primary part of the selection + cells.unshift($headCell.pos - tableStart); + const ranges = cells.map((pos) => { + const cell = table.nodeAt(pos); + if (!cell) { + throw RangeError(`No cell with offset ${pos} found`); + } + const from = tableStart + pos + 1; + return new SelectionRange( + doc.resolve(from), + doc.resolve(from + cell.content.size), + ); + }); + super(ranges[0].$from, ranges[0].$to, ranges); + this.$anchorCell = $anchorCell; + this.$headCell = $headCell; + } + + public map(doc: Node, mapping: Mappable): CellSelection | Selection { + const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos)); + const $headCell = doc.resolve(mapping.map(this.$headCell.pos)); + if ( + pointsAtCell($anchorCell) && + pointsAtCell($headCell) && + inSameTable($anchorCell, $headCell) + ) { + const tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1); + if (tableChanged && this.isRowSelection()) + return CellSelection.rowSelection($anchorCell, $headCell); + else if (tableChanged && this.isColSelection()) + return CellSelection.colSelection($anchorCell, $headCell); + else return new CellSelection($anchorCell, $headCell); + } + return TextSelection.between($anchorCell, $headCell); + } + + // Returns a rectangular slice of table rows containing the selected + // cells. + public content(): Slice { + const table = this.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = this.$anchorCell.start(-1); + + const rect = map.rectBetween( + this.$anchorCell.pos - tableStart, + this.$headCell.pos - tableStart, + ); + const seen: Record<number, boolean> = {}; + const rows = []; + for (let row = rect.top; row < rect.bottom; row++) { + const rowContent = []; + for ( + let index = row * map.width + rect.left, col = rect.left; + col < rect.right; + col++, index++ + ) { + const pos = map.map[index]; + if (seen[pos]) continue; + seen[pos] = true; + + const cellRect = map.findCell(pos); + let cell = table.nodeAt(pos); + if (!cell) { + throw RangeError(`No cell with offset ${pos} found`); + } + + const extraLeft = rect.left - cellRect.left; + const extraRight = cellRect.right - rect.right; + + if (extraLeft > 0 || extraRight > 0) { + let attrs = cell.attrs as CellAttrs; + if (extraLeft > 0) { + attrs = removeColSpan(attrs, 0, extraLeft); + } + if (extraRight > 0) { + attrs = removeColSpan( + attrs, + attrs.colspan - extraRight, + extraRight, + ); + } + if (cellRect.left < rect.left) { + cell = cell.type.createAndFill(attrs); + if (!cell) { + throw RangeError( + `Could not create cell with attrs ${JSON.stringify(attrs)}`, + ); + } + } else { + cell = cell.type.create(attrs, cell.content); + } + } + if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) { + const attrs = { + ...cell.attrs, + rowspan: + Math.min(cellRect.bottom, rect.bottom) - + Math.max(cellRect.top, rect.top), + }; + if (cellRect.top < rect.top) { + cell = cell.type.createAndFill(attrs)!; + } else { + cell = cell.type.create(attrs, cell.content); + } + } + rowContent.push(cell); + } + rows.push(table.child(row).copy(Fragment.from(rowContent))); + } + + const fragment = + this.isColSelection() && this.isRowSelection() ? table : rows; + return new Slice(Fragment.from(fragment), 1, 1); + } + + public replace(tr: Transaction, content: Slice = Slice.empty): void { + const mapFrom = tr.steps.length, + ranges = this.ranges; + for (let i = 0; i < ranges.length; i++) { + const { $from, $to } = ranges[i], + mapping = tr.mapping.slice(mapFrom); + tr.replace( + mapping.map($from.pos), + mapping.map($to.pos), + i ? Slice.empty : content, + ); + } + const sel = Selection.findFrom( + tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)), + -1, + ); + if (sel) tr.setSelection(sel); + } + + public replaceWith(tr: Transaction, node: Node): void { + this.replace(tr, new Slice(Fragment.from(node), 0, 0)); + } + + public forEachCell(f: (node: Node, pos: number) => void): void { + const table = this.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = this.$anchorCell.start(-1); + + const cells = map.cellsInRect( + map.rectBetween( + this.$anchorCell.pos - tableStart, + this.$headCell.pos - tableStart, + ), + ); + for (let i = 0; i < cells.length; i++) { + f(table.nodeAt(cells[i])!, tableStart + cells[i]); + } + } + + // True if this selection goes all the way from the top to the + // bottom of the table. + public isColSelection(): boolean { + const anchorTop = this.$anchorCell.index(-1); + const headTop = this.$headCell.index(-1); + if (Math.min(anchorTop, headTop) > 0) return false; + + const anchorBottom = anchorTop + this.$anchorCell.nodeAfter!.attrs.rowspan; + const headBottom = headTop + this.$headCell.nodeAfter!.attrs.rowspan; + + return ( + Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount + ); + } + + // Returns the smallest column selection that covers the given anchor + // and head cell. + public static colSelection( + $anchorCell: ResolvedPos, + $headCell: ResolvedPos = $anchorCell, + ): CellSelection { + const table = $anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = $anchorCell.start(-1); + + const anchorRect = map.findCell($anchorCell.pos - tableStart); + const headRect = map.findCell($headCell.pos - tableStart); + const doc = $anchorCell.node(0); + + if (anchorRect.top <= headRect.top) { + if (anchorRect.top > 0) + $anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]); + if (headRect.bottom < map.height) + $headCell = doc.resolve( + tableStart + + map.map[map.width * (map.height - 1) + headRect.right - 1], + ); + } else { + if (headRect.top > 0) + $headCell = doc.resolve(tableStart + map.map[headRect.left]); + if (anchorRect.bottom < map.height) + $anchorCell = doc.resolve( + tableStart + + map.map[map.width * (map.height - 1) + anchorRect.right - 1], + ); + } + return new CellSelection($anchorCell, $headCell); + } + + // True if this selection goes all the way from the left to the + // right of the table. + public isRowSelection(): boolean { + const table = this.$anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = this.$anchorCell.start(-1); + + const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart); + const headLeft = map.colCount(this.$headCell.pos - tableStart); + if (Math.min(anchorLeft, headLeft) > 0) return false; + + const anchorRight = anchorLeft + this.$anchorCell.nodeAfter!.attrs.colspan; + const headRight = headLeft + this.$headCell.nodeAfter!.attrs.colspan; + return Math.max(anchorRight, headRight) == map.width; + } + + public eq(other: unknown): boolean { + return ( + other instanceof CellSelection && + other.$anchorCell.pos == this.$anchorCell.pos && + other.$headCell.pos == this.$headCell.pos + ); + } + + // Returns the smallest row selection that covers the given anchor + // and head cell. + public static rowSelection( + $anchorCell: ResolvedPos, + $headCell: ResolvedPos = $anchorCell, + ): CellSelection { + const table = $anchorCell.node(-1); + const map = TableMap.get(table); + const tableStart = $anchorCell.start(-1); + + const anchorRect = map.findCell($anchorCell.pos - tableStart); + const headRect = map.findCell($headCell.pos - tableStart); + const doc = $anchorCell.node(0); + + if (anchorRect.left <= headRect.left) { + if (anchorRect.left > 0) + $anchorCell = doc.resolve( + tableStart + map.map[anchorRect.top * map.width], + ); + if (headRect.right < map.width) + $headCell = doc.resolve( + tableStart + map.map[map.width * (headRect.top + 1) - 1], + ); + } else { + if (headRect.left > 0) + $headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]); + if (anchorRect.right < map.width) + $anchorCell = doc.resolve( + tableStart + map.map[map.width * (anchorRect.top + 1) - 1], + ); + } + return new CellSelection($anchorCell, $headCell); + } + + public toJSON(): CellSelectionJSON { + return { + type: 'cell', + anchor: this.$anchorCell.pos, + head: this.$headCell.pos, + }; + } + + static fromJSON(doc: Node, json: CellSelectionJSON): CellSelection { + return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head)); + } + + static create( + doc: Node, + anchorCell: number, + headCell: number = anchorCell, + ): CellSelection { + return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell)); + } + + getBookmark(): CellBookmark { + return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos); + } +} + +CellSelection.prototype.visible = false; + +Selection.jsonID('cell', CellSelection); + +/** + * @public + */ +export class CellBookmark { + constructor(public anchor: number, public head: number) {} + + map(mapping: Mappable): CellBookmark { + return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head)); + } + + resolve(doc: Node): CellSelection | Selection { + const $anchorCell = doc.resolve(this.anchor), + $headCell = doc.resolve(this.head); + if ( + $anchorCell.parent.type.spec.tableRole == 'row' && + $headCell.parent.type.spec.tableRole == 'row' && + $anchorCell.index() < $anchorCell.parent.childCount && + $headCell.index() < $headCell.parent.childCount && + inSameTable($anchorCell, $headCell) + ) + return new CellSelection($anchorCell, $headCell); + else return Selection.near($headCell, 1); + } +} + +export function drawCellSelection(state: EditorState): DecorationSource | null { + if (!(state.selection instanceof CellSelection)) return null; + const cells: Decoration[] = []; + state.selection.forEachCell((node, pos) => { + cells.push( + Decoration.node(pos, pos + node.nodeSize, { class: 'selectedCell' }), + ); + }); + return DecorationSet.create(state.doc, cells); +} + +function isCellBoundarySelection({ $from, $to }: TextSelection) { + if ($from.pos == $to.pos || $from.pos < $from.pos - 6) return false; // Cheap elimination + let afterFrom = $from.pos; + let beforeTo = $to.pos; + let depth = $from.depth; + for (; depth >= 0; depth--, afterFrom++) + if ($from.after(depth + 1) < $from.end(depth)) break; + for (let d = $to.depth; d >= 0; d--, beforeTo--) + if ($to.before(d + 1) > $to.start(d)) break; + return ( + afterFrom == beforeTo && + /row|table/.test($from.node(depth).type.spec.tableRole) + ); +} + +function isTextSelectionAcrossCells({ $from, $to }: TextSelection) { + let fromCellBoundaryNode: Node | undefined; + let toCellBoundaryNode: Node | undefined; + + for (let i = $from.depth; i > 0; i--) { + const node = $from.node(i); + if ( + node.type.spec.tableRole === 'cell' || + node.type.spec.tableRole === 'header_cell' + ) { + fromCellBoundaryNode = node; + break; + } + } + + for (let i = $to.depth; i > 0; i--) { + const node = $to.node(i); + if ( + node.type.spec.tableRole === 'cell' || + node.type.spec.tableRole === 'header_cell' + ) { + toCellBoundaryNode = node; + break; + } + } + + return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0; +} + +export function normalizeSelection( + state: EditorState, + tr: Transaction | undefined, + allowTableNodeSelection: boolean, +): Transaction | undefined { + const sel = (tr || state).selection; + const doc = (tr || state).doc; + let normalize: Selection | undefined; + let role: string | undefined; + if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) { + if (role == 'cell' || role == 'header_cell') { + normalize = CellSelection.create(doc, sel.from); + } else if (role == 'row') { + const $cell = doc.resolve(sel.from + 1); + normalize = CellSelection.rowSelection($cell, $cell); + } else if (!allowTableNodeSelection) { + const map = TableMap.get(sel.node); + const start = sel.from + 1; + const lastCell = start + map.map[map.width * map.height - 1]; + normalize = CellSelection.create(doc, start + 1, lastCell); + } + } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) { + normalize = TextSelection.create(doc, sel.from); + } else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) { + normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end()); + } + if (normalize) (tr || (tr = state.tr)).setSelection(normalize); + return tr; +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts b/wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed20192061032737a569ec45b98e3be69e257e6b --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/columnresizing.ts @@ -0,0 +1,383 @@ +import { Attrs, Node as ProsemirrorNode } from 'prosemirror-model'; +import { EditorState, Plugin, PluginKey, Transaction } from 'prosemirror-state'; +import { + Decoration, + DecorationSet, + EditorView, + NodeView, +} from 'prosemirror-view'; +import { tableNodeTypes } from './schema'; +import { TableMap } from './tablemap'; +import { TableView, updateColumnsOnResize } from './tableview'; +import { cellAround, CellAttrs, pointsAtCell } from './util'; + +/** + * @public + */ +export const columnResizingPluginKey = new PluginKey<ResizeState>( + 'tableColumnResizing', +); + +/** + * @public + */ +export type ColumnResizingOptions = { + handleWidth?: number; + cellMinWidth?: number; + lastColumnResizable?: boolean; + View?: new ( + node: ProsemirrorNode, + cellMinWidth: number, + view: EditorView, + ) => NodeView; +}; + +/** + * @public + */ +export type Dragging = { startX: number; startWidth: number }; + +/** + * @public + */ +export function columnResizing({ + handleWidth = 5, + cellMinWidth = 25, + View = TableView, + lastColumnResizable = true, +}: ColumnResizingOptions = {}): Plugin { + const plugin = new Plugin<ResizeState>({ + key: columnResizingPluginKey, + state: { + init(_, state) { + plugin.spec!.props!.nodeViews![ + tableNodeTypes(state.schema).table.name + ] = (node, view) => new View(node, cellMinWidth, view); + return new ResizeState(-1, false); + }, + apply(tr, prev) { + return prev.apply(tr); + }, + }, + props: { + attributes: (state): Record<string, string> => { + const pluginState = columnResizingPluginKey.getState(state); + return pluginState && pluginState.activeHandle > -1 + ? { class: 'resize-cursor' } + : {}; + }, + + handleDOMEvents: { + mousemove: (view, event) => { + handleMouseMove( + view, + event, + handleWidth, + cellMinWidth, + lastColumnResizable, + ); + }, + mouseleave: (view) => { + handleMouseLeave(view); + }, + mousedown: (view, event) => { + handleMouseDown(view, event, cellMinWidth); + }, + }, + + decorations: (state) => { + const pluginState = columnResizingPluginKey.getState(state); + if (pluginState && pluginState.activeHandle > -1) { + return handleDecorations(state, pluginState.activeHandle); + } + }, + + nodeViews: {}, + }, + }); + return plugin; +} + +/** + * @public + */ +export class ResizeState { + constructor(public activeHandle: number, public dragging: Dragging | false) {} + + apply(tr: Transaction): ResizeState { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const state = this; + const action = tr.getMeta(columnResizingPluginKey); + if (action && action.setHandle != null) + return new ResizeState(action.setHandle, false); + if (action && action.setDragging !== undefined) + return new ResizeState(state.activeHandle, action.setDragging); + if (state.activeHandle > -1 && tr.docChanged) { + let handle = tr.mapping.map(state.activeHandle, -1); + if (!pointsAtCell(tr.doc.resolve(handle))) { + handle = -1; + } + return new ResizeState(handle, state.dragging); + } + return state; + } +} + +function handleMouseMove( + view: EditorView, + event: MouseEvent, + handleWidth: number, + cellMinWidth: number, + lastColumnResizable: boolean, +): void { + const pluginState = columnResizingPluginKey.getState(view.state); + if (!pluginState) return; + + if (!pluginState.dragging) { + const target = domCellAround(event.target as HTMLElement); + let cell = -1; + if (target) { + const { left, right } = target.getBoundingClientRect(); + if (event.clientX - left <= handleWidth) + cell = edgeCell(view, event, 'left', handleWidth); + else if (right - event.clientX <= handleWidth) + cell = edgeCell(view, event, 'right', handleWidth); + } + + if (cell != pluginState.activeHandle) { + if (!lastColumnResizable && cell !== -1) { + const $cell = view.state.doc.resolve(cell); + const table = $cell.node(-1); + const map = TableMap.get(table); + const tableStart = $cell.start(-1); + const col = + map.colCount($cell.pos - tableStart) + + $cell.nodeAfter!.attrs.colspan - + 1; + + if (col == map.width - 1) { + return; + } + } + + updateHandle(view, cell); + } + } +} + +function handleMouseLeave(view: EditorView): void { + const pluginState = columnResizingPluginKey.getState(view.state); + if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) + updateHandle(view, -1); +} + +function handleMouseDown( + view: EditorView, + event: MouseEvent, + cellMinWidth: number, +): boolean { + const pluginState = columnResizingPluginKey.getState(view.state); + if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) + return false; + + const cell = view.state.doc.nodeAt(pluginState.activeHandle)!; + const width = currentColWidth(view, pluginState.activeHandle, cell.attrs); + view.dispatch( + view.state.tr.setMeta(columnResizingPluginKey, { + setDragging: { startX: event.clientX, startWidth: width }, + }), + ); + + function finish(event: MouseEvent) { + window.removeEventListener('mouseup', finish); + window.removeEventListener('mousemove', move); + const pluginState = columnResizingPluginKey.getState(view.state); + if (pluginState?.dragging) { + updateColumnWidth( + view, + pluginState.activeHandle, + draggedWidth(pluginState.dragging, event, cellMinWidth), + ); + view.dispatch( + view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }), + ); + } + } + + function move(event: MouseEvent): void { + if (!event.which) return finish(event); + const pluginState = columnResizingPluginKey.getState(view.state); + if (!pluginState) return; + if (pluginState.dragging) { + const dragged = draggedWidth(pluginState.dragging, event, cellMinWidth); + displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth); + } + } + + window.addEventListener('mouseup', finish); + window.addEventListener('mousemove', move); + event.preventDefault(); + return true; +} + +function currentColWidth( + view: EditorView, + cellPos: number, + { colspan, colwidth }: Attrs, +): number { + const width = colwidth && colwidth[colwidth.length - 1]; + if (width) return width; + const dom = view.domAtPos(cellPos); + const node = dom.node.childNodes[dom.offset] as HTMLElement; + let domWidth = node.offsetWidth, + parts = colspan; + if (colwidth) + for (let i = 0; i < colspan; i++) + if (colwidth[i]) { + domWidth -= colwidth[i]; + parts--; + } + return domWidth / parts; +} + +function domCellAround(target: HTMLElement | null): HTMLElement | null { + while (target && target.nodeName != 'TD' && target.nodeName != 'TH') + target = + target.classList && target.classList.contains('ProseMirror') + ? null + : (target.parentNode as HTMLElement); + return target; +} + +function edgeCell( + view: EditorView, + event: MouseEvent, + side: 'left' | 'right', + handleWidth: number, +): number { + // posAtCoords returns inconsistent positions when cursor is moving + // across a collapsed table border. Use an offset to adjust the + // target viewport coordinates away from the table border. + const offset = side == 'right' ? -handleWidth : handleWidth; + const found = view.posAtCoords({ + left: event.clientX + offset, + top: event.clientY, + }); + if (!found) return -1; + const { pos } = found; + const $cell = cellAround(view.state.doc.resolve(pos)); + if (!$cell) return -1; + if (side == 'right') return $cell.pos; + const map = TableMap.get($cell.node(-1)), + start = $cell.start(-1); + const index = map.map.indexOf($cell.pos - start); + return index % map.width == 0 ? -1 : start + map.map[index - 1]; +} + +function draggedWidth( + dragging: Dragging, + event: MouseEvent, + cellMinWidth: number, +): number { + const offset = event.clientX - dragging.startX; + return Math.max(cellMinWidth, dragging.startWidth + offset); +} + +function updateHandle(view: EditorView, value: number): void { + view.dispatch( + view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }), + ); +} + +function updateColumnWidth( + view: EditorView, + cell: number, + width: number, +): void { + const $cell = view.state.doc.resolve(cell); + const table = $cell.node(-1), + map = TableMap.get(table), + start = $cell.start(-1); + const col = + map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1; + const tr = view.state.tr; + for (let row = 0; row < map.height; row++) { + const mapIndex = row * map.width + col; + // Rowspanning cell that has already been handled + if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue; + const pos = map.map[mapIndex]; + const attrs = table.nodeAt(pos)!.attrs as CellAttrs; + const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos); + if (attrs.colwidth && attrs.colwidth[index] == width) continue; + const colwidth = attrs.colwidth + ? attrs.colwidth.slice() + : zeroes(attrs.colspan); + colwidth[index] = width; + tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth }); + } + if (tr.docChanged) view.dispatch(tr); +} + +function displayColumnWidth( + view: EditorView, + cell: number, + width: number, + cellMinWidth: number, +): void { + const $cell = view.state.doc.resolve(cell); + const table = $cell.node(-1), + start = $cell.start(-1); + const col = + TableMap.get(table).colCount($cell.pos - start) + + $cell.nodeAfter!.attrs.colspan - + 1; + let dom: Node | null = view.domAtPos($cell.start(-1)).node; + while (dom && dom.nodeName != 'TABLE') { + dom = dom.parentNode; + } + if (!dom) return; + updateColumnsOnResize( + table, + dom.firstChild as HTMLTableColElement, + dom as HTMLTableElement, + cellMinWidth, + col, + width, + ); +} + +function zeroes(n: number): 0[] { + return Array(n).fill(0); +} + +export function handleDecorations( + state: EditorState, + cell: number, +): DecorationSet { + const decorations = []; + const $cell = state.doc.resolve(cell); + const table = $cell.node(-1); + if (!table) { + return DecorationSet.empty; + } + const map = TableMap.get(table); + const start = $cell.start(-1); + const col = map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan; + for (let row = 0; row < map.height; row++) { + const index = col + row * map.width - 1; + // For positions that have either a different cell or the end + // of the table to their right, and either the top of the table or + // a different cell above them, add a decoration + if ( + (col == map.width || map.map[index] != map.map[index + 1]) && + (row == 0 || map.map[index] != map.map[index - map.width]) + ) { + const cellPos = map.map[index]; + const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1; + const dom = document.createElement('div'); + dom.className = 'column-resize-handle'; + decorations.push(Decoration.widget(pos, dom)); + } + } + return DecorationSet.create(state.doc, decorations); +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/commands.ts b/wax-prosemirror-services/src/TablesService/tableSrc/commands.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f8a9c704399619bd448440b73538e5d954f4c05 --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/commands.ts @@ -0,0 +1,852 @@ +// This file defines a number of table-related commands. + +import { Fragment, Node, NodeType, ResolvedPos } from 'prosemirror-model'; +import { + Command, + EditorState, + TextSelection, + Transaction, +} from 'prosemirror-state'; + +import { CellSelection } from './cellselection'; +import type { Direction } from './input'; +import { tableNodeTypes, TableRole } from './schema'; +import { Rect, TableMap } from './tablemap'; +import { + addColSpan, + cellAround, + CellAttrs, + cellWrapping, + columnIsHeader, + isInTable, + moveCellForward, + removeColSpan, + selectionCell, +} from './util'; + +/** + * @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; + const $pos = selectionCell(state); + const table = $pos.node(-1); + const tableStart = $pos.start(-1); + const map = TableMap.get(table); + const rect = + sel instanceof CellSelection + ? map.rectBetween( + sel.$anchorCell.pos - tableStart, + sel.$headCell.pos - tableStart, + ) + : map.findCell($pos.pos - tableStart); + return { ...rect, tableStart, map, table }; +} + +/** + * 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: number | null = 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]; + const cell = table.nodeAt(pos)!; + tr.setNodeMarkup( + tr.mapping.map(tableStart + pos), + null, + addColSpan(cell.attrs as CellAttrs, 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; + const pos = map.map[index]; + const cell = table.nodeAt(pos)!; + const attrs = cell.attrs as CellAttrs; + // 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(attrs, col - map.colCount(pos)), + ); + } else { + const start = tr.mapping.slice(mapStart).map(tableStart + pos); + tr.delete(start, start + cell.nodeSize); + } + row += 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); + const 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; + const table = rect.tableStart + ? tr.doc.nodeAt(rect.tableStart - 1) + : tr.doc; + if (!table) { + throw RangeError('No table found'); + } + rect.table = table; + rect.map = TableMap.get(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: number | null = 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]; + const attrs = table.nodeAt(pos)!.attrs; + tr.setNodeMarkup(tableStart + pos, null, { + ...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; + const node = type?.createAndFill(); + if (node) cells.push(node); + } + } + 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 as CellAttrs; + tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + tableStart), null, { + ...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 attrs = cell.attrs as CellAttrs; + const copy = cell.type.create( + { ...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 += 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; + const table = rect.tableStart + ? tr.doc.nodeAt(rect.tableStart - 1) + : tr.doc; + if (!table) { + throw RangeError('No table found'); + } + rect.table = table; + rect.map = TableMap.get(rect.table); + } + dispatch(tr); + } + return true; +} + +function isEmpty(cell: Node): boolean { + const c = cell.content; + + return ( + c.childCount == 1 && c.child(0).isTextblock && c.child(0).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: Record<number, boolean> = {}; + let content = Fragment.empty; + let mergedPos: number | undefined; + let mergedCell: Node | undefined; + 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]; + const cell = rect.table.nodeAt(cellPos); + if (seen[cellPos] || !cell) 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); + } + } + } + if (mergedPos == null || mergedCell == null) { + return true; + } + + tr.setNodeMarkup(mergedPos + rect.tableStart, null, { + ...addColSpan( + mergedCell.attrs as CellAttrs, + 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 as 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: Node | null | undefined; + let cellPos: number | undefined; + 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 == null || cellPos == null) { + return false; + } + 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 = { ...baseAttrs, rowspan: 1 }; + if (baseAttrs.colspan > 1) baseAttrs = { ...baseAttrs, colspan: 1 }; + const rect = selectedRect(state), + tr = state.tr; + for (let i = 0; i < rect.right - rect.left; i++) + attrs.push( + colwidth + ? { + ...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) : 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. + * + * @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, { + ...node.attrs, + [name]: value, + }); + }); + else + tr.setNodeMarkup($cell.pos, null, { + ...$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<string, NodeType>, +): 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 | null { + 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); + const lastChild = rowNode.lastChild; + if (lastChild) { + return rowEnd - 1 - 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; + } + } + return null; +} + +/** + * 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; +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts b/wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts new file mode 100644 index 0000000000000000000000000000000000000000..565bc815c8737c7001075c40070799fa000f19be --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/copypaste.ts @@ -0,0 +1,381 @@ +// Utilities used for copy/paste handling. +// +// This module handles pasting cell content into tables, or pasting +// anything into a cell selection, as replacing a block of cells with +// the content of the selection. When pasting cells into a cell, that +// involves placing the block of pasted content so that its top left +// aligns with the selection cell, optionally extending the table to +// the right or bottom to make sure it is large enough. Pasting into a +// cell selection is different, here the cells in the selection are +// clipped to the selection's rectangle, optionally repeating the +// pasted cells when they are smaller than the selection. + +import { Fragment, Node, NodeType, Schema, Slice } from 'prosemirror-model'; +import { Transform } from 'prosemirror-transform'; + +import { EditorState, Transaction } from 'prosemirror-state'; +import { CellSelection } from './cellselection'; +import { tableNodeTypes } from './schema'; +import { ColWidths, Rect, TableMap } from './tablemap'; +import { CellAttrs, removeColSpan } from './util'; + +/** + * @internal + */ +export type Area = { width: number; height: number; rows: Fragment[] }; + +// Utilities to help with copying and pasting table cells + +/** + * Get a rectangular area of cells from a slice, or null if the outer + * nodes of the slice aren't table cells or rows. + * + * @internal + */ +export function pastedCells(slice: Slice): Area | null { + if (!slice.size) return null; + let { content, openStart, openEnd } = slice; + while ( + content.childCount == 1 && + ((openStart > 0 && openEnd > 0) || + content.child(0).type.spec.tableRole == 'table') + ) { + openStart--; + openEnd--; + content = content.child(0).content; + } + const first = content.child(0); + const role = first.type.spec.tableRole; + const schema = first.type.schema, + rows = []; + if (role == 'row') { + for (let i = 0; i < content.childCount; i++) { + let cells = content.child(i).content; + const left = i ? 0 : Math.max(0, openStart - 1); + const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1); + if (left || right) + cells = fitSlice( + tableNodeTypes(schema).row, + new Slice(cells, left, right), + ).content; + rows.push(cells); + } + } else if (role == 'cell' || role == 'header_cell') { + rows.push( + openStart || openEnd + ? fitSlice( + tableNodeTypes(schema).row, + new Slice(content, openStart, openEnd), + ).content + : content, + ); + } else { + return null; + } + return ensureRectangular(schema, rows); +} + +// Compute the width and height of a set of cells, and make sure each +// row has the same number of cells. +function ensureRectangular(schema: Schema, rows: Fragment[]): Area { + const widths: ColWidths = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + for (let j = row.childCount - 1; j >= 0; j--) { + const { rowspan, colspan } = row.child(j).attrs; + for (let r = i; r < i + rowspan; r++) + widths[r] = (widths[r] || 0) + colspan; + } + } + let width = 0; + for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]); + for (let r = 0; r < widths.length; r++) { + if (r >= rows.length) rows.push(Fragment.empty); + if (widths[r] < width) { + const empty = tableNodeTypes(schema).cell.createAndFill()!; + const cells = []; + for (let i = widths[r]; i < width; i++) { + cells.push(empty); + } + rows[r] = rows[r].append(Fragment.from(cells)); + } + } + return { height: rows.length, width, rows }; +} + +export function fitSlice(nodeType: NodeType, slice: Slice): Node { + const node = nodeType.createAndFill()!; + const tr = new Transform(node).replace(0, node.content.size, slice); + return tr.doc; +} + +/** + * Clip or extend (repeat) the given set of cells to cover the given + * width and height. Will clip rowspan/colspan cells at the edges when + * they stick out. + * + * @internal + */ +export function clipCells( + { width, height, rows }: Area, + newWidth: number, + newHeight: number, +): Area { + if (width != newWidth) { + const added: number[] = []; + const newRows: Fragment[] = []; + for (let row = 0; row < rows.length; row++) { + const frag = rows[row], + cells = []; + for (let col = added[row] || 0, i = 0; col < newWidth; i++) { + let cell = frag.child(i % frag.childCount); + if (col + cell.attrs.colspan > newWidth) + cell = cell.type.createChecked( + removeColSpan( + cell.attrs as CellAttrs, + cell.attrs.colspan, + col + cell.attrs.colspan - newWidth, + ), + cell.content, + ); + cells.push(cell); + col += cell.attrs.colspan; + for (let j = 1; j < cell.attrs.rowspan; j++) + added[row + j] = (added[row + j] || 0) + cell.attrs.colspan; + } + newRows.push(Fragment.from(cells)); + } + rows = newRows; + width = newWidth; + } + + if (height != newHeight) { + const newRows = []; + for (let row = 0, i = 0; row < newHeight; row++, i++) { + const cells = [], + source = rows[i % height]; + for (let j = 0; j < source.childCount; j++) { + let cell = source.child(j); + if (row + cell.attrs.rowspan > newHeight) + cell = cell.type.create( + { + ...cell.attrs, + rowspan: Math.max(1, newHeight - cell.attrs.rowspan), + }, + cell.content, + ); + cells.push(cell); + } + newRows.push(Fragment.from(cells)); + } + rows = newRows; + height = newHeight; + } + + return { width, height, rows }; +} + +// Make sure a table has at least the given width and height. Return +// true if something was changed. +function growTable( + tr: Transaction, + map: TableMap, + table: Node, + start: number, + width: number, + height: number, + mapFrom: number, +): boolean { + const schema = tr.doc.type.schema; + const types = tableNodeTypes(schema); + let empty; + let emptyHead; + if (width > map.width) { + for (let row = 0, rowEnd = 0; row < map.height; row++) { + const rowNode = table.child(row); + rowEnd += rowNode.nodeSize; + const cells: Node[] = []; + let add: Node; + if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell) + add = empty || (empty = types.cell.createAndFill()!); + else add = emptyHead || (emptyHead = types.header_cell.createAndFill()!); + for (let i = map.width; i < width; i++) cells.push(add); + tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells); + } + } + if (height > map.height) { + const cells = []; + for ( + let i = 0, start = (map.height - 1) * map.width; + i < Math.max(map.width, width); + i++ + ) { + const header = + i >= map.width + ? false + : table.nodeAt(map.map[start + i])!.type == types.header_cell; + cells.push( + header + ? emptyHead || (emptyHead = types.header_cell.createAndFill()!) + : empty || (empty = types.cell.createAndFill()!), + ); + } + + const emptyRow = types.row.create(null, Fragment.from(cells)), + rows = []; + for (let i = map.height; i < height; i++) rows.push(emptyRow); + tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows); + } + return !!(empty || emptyHead); +} + +// Make sure the given line (left, top) to (right, top) doesn't cross +// any rowspan cells by splitting cells that cross it. Return true if +// something changed. +function isolateHorizontal( + tr: Transaction, + map: TableMap, + table: Node, + start: number, + left: number, + right: number, + top: number, + mapFrom: number, +): boolean { + if (top == 0 || top == map.height) return false; + let found = false; + for (let col = left; col < right; col++) { + const index = top * map.width + col, + pos = map.map[index]; + if (map.map[index - map.width] == pos) { + found = true; + const cell = table.nodeAt(pos)!; + const { top: cellTop, left: cellLeft } = map.findCell(pos); + tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + start), null, { + ...cell.attrs, + rowspan: top - cellTop, + }); + tr.insert( + tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)), + cell.type.createAndFill({ + ...cell.attrs, + rowspan: cellTop + cell.attrs.rowspan - top, + })!, + ); + col += cell.attrs.colspan - 1; + } + } + return found; +} + +// Make sure the given line (left, top) to (left, bottom) doesn't +// cross any colspan cells by splitting cells that cross it. Return +// true if something changed. +function isolateVertical( + tr: Transaction, + map: TableMap, + table: Node, + start: number, + top: number, + bottom: number, + left: number, + mapFrom: number, +): boolean { + if (left == 0 || left == map.width) return false; + let found = false; + for (let row = top; row < bottom; row++) { + const index = row * map.width + left, + pos = map.map[index]; + if (map.map[index - 1] == pos) { + found = true; + const cell = table.nodeAt(pos)!; + const cellLeft = map.colCount(pos); + const updatePos = tr.mapping.slice(mapFrom).map(pos + start); + tr.setNodeMarkup( + updatePos, + null, + removeColSpan( + cell.attrs as CellAttrs, + left - cellLeft, + cell.attrs.colspan - (left - cellLeft), + ), + ); + tr.insert( + updatePos + cell.nodeSize, + cell.type.createAndFill( + removeColSpan(cell.attrs as CellAttrs, 0, left - cellLeft), + )!, + ); + row += cell.attrs.rowspan - 1; + } + } + return found; +} + +/** + * Insert the given set of cells (as returned by `pastedCells`) into a + * table, at the position pointed at by rect. + * + * @internal + */ +export function insertCells( + state: EditorState, + dispatch: (tr: Transaction) => void, + tableStart: number, + rect: Rect, + cells: Area, +): void { + let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc; + if (!table) { + throw new Error('No table found'); + } + let map = TableMap.get(table); + const { top, left } = rect; + const right = left + cells.width, + bottom = top + cells.height; + const tr = state.tr; + let mapFrom = 0; + + function recomp(): void { + table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc; + if (!table) { + throw new Error('No table found'); + } + map = TableMap.get(table); + mapFrom = tr.mapping.maps.length; + } + + // Prepare the table to be large enough and not have any cells + // crossing the boundaries of the rectangle that we want to + // insert into. If anything about it changes, recompute the table + // map so that subsequent operations can see the current shape. + if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp(); + if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom)) + recomp(); + if ( + isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom) + ) + recomp(); + if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom)) + recomp(); + if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom)) + recomp(); + + for (let row = top; row < bottom; row++) { + const from = map.positionAt(row, left, table), + to = map.positionAt(row, right, table); + tr.replace( + tr.mapping.slice(mapFrom).map(from + tableStart), + tr.mapping.slice(mapFrom).map(to + tableStart), + new Slice(cells.rows[row - top], 0, 0), + ); + } + recomp(); + tr.setSelection( + new CellSelection( + tr.doc.resolve(tableStart + map.positionAt(top, left, table)), + tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)), + ), + ); + dispatch(tr); +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts b/wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts new file mode 100644 index 0000000000000000000000000000000000000000..e35bc6520adf4b1cb5ef09e7e85f6f0b944e90ab --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/fixtables.ts @@ -0,0 +1,150 @@ +// This file defines helpers for normalizing tables, making sure no +// cells overlap (which can happen, if you have the wrong col- and +// rowspans) and that each row has the same width. Uses the problems +// reported by `TableMap`. + +import { Node } from 'prosemirror-model'; +import { EditorState, PluginKey, Transaction } from 'prosemirror-state'; +import { tableNodeTypes, TableRole } from './schema'; +import { TableMap } from './tablemap'; +import { CellAttrs, removeColSpan } from './util'; + +/** + * @public + */ +export const fixTablesKey = new PluginKey<{ fixTables: boolean }>('fix-tables'); + +/** + * Helper for iterating through the nodes in a document that changed + * compared to the given previous document. Useful for avoiding + * duplicate work on each transaction. + * + * @public + */ +function changedDescendants( + old: Node, + cur: Node, + offset: number, + f: (node: Node, pos: number) => void, +): void { + const oldSize = old.childCount, + curSize = cur.childCount; + outer: for (let i = 0, j = 0; i < curSize; i++) { + const child = cur.child(i); + for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { + if (old.child(scan) == child) { + j = scan + 1; + offset += child.nodeSize; + continue outer; + } + } + f(child, offset); + if (j < oldSize && old.child(j).sameMarkup(child)) + changedDescendants(old.child(j), child, offset + 1, f); + else child.nodesBetween(0, child.content.size, f, offset + 1); + offset += child.nodeSize; + } +} + +/** + * Inspect all tables in the given state's document and return a + * transaction that fixes them, if necessary. If `oldState` was + * provided, that is assumed to hold a previous, known-good state, + * which will be used to avoid re-scanning unchanged parts of the + * document. + * + * @public + */ +export function fixTables( + state: EditorState, + oldState?: EditorState, +): Transaction | undefined { + let tr: Transaction | undefined; + const check = (node: Node, pos: number) => { + if (node.type.spec.tableRole == 'table') + tr = fixTable(state, node, pos, tr); + }; + if (!oldState) state.doc.descendants(check); + else if (oldState.doc != state.doc) + changedDescendants(oldState.doc, state.doc, 0, check); + return tr; +} + +// Fix the given table, if necessary. Will append to the transaction +// it was given, if non-null, or create a new one if necessary. +export function fixTable( + state: EditorState, + table: Node, + tablePos: number, + tr: Transaction | undefined, +): Transaction | undefined { + const map = TableMap.get(table); + if (!map.problems) return tr; + if (!tr) tr = state.tr; + + // Track which rows we must add cells to, so that we can adjust that + // when fixing collisions. + const mustAdd: number[] = []; + for (let i = 0; i < map.height; i++) mustAdd.push(0); + for (let i = 0; i < map.problems.length; i++) { + const prob = map.problems[i]; + if (prob.type == 'collision') { + const cell = table.nodeAt(prob.pos); + if (!cell) continue; + const attrs = cell.attrs as CellAttrs; + for (let j = 0; j < attrs.rowspan; j++) mustAdd[prob.row + j] += prob.n; + tr.setNodeMarkup( + tr.mapping.map(tablePos + 1 + prob.pos), + null, + removeColSpan(attrs, attrs.colspan - prob.n, prob.n), + ); + } else if (prob.type == 'missing') { + mustAdd[prob.row] += prob.n; + } else if (prob.type == 'overlong_rowspan') { + const cell = table.nodeAt(prob.pos); + if (!cell) continue; + tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { + ...cell.attrs, + rowspan: cell.attrs.rowspan - prob.n, + }); + } else if (prob.type == 'colwidth mismatch') { + const cell = table.nodeAt(prob.pos); + if (!cell) continue; + tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { + ...cell.attrs, + colwidth: prob.colwidth, + }); + } + } + let first, last; + for (let i = 0; i < mustAdd.length; i++) + if (mustAdd[i]) { + if (first == null) first = i; + last = i; + } + // Add the necessary cells, using a heuristic for whether to add the + // cells at the start or end of the rows (if it looks like a 'bite' + // was taken out of the table, add cells at the start of the row + // after the bite. Otherwise add them at the end). + for (let i = 0, pos = tablePos + 1; i < map.height; i++) { + const row = table.child(i); + const end = pos + row.nodeSize; + const add = mustAdd[i]; + if (add > 0) { + let role: TableRole = 'cell'; + if (row.firstChild) { + role = row.firstChild.type.spec.tableRole; + } + const nodes: Node[] = []; + for (let j = 0; j < add; j++) { + const node = tableNodeTypes(state.schema)[role].createAndFill(); + + if (node) nodes.push(node); + } + const side = (i == 0 || first == i - 1) && last == i ? pos + 1 : end - 1; + tr.insert(tr.mapping.map(side), nodes); + } + pos = end; + } + return tr.setMeta(fixTablesKey, { fixTables: true }); +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/index.ts b/wax-prosemirror-services/src/TablesService/tableSrc/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..db21e4cb23dd54b30038b5cf91d19c24f609797c --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/index.ts @@ -0,0 +1,136 @@ +// This file defines a plugin that handles the drawing of cell +// selections and the basic user interactions for creating and working +// with such selections. It also makes sure that, after each +// transaction, the shapes of tables are normalized to be rectangular +// and not contain overlapping cells. + +import { Plugin } from 'prosemirror-state'; + +import { drawCellSelection, normalizeSelection } from './cellselection'; +import { fixTables, fixTablesKey } from './fixtables'; +import { + handleKeyDown, + handleMouseDown, + handlePaste, + handleTripleClick, +} from './input'; +import { tableEditingKey } from './util'; + +export { CellBookmark, CellSelection } from './cellselection'; +export type { CellSelectionJSON } from './cellselection'; +export { + columnResizing, + columnResizingPluginKey, + ResizeState, +} from './columnresizing'; +export type { ColumnResizingOptions, Dragging } from './columnresizing'; +export * from './commands'; +export { + clipCells as __clipCells, + insertCells as __insertCells, + pastedCells as __pastedCells, +} from './copypaste'; +export type { Area as __Area } from './copypaste'; +export type { Direction } from './input'; +export { tableNodes, tableNodeTypes } from './schema'; +export type { + CellAttributes, + getFromDOM, + setDOMAttr, + TableNodes, + TableNodesOptions, + TableRole, +} from './schema'; +export { TableMap } from './tablemap'; +export type { ColWidths, Problem, Rect } from './tablemap'; +export { TableView, updateColumnsOnResize } from './tableview'; +export { + addColSpan, + cellAround, + colCount, + columnIsHeader, + findCell, + inSameTable, + isInTable, + moveCellForward, + nextCell, + pointsAtCell, + removeColSpan, + selectionCell, +} from './util'; +export type { MutableAttrs } from './util'; +export { fixTables, handlePaste, fixTablesKey }; +export { tableEditingKey }; + +/** + * @public + */ +export type TableEditingOptions = { + allowTableNodeSelection?: boolean; +}; + +/** + * Creates a [plugin](http://prosemirror.net/docs/ref/#state.Plugin) + * that, when added to an editor, enables cell-selection, handles + * cell-based copy/paste, and makes sure tables stay well-formed (each + * row has the same width, and cells don't overlap). + * + * You should probably put this plugin near the end of your array of + * plugins, since it handles mouse and arrow key events in tables + * rather broadly, and other plugins, like the gap cursor or the + * column-width dragging plugin, might want to get a turn first to + * perform more specific behavior. + * + * @public + */ +export function tableEditing({ + allowTableNodeSelection = false, +}: TableEditingOptions = {}): Plugin { + return new Plugin({ + key: tableEditingKey, + + // This piece of state is used to remember when a mouse-drag + // cell-selection is happening, so that it can continue even as + // transactions (which might move its anchor cell) come in. + state: { + init() { + return null; + }, + apply(tr, cur) { + const set = tr.getMeta(tableEditingKey); + if (set != null) return set == -1 ? null : set; + if (cur == null || !tr.docChanged) return cur; + const { deleted, pos } = tr.mapping.mapResult(cur); + return deleted ? null : pos; + }, + }, + + props: { + decorations: drawCellSelection, + + handleDOMEvents: { + mousedown: handleMouseDown, + }, + + createSelectionBetween(view) { + return tableEditingKey.getState(view.state) != null + ? view.state.selection + : null; + }, + + handleTripleClick, + + handleKeyDown, + + handlePaste, + }, + + appendTransaction(_, oldState, state) { + return normalizeSelection( + state, + fixTables(state, oldState), + allowTableNodeSelection, + ); + }, + }); +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/input.ts b/wax-prosemirror-services/src/TablesService/tableSrc/input.ts new file mode 100644 index 0000000000000000000000000000000000000000..96e7b63f36bc50504e87ffc3eb91e04afc61f275 --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/input.ts @@ -0,0 +1,310 @@ +// This file defines a number of helpers for wiring up user input to +// table-related functionality. + +import { Fragment, ResolvedPos, Slice } from 'prosemirror-model'; +import { + Command, + EditorState, + Selection, + TextSelection, + Transaction, +} from 'prosemirror-state'; +import { keydownHandler } from 'prosemirror-keymap'; + +import { + cellAround, + inSameTable, + isInTable, + tableEditingKey, + nextCell, + selectionCell, +} from './util'; +import { CellSelection } from './cellselection'; +import { TableMap } from './tablemap'; +import { clipCells, fitSlice, insertCells, pastedCells } from './copypaste'; +import { tableNodeTypes } from './schema'; +import { EditorView } from 'prosemirror-view'; + +type Axis = 'horiz' | 'vert'; + +/** + * @public + */ +export type Direction = -1 | 1; + +export const handleKeyDown = keydownHandler({ + ArrowLeft: arrow('horiz', -1), + ArrowRight: arrow('horiz', 1), + ArrowUp: arrow('vert', -1), + ArrowDown: arrow('vert', 1), + + 'Shift-ArrowLeft': shiftArrow('horiz', -1), + 'Shift-ArrowRight': shiftArrow('horiz', 1), + 'Shift-ArrowUp': shiftArrow('vert', -1), + 'Shift-ArrowDown': shiftArrow('vert', 1), + + Backspace: deleteCellSelection, + 'Mod-Backspace': deleteCellSelection, + Delete: deleteCellSelection, + 'Mod-Delete': deleteCellSelection, +}); + +function maybeSetSelection( + state: EditorState, + dispatch: undefined | ((tr: Transaction) => void), + selection: Selection, +): boolean { + if (selection.eq(state.selection)) return false; + if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()); + return true; +} + +function arrow(axis: Axis, dir: Direction): Command { + return (state, dispatch, view) => { + if (!view) return false; + const sel = state.selection; + if (sel instanceof CellSelection) { + return maybeSetSelection( + state, + dispatch, + Selection.near(sel.$headCell, dir), + ); + } + if (axis != 'horiz' && !sel.empty) return false; + const end = atEndOfCell(view, axis, dir); + if (end == null) return false; + if (axis == 'horiz') { + return maybeSetSelection( + state, + dispatch, + Selection.near(state.doc.resolve(sel.head + dir), dir), + ); + } else { + const $cell = state.doc.resolve(end); + const $next = nextCell($cell, axis, dir); + let newSel; + if ($next) newSel = Selection.near($next, 1); + else if (dir < 0) + newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1); + else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1); + return maybeSetSelection(state, dispatch, newSel); + } + }; +} + +function shiftArrow(axis: Axis, dir: Direction): Command { + return (state, dispatch, view) => { + if (!view) return false; + const sel = state.selection; + let cellSel: CellSelection; + if (sel instanceof CellSelection) { + cellSel = sel; + } else { + const end = atEndOfCell(view, axis, dir); + if (end == null) return false; + cellSel = new CellSelection(state.doc.resolve(end)); + } + + const $head = nextCell(cellSel.$headCell, axis, dir); + if (!$head) return false; + return maybeSetSelection( + state, + dispatch, + new CellSelection(cellSel.$anchorCell, $head), + ); + }; +} + +function deleteCellSelection( + state: EditorState, + dispatch?: (tr: Transaction) => void, +): boolean { + const sel = state.selection; + if (!(sel instanceof CellSelection)) return false; + if (dispatch) { + const tr = state.tr; + const baseContent = tableNodeTypes(state.schema).cell.createAndFill()! + .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; +} + +export function handleTripleClick(view: EditorView, pos: number): boolean { + const doc = view.state.doc, + $cell = cellAround(doc.resolve(pos)); + if (!$cell) return false; + view.dispatch(view.state.tr.setSelection(new CellSelection($cell))); + return true; +} + +/** + * @public + */ +export function handlePaste( + view: EditorView, + _: ClipboardEvent, + slice: Slice, +): boolean { + if (!isInTable(view.state)) return false; + let cells = pastedCells(slice); + const sel = view.state.selection; + if (sel instanceof CellSelection) { + if (!cells) + cells = { + width: 1, + height: 1, + rows: [ + Fragment.from( + fitSlice(tableNodeTypes(view.state.schema).cell, slice), + ), + ], + }; + const table = sel.$anchorCell.node(-1); + const start = sel.$anchorCell.start(-1); + const rect = TableMap.get(table).rectBetween( + sel.$anchorCell.pos - start, + sel.$headCell.pos - start, + ); + cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top); + insertCells(view.state, view.dispatch, start, rect, cells); + return true; + } else if (cells) { + const $cell = selectionCell(view.state); + const start = $cell.start(-1); + insertCells( + view.state, + view.dispatch, + start, + TableMap.get($cell.node(-1)).findCell($cell.pos - start), + cells, + ); + return true; + } else { + return false; + } +} + +export function handleMouseDown( + view: EditorView, + startEvent: MouseEvent, +): void { + if (startEvent.ctrlKey || startEvent.metaKey) return; + + const startDOMCell = domInCell(view, startEvent.target as Node); + let $anchor; + if (startEvent.shiftKey && view.state.selection instanceof CellSelection) { + // Adding to an existing cell selection + setCellSelection(view.state.selection.$anchorCell, startEvent); + startEvent.preventDefault(); + } else if ( + startEvent.shiftKey && + startDOMCell && + ($anchor = cellAround(view.state.selection.$anchor)) != null && + cellUnderMouse(view, startEvent)?.pos != $anchor.pos + ) { + // Adding to a selection that starts in another cell (causing a + // cell selection to be created). + setCellSelection($anchor, startEvent); + startEvent.preventDefault(); + } else if (!startDOMCell) { + // Not in a cell, let the default behavior happen. + return; + } + + // Create and dispatch a cell selection between the given anchor and + // the position under the mouse. + function setCellSelection($anchor: ResolvedPos, event: MouseEvent): void { + let $head = cellUnderMouse(view, event); + const starting = tableEditingKey.getState(view.state) == null; + if (!$head || !inSameTable($anchor, $head)) { + if (starting) $head = $anchor; + else return; + } + const selection = new CellSelection($anchor, $head); + if (starting || !view.state.selection.eq(selection)) { + const tr = view.state.tr.setSelection(selection); + if (starting) tr.setMeta(tableEditingKey, $anchor.pos); + view.dispatch(tr); + } + } + + // Stop listening to mouse motion events. + function stop(): void { + view.root.removeEventListener('mouseup', stop); + view.root.removeEventListener('dragstart', stop); + view.root.removeEventListener('mousemove', move); + if (tableEditingKey.getState(view.state) != null) + view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); + } + + function move(_event: Event): void { + const event = _event as MouseEvent; + const anchor = tableEditingKey.getState(view.state); + let $anchor; + if (anchor != null) { + // Continuing an existing cross-cell selection + $anchor = view.state.doc.resolve(anchor); + } else if (domInCell(view, event.target as Node) != startDOMCell) { + // Moving out of the initial cell -- start a new cell selection + $anchor = cellUnderMouse(view, startEvent); + if (!$anchor) return stop(); + } + if ($anchor) setCellSelection($anchor, event); + } + + view.root.addEventListener('mouseup', stop); + view.root.addEventListener('dragstart', stop); + view.root.addEventListener('mousemove', move); +} + +// Check whether the cursor is at the end of a cell (so that further +// motion would move out of the cell) +function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number { + if (!(view.state.selection instanceof TextSelection)) return null; + const { $head } = view.state.selection; + for (let d = $head.depth - 1; d >= 0; d--) { + const parent = $head.node(d), + index = dir < 0 ? $head.index(d) : $head.indexAfter(d); + if (index != (dir < 0 ? 0 : parent.childCount)) return null; + if ( + parent.type.spec.tableRole == 'cell' || + parent.type.spec.tableRole == 'header_cell' + ) { + const cellPos = $head.before(d); + const dirStr: 'up' | 'down' | 'left' | 'right' = + axis == 'vert' ? (dir > 0 ? 'down' : 'up') : dir > 0 ? 'right' : 'left'; + return view.endOfTextblock(dirStr) ? cellPos : null; + } + } + return null; +} + +function domInCell(view: EditorView, dom: Node | null): Node | null { + for (; dom && dom != view.dom; dom = dom.parentNode) { + if (dom.nodeName == 'TD' || dom.nodeName == 'TH') { + return dom; + } + } + return null; +} + +function cellUnderMouse( + view: EditorView, + event: MouseEvent, +): ResolvedPos | null { + const mousePos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (!mousePos) return null; + return mousePos ? cellAround(view.state.doc.resolve(mousePos.pos)) : null; +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/schema.ts b/wax-prosemirror-services/src/TablesService/tableSrc/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9012f8186dc33545fbc8f17d140b08a97d329e0 --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/schema.ts @@ -0,0 +1,197 @@ +// Helper for creating a schema that supports tables. + +import { + AttributeSpec, + Attrs, + Node, + NodeSpec, + NodeType, + Schema, +} from 'prosemirror-model'; +import { CellAttrs, MutableAttrs } from './util'; + +function getCellAttrs(dom: HTMLElement | string, extraAttrs: Attrs): Attrs { + if (typeof dom === 'string') { + return {}; + } + + const widthAttr = dom.getAttribute('data-colwidth'); + const widths = + widthAttr && /^\d+(,\d+)*$/.test(widthAttr) + ? widthAttr.split(',').map((s) => Number(s)) + : null; + const colspan = Number(dom.getAttribute('colspan') || 1); + const result: MutableAttrs = { + colspan, + rowspan: Number(dom.getAttribute('rowspan') || 1), + colwidth: widths && widths.length == colspan ? widths : null, + } satisfies CellAttrs; + for (const prop in extraAttrs) { + const getter = extraAttrs[prop].getFromDOM; + const value = getter && getter(dom); + if (value != null) { + result[prop] = value; + } + } + return result; +} + +function setCellAttrs(node: Node, extraAttrs: Attrs): Attrs { + const attrs: MutableAttrs = {}; + if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan; + if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan; + if (node.attrs.colwidth) + attrs['data-colwidth'] = node.attrs.colwidth.join(','); + for (const prop in extraAttrs) { + const setter = extraAttrs[prop].setDOMAttr; + if (setter) setter(node.attrs[prop], attrs); + } + return attrs; +} + +/** + * @public + */ +export type getFromDOM = (dom: HTMLElement) => unknown; + +/** + * @public + */ +export type setDOMAttr = (value: unknown, attrs: MutableAttrs) => void; + +/** + * @public + */ +export interface CellAttributes { + /** + * The attribute's default value. + */ + default: unknown; + + /** + * A function to read the attribute's value from a DOM node. + */ + getFromDOM?: getFromDOM; + + /** + * A function to add the attribute's value to an attribute + * object that's used to render the cell's DOM. + */ + setDOMAttr?: setDOMAttr; +} + +/** + * @public + */ +export interface TableNodesOptions { + /** + * A group name (something like `"block"`) to add to the table + * node type. + */ + tableGroup?: string; + + /** + * The content expression for table cells. + */ + cellContent: string; + + /** + * Additional attributes to add to cells. Maps attribute names to + * objects with the following properties: + */ + cellAttributes: { [key: string]: CellAttributes }; +} + +/** + * @public + */ +export type TableNodes = Record< + 'table' | 'table_row' | 'table_cell' | 'table_header', + NodeSpec +>; + +/** + * This function creates a set of [node + * specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for + * `table`, `table_row`, and `table_cell` nodes types as used by this + * module. The result can then be added to the set of nodes when + * creating a schema. + * + * @public + */ +export function tableNodes(options: TableNodesOptions): TableNodes { + const extraAttrs = options.cellAttributes || {}; + const cellAttrs: Record<string, AttributeSpec> = { + colspan: { default: 1 }, + rowspan: { default: 1 }, + colwidth: { default: null }, + }; + for (const prop in extraAttrs) + cellAttrs[prop] = { default: extraAttrs[prop].default }; + + return { + table: { + content: 'table_row+', + tableRole: 'table', + isolating: true, + group: options.tableGroup, + parseDOM: [{ tag: 'table' }], + toDOM() { + return ['table', ['tbody', 0]]; + }, + }, + table_row: { + content: '(table_cell | table_header)*', + tableRole: 'row', + parseDOM: [{ tag: 'tr' }], + toDOM() { + return ['tr', 0]; + }, + }, + table_cell: { + content: options.cellContent, + attrs: cellAttrs, + tableRole: 'cell', + isolating: true, + parseDOM: [ + { tag: 'td', getAttrs: (dom) => getCellAttrs(dom, extraAttrs) }, + ], + toDOM(node) { + return ['td', setCellAttrs(node, extraAttrs), 0]; + }, + }, + table_header: { + content: options.cellContent, + attrs: cellAttrs, + tableRole: 'header_cell', + isolating: true, + parseDOM: [ + { tag: 'th', getAttrs: (dom) => getCellAttrs(dom, extraAttrs) }, + ], + toDOM(node) { + return ['th', setCellAttrs(node, extraAttrs), 0]; + }, + }, + }; +} + +/** + * @public + */ +export type TableRole = 'table' | 'row' | 'cell' | 'header_cell'; + +/** + * @public + */ +export function tableNodeTypes(schema: Schema): Record<TableRole, NodeType> { + let result = schema.cached.tableNodeTypes; + if (!result) { + result = schema.cached.tableNodeTypes = {}; + for (const name in schema.nodes) { + const type = schema.nodes[name], + role = type.spec.tableRole; + if (role) result[role] = type; + } + } + return result; +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts b/wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf633e25b488efce099140e731143da11c1fe4f5 --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/tablemap.ts @@ -0,0 +1,377 @@ +// Because working with row and column-spanning cells is not quite +// trivial, this code builds up a descriptive structure for a given +// table node. The structures are cached with the (persistent) table +// nodes as key, so that they only have to be recomputed when the +// content of the table changes. +// +// This does mean that they have to store table-relative, not +// document-relative positions. So code that uses them will typically +// compute the start position of the table and offset positions passed +// to or gotten from this structure by that amount. +import { Attrs, Node } from 'prosemirror-model'; +import { CellAttrs } from './util'; + +/** + * @public + */ +export type ColWidths = number[]; + +/** + * @public + */ +export type Problem = + | { + type: 'colwidth mismatch'; + pos: number; + colwidth: ColWidths; + } + | { + type: 'collision'; + pos: number; + row: number; + n: number; + } + | { + type: 'missing'; + row: number; + n: number; + } + | { + type: 'overlong_rowspan'; + pos: number; + n: number; + }; + +let readFromCache: (key: Node) => TableMap | undefined; +let addToCache: (key: Node, value: TableMap) => TableMap; + +// Prefer using a weak map to cache table maps. Fall back on a +// fixed-size cache if that's not supported. +if (typeof WeakMap != 'undefined') { + // eslint-disable-next-line + let cache = new WeakMap<Node, TableMap>(); + readFromCache = (key) => cache.get(key); + addToCache = (key, value) => { + cache.set(key, value); + return value; + }; +} else { + const cache: (Node | TableMap)[] = []; + const cacheSize = 10; + let cachePos = 0; + readFromCache = (key) => { + for (let i = 0; i < cache.length; i += 2) + if (cache[i] == key) return cache[i + 1] as TableMap; + }; + addToCache = (key, value) => { + if (cachePos == cacheSize) cachePos = 0; + cache[cachePos++] = key; + return (cache[cachePos++] = value); + }; +} + +/** + * @public + */ +export interface Rect { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * A table map describes the structure of a given table. To avoid + * recomputing them all the time, they are cached per table node. To + * be able to do that, positions saved in the map are relative to the + * start of the table, rather than the start of the document. + * + * @public + */ +export class TableMap { + constructor( + /** + * The number of columns + */ + public width: number, + /** + * The number of rows + */ + public height: number, + /** + * A width * height array with the start position of + * the cell covering that part of the table in each slot + */ + public map: number[], + /** + * An optional array of problems (cell overlap or non-rectangular + * shape) for the table, used by the table normalizer. + */ + public problems: Problem[] | null, + ) {} + + // Find the dimensions of the cell at the given position. + findCell(pos: number): Rect { + for (let i = 0; i < this.map.length; i++) { + const curPos = this.map[i]; + if (curPos != pos) continue; + + const left = i % this.width; + const top = (i / this.width) | 0; + let right = left + 1; + let bottom = top + 1; + + for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) { + right++; + } + for ( + let j = 1; + bottom < this.height && this.map[i + this.width * j] == curPos; + j++ + ) { + bottom++; + } + + return { left, top, right, bottom }; + } + throw new RangeError(`No cell with offset ${pos} found`); + } + + // Find the left side of the cell at the given position. + colCount(pos: number): number { + for (let i = 0; i < this.map.length; i++) { + if (this.map[i] == pos) { + return i % this.width; + } + } + throw new RangeError(`No cell with offset ${pos} found`); + } + + // Find the next cell in the given direction, starting from the cell + // at `pos`, if any. + nextCell(pos: number, axis: 'horiz' | 'vert', dir: number): null | number { + const { left, right, top, bottom } = this.findCell(pos); + if (axis == 'horiz') { + if (dir < 0 ? left == 0 : right == this.width) return null; + return this.map[top * this.width + (dir < 0 ? left - 1 : right)]; + } else { + if (dir < 0 ? top == 0 : bottom == this.height) return null; + return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)]; + } + } + + // Get the rectangle spanning the two given cells. + rectBetween(a: number, b: number): Rect { + const { + left: leftA, + right: rightA, + top: topA, + bottom: bottomA, + } = this.findCell(a); + const { + left: leftB, + right: rightB, + top: topB, + bottom: bottomB, + } = this.findCell(b); + return { + left: Math.min(leftA, leftB), + top: Math.min(topA, topB), + right: Math.max(rightA, rightB), + bottom: Math.max(bottomA, bottomB), + }; + } + + // Return the position of all cells that have the top left corner in + // the given rectangle. + cellsInRect(rect: Rect): number[] { + const result: number[] = []; + const seen: Record<number, boolean> = {}; + for (let row = rect.top; row < rect.bottom; row++) { + for (let col = rect.left; col < rect.right; col++) { + const index = row * this.width + col; + const pos = this.map[index]; + + if (seen[pos]) continue; + seen[pos] = true; + + if ( + (col == rect.left && col && this.map[index - 1] == pos) || + (row == rect.top && row && this.map[index - this.width] == pos) + ) { + continue; + } + result.push(pos); + } + } + return result; + } + + // Return the position at which the cell at the given row and column + // starts, or would start, if a cell started there. + positionAt(row: number, col: number, table: Node): number { + for (let i = 0, rowStart = 0; ; i++) { + const rowEnd = rowStart + table.child(i).nodeSize; + if (i == row) { + let index = col + row * this.width; + const rowEndIndex = (row + 1) * this.width; + // Skip past cells from previous rows (via rowspan) + while (index < rowEndIndex && this.map[index] < rowStart) index++; + return index == rowEndIndex ? rowEnd - 1 : this.map[index]; + } + rowStart = rowEnd; + } + } + + // Find the table map for the given table node. + static get(table: Node): TableMap { + return readFromCache(table) || addToCache(table, computeMap(table)); + } +} + +// Compute a table map. +function computeMap(table: Node): TableMap { + if (table.type.spec.tableRole != 'table') + throw new RangeError('Not a table node: ' + table.type.name); + const width = findWidth(table), + height = table.childCount; + const map = []; + let mapPos = 0; + let problems: Problem[] | null = null; + const colWidths: ColWidths = []; + for (let i = 0, e = width * height; i < e; i++) map[i] = 0; + + for (let row = 0, pos = 0; row < height; row++) { + const rowNode = table.child(row); + pos++; + for (let i = 0; ; i++) { + while (mapPos < map.length && map[mapPos] != 0) mapPos++; + if (i == rowNode.childCount) break; + const cellNode = rowNode.child(i); + const { colspan, rowspan, colwidth } = cellNode.attrs; + for (let h = 0; h < rowspan; h++) { + if (h + row >= height) { + (problems || (problems = [])).push({ + type: 'overlong_rowspan', + pos, + n: rowspan - h, + }); + break; + } + const start = mapPos + h * width; + for (let w = 0; w < colspan; w++) { + if (map[start + w] == 0) map[start + w] = pos; + else + (problems || (problems = [])).push({ + type: 'collision', + row, + pos, + n: colspan - w, + }); + const colW = colwidth && colwidth[w]; + if (colW) { + const widthIndex = ((start + w) % width) * 2, + prev = colWidths[widthIndex]; + if ( + prev == null || + (prev != colW && colWidths[widthIndex + 1] == 1) + ) { + colWidths[widthIndex] = colW; + colWidths[widthIndex + 1] = 1; + } else if (prev == colW) { + colWidths[widthIndex + 1]++; + } + } + } + } + mapPos += colspan; + pos += cellNode.nodeSize; + } + const expectedPos = (row + 1) * width; + let missing = 0; + while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++; + if (missing) + (problems || (problems = [])).push({ type: 'missing', row, n: missing }); + pos++; + } + + const tableMap = new TableMap(width, height, map, problems); + let badWidths = false; + + // For columns that have defined widths, but whose widths disagree + // between rows, fix up the cells whose width doesn't match the + // computed one. + for (let i = 0; !badWidths && i < colWidths.length; i += 2) + if (colWidths[i] != null && colWidths[i + 1] < height) badWidths = true; + if (badWidths) findBadColWidths(tableMap, colWidths, table); + + return tableMap; +} + +function findWidth(table: Node): number { + let width = -1; + let hasRowSpan = false; + for (let row = 0; row < table.childCount; row++) { + const rowNode = table.child(row); + let rowWidth = 0; + if (hasRowSpan) + for (let j = 0; j < row; j++) { + const prevRow = table.child(j); + for (let i = 0; i < prevRow.childCount; i++) { + const cell = prevRow.child(i); + if (j + cell.attrs.rowspan > row) rowWidth += cell.attrs.colspan; + } + } + for (let i = 0; i < rowNode.childCount; i++) { + const cell = rowNode.child(i); + rowWidth += cell.attrs.colspan; + if (cell.attrs.rowspan > 1) hasRowSpan = true; + } + if (width == -1) width = rowWidth; + else if (width != rowWidth) width = Math.max(width, rowWidth); + } + return width; +} + +function findBadColWidths( + map: TableMap, + colWidths: ColWidths, + table: Node, +): void { + if (!map.problems) map.problems = []; + const seen: Record<number, boolean> = {}; + for (let i = 0; i < map.map.length; i++) { + const pos = map.map[i]; + if (seen[pos]) continue; + seen[pos] = true; + const node = table.nodeAt(pos); + if (!node) { + throw new RangeError(`No cell with offset ${pos} found`); + } + + let updated = null; + const attrs = node.attrs as CellAttrs; + for (let j = 0; j < attrs.colspan; j++) { + const col = (i + j) % map.width; + const colWidth = colWidths[col * 2]; + if ( + colWidth != null && + (!attrs.colwidth || attrs.colwidth[j] != colWidth) + ) + (updated || (updated = freshColWidth(attrs)))[j] = colWidth; + } + if (updated) + map.problems.unshift({ + type: 'colwidth mismatch', + pos, + colwidth: updated, + }); + } +} + +function freshColWidth(attrs: Attrs): ColWidths { + if (attrs.colwidth) return attrs.colwidth.slice(); + const result: ColWidths = []; + for (let i = 0; i < attrs.colspan; i++) result.push(0); + return result; +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts b/wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3130bc85bd23b22f1a0921048f813c3b25bdace --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/tableview.ts @@ -0,0 +1,86 @@ +import { Node } from 'prosemirror-model'; +import { NodeView } from 'prosemirror-view'; +import { CellAttrs } from './util'; + +/** + * @public + */ +export class TableView implements NodeView { + public dom: HTMLDivElement; + public table: HTMLTableElement; + public colgroup: HTMLTableColElement; + public contentDOM: HTMLTableSectionElement; + + constructor(public node: Node, public cellMinWidth: number) { + this.dom = document.createElement('div'); + this.dom.className = 'tableWrapper'; + this.table = this.dom.appendChild(document.createElement('table')); + this.colgroup = this.table.appendChild(document.createElement('colgroup')); + updateColumnsOnResize(node, this.colgroup, this.table, cellMinWidth); + this.contentDOM = this.table.appendChild(document.createElement('tbody')); + } + + update(node: Node): boolean { + if (node.type != this.node.type) return false; + this.node = node; + updateColumnsOnResize(node, this.colgroup, this.table, this.cellMinWidth); + return true; + } + + ignoreMutation(record: MutationRecord): boolean { + return ( + record.type == 'attributes' && + (record.target == this.table || this.colgroup.contains(record.target)) + ); + } +} + +/** + * @public + */ +export function updateColumnsOnResize( + node: Node, + colgroup: HTMLTableColElement, + table: HTMLTableElement, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: number, +): void { + let totalWidth = 0; + let fixedWidth = true; + let nextDOM = colgroup.firstChild as HTMLElement; + const row = node.firstChild; + if (!row) return; + + for (let i = 0, col = 0; i < row.childCount; i++) { + const { colspan, colwidth } = row.child(i).attrs as CellAttrs; + for (let j = 0; j < colspan; j++, col++) { + const hasWidth = + overrideCol == col ? overrideValue : colwidth && colwidth[j]; + const cssWidth = hasWidth ? hasWidth + 'px' : ''; + totalWidth += hasWidth || cellMinWidth; + if (!hasWidth) fixedWidth = false; + if (!nextDOM) { + colgroup.appendChild(document.createElement('col')).style.width = + cssWidth; + } else { + if (nextDOM.style.width != cssWidth) nextDOM.style.width = cssWidth; + nextDOM = nextDOM.nextSibling as HTMLElement; + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling; + nextDOM.parentNode?.removeChild(nextDOM); + nextDOM = after as HTMLElement; + } + + if (fixedWidth) { + table.style.width = totalWidth + 'px'; + table.style.minWidth = ''; + } else { + table.style.width = ''; + table.style.minWidth = totalWidth + 'px'; + } +} diff --git a/wax-prosemirror-services/src/TablesService/tableSrc/util.ts b/wax-prosemirror-services/src/TablesService/tableSrc/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..70fd658f9ff15c528608455b470aa1e3554daed6 --- /dev/null +++ b/wax-prosemirror-services/src/TablesService/tableSrc/util.ts @@ -0,0 +1,195 @@ +// Various helper function for working with tables + +import { EditorState, NodeSelection, PluginKey } from 'prosemirror-state'; + +import { Attrs, Node, ResolvedPos } from 'prosemirror-model'; +import { CellSelection } from './cellselection'; +import { tableNodeTypes } from './schema'; +import { Rect, TableMap } from './tablemap'; + +/** + * @public + */ +export type MutableAttrs = Record<string, unknown>; + +/** + * @public + */ +export interface CellAttrs { + colspan: number; + rowspan: number; + colwidth: number[] | null; +} + +/** + * @public + */ +export const tableEditingKey = new PluginKey<number>('selectingCells'); + +/** + * @public + */ +export function cellAround($pos: ResolvedPos): ResolvedPos | null { + for (let d = $pos.depth - 1; d > 0; d--) + if ($pos.node(d).type.spec.tableRole == 'row') + return $pos.node(0).resolve($pos.before(d + 1)); + return null; +} + +export function cellWrapping($pos: ResolvedPos): null | Node { + for (let d = $pos.depth; d > 0; d--) { + // Sometimes the cell can be in the same depth. + const role = $pos.node(d).type.spec.tableRole; + if (role === 'cell' || role === 'header_cell') return $pos.node(d); + } + return null; +} + +/** + * @public + */ +export function isInTable(state: EditorState): boolean { + const $head = state.selection.$head; + for (let d = $head.depth; d > 0; d--) + if ($head.node(d).type.spec.tableRole == 'row') return true; + return false; +} + +/** + * @internal + */ +export function selectionCell(state: EditorState): ResolvedPos { + const sel = state.selection as CellSelection | NodeSelection; + if ('$anchorCell' in sel && sel.$anchorCell) { + return sel.$anchorCell.pos > sel.$headCell.pos + ? sel.$anchorCell + : sel.$headCell; + } else if ( + 'node' in sel && + sel.node && + sel.node.type.spec.tableRole == 'cell' + ) { + return sel.$anchor; + } + const $cell = cellAround(sel.$head) || cellNear(sel.$head); + if ($cell) { + return $cell; + } + throw new RangeError(`No cell found around position ${sel.head}`); +} + +function cellNear($pos: ResolvedPos): ResolvedPos | undefined { + for ( + let after = $pos.nodeAfter, pos = $pos.pos; + after; + after = after.firstChild, pos++ + ) { + const role = after.type.spec.tableRole; + if (role == 'cell' || role == 'header_cell') return $pos.doc.resolve(pos); + } + for ( + let before = $pos.nodeBefore, pos = $pos.pos; + before; + before = before.lastChild, pos-- + ) { + const role = before.type.spec.tableRole; + if (role == 'cell' || role == 'header_cell') + return $pos.doc.resolve(pos - before.nodeSize); + } +} + +/** + * @public + */ +export function pointsAtCell($pos: ResolvedPos): boolean { + return $pos.parent.type.spec.tableRole == 'row' && !!$pos.nodeAfter; +} + +/** + * @public + */ +export function moveCellForward($pos: ResolvedPos): ResolvedPos { + return $pos.node(0).resolve($pos.pos + $pos.nodeAfter!.nodeSize); +} + +/** + * @internal + */ +export function inSameTable($cellA: ResolvedPos, $cellB: ResolvedPos): boolean { + return ( + $cellA.depth == $cellB.depth && + $cellA.pos >= $cellB.start(-1) && + $cellA.pos <= $cellB.end(-1) + ); +} + +/** + * @public + */ +export function findCell($pos: ResolvedPos): Rect { + return TableMap.get($pos.node(-1)).findCell($pos.pos - $pos.start(-1)); +} + +/** + * @public + */ +export function colCount($pos: ResolvedPos): number { + return TableMap.get($pos.node(-1)).colCount($pos.pos - $pos.start(-1)); +} + +/** + * @public + */ +export function nextCell( + $pos: ResolvedPos, + axis: 'horiz' | 'vert', + dir: number, +): ResolvedPos | null { + const table = $pos.node(-1); + const map = TableMap.get(table); + const tableStart = $pos.start(-1); + + const moved = map.nextCell($pos.pos - tableStart, axis, dir); + return moved == null ? null : $pos.node(0).resolve(tableStart + moved); +} + +/** + * @public + */ +export function removeColSpan(attrs: CellAttrs, pos: number, n = 1): CellAttrs { + const result: CellAttrs = { ...attrs, colspan: attrs.colspan - n }; + + if (result.colwidth) { + result.colwidth = result.colwidth.slice(); + result.colwidth.splice(pos, n); + if (!result.colwidth.some((w) => w > 0)) result.colwidth = null; + } + return result; +} + +/** + * @public + */ +export function addColSpan(attrs: CellAttrs, pos: number, n = 1): Attrs { + const result = { ...attrs, colspan: attrs.colspan + n }; + if (result.colwidth) { + result.colwidth = result.colwidth.slice(); + for (let i = 0; i < n; i++) result.colwidth.splice(pos, 0, 0); + } + return result; +} + +/** + * @public + */ +export function columnIsHeader( + map: TableMap, + table: Node, + col: number, +): boolean { + const headerCell = tableNodeTypes(table.type.schema).header_cell; + for (let row = 0; row < map.height; row++) + if (table.nodeAt(map.map[col + row * map.width])!.type != headerCell) + return false; + return true; +}