// 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 { EditorState, NodeSelection, Selection, SelectionRange, TextSelection, Transaction, } from 'prosemirror-state'; import { Decoration, DecorationSet, DecorationSource } from 'prosemirror-view'; import { Fragment, Node, ResolvedPos, Slice } from 'prosemirror-model'; import { inSameTable, pointsAtCell, removeColSpan, _setAttr } from './util'; import { TableMap } from './tablemap'; import { Mappable } from 'prosemirror-transform'; /** * @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), map = TableMap.get(table), start = $anchorCell.start(-1); const rect = map.rectBetween( $anchorCell.pos - start, $headCell.pos - start, ); const doc = $anchorCell.node(0); const cells = map .cellsInRect(rect) .filter((p) => p != $headCell.pos - start); // Make the head cell the first range, so that it counts as the // primary part of the selection cells.unshift($headCell.pos - start); const ranges = cells.map((pos) => { const cell = table.nodeAt(pos), from = pos + start + 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), map = TableMap.get(table), start = this.$anchorCell.start(-1); const rect = map.rectBetween( this.$anchorCell.pos - start, this.$headCell.pos - start, ); const seen = {}, 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]) { seen[pos] = true; const cellRect = map.findCell(pos); let cell = table.nodeAt(pos); const extraLeft = rect.left - cellRect.left, extraRight = cellRect.right - rect.right; if (extraLeft > 0 || extraRight > 0) { let attrs = cell.attrs; 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); else cell = cell.type.create(attrs, cell.content); } if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) { const attrs = _setAttr( 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), map = TableMap.get(table), start = this.$anchorCell.start(-1); const cells = map.cellsInRect( map.rectBetween(this.$anchorCell.pos - start, this.$headCell.pos - start), ); for (let i = 0; i < cells.length; i++) f(table.nodeAt(cells[i]), start + 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), headTop = this.$headCell.index(-1); if (Math.min(anchorTop, headTop) > 0) return false; const anchorBot = anchorTop + this.$anchorCell.nodeAfter.attrs.rowspan, headBot = headTop + this.$headCell.nodeAfter.attrs.rowspan; return Math.max(anchorBot, headBot) == 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 | null = $anchorCell, ): CellSelection { const map = TableMap.get($anchorCell.node(-1)), start = $anchorCell.start(-1); const anchorRect = map.findCell($anchorCell.pos - start), headRect = map.findCell($headCell.pos - start); const doc = $anchorCell.node(0); if (anchorRect.top <= headRect.top) { if (anchorRect.top > 0) $anchorCell = doc.resolve(start + map.map[anchorRect.left]); if (headRect.bottom < map.height) $headCell = doc.resolve( start + map.map[map.width * (map.height - 1) + headRect.right - 1], ); } else { if (headRect.top > 0) $headCell = doc.resolve(start + map.map[headRect.left]); if (anchorRect.bottom < map.height) $anchorCell = doc.resolve( start + 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 map = TableMap.get(this.$anchorCell.node(-1)), start = this.$anchorCell.start(-1); const anchorLeft = map.colCount(this.$anchorCell.pos - start), headLeft = map.colCount(this.$headCell.pos - start); if (Math.min(anchorLeft, headLeft) > 0) return false; const anchorRight = anchorLeft + this.$anchorCell.nodeAfter.attrs.colspan, 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 | null = $anchorCell, ): CellSelection { const map = TableMap.get($anchorCell.node(-1)), start = $anchorCell.start(-1); const anchorRect = map.findCell($anchorCell.pos - start), headRect = map.findCell($headCell.pos - start); const doc = $anchorCell.node(0); if (anchorRect.left <= headRect.left) { if (anchorRect.left > 0) $anchorCell = doc.resolve(start + map.map[anchorRect.top * map.width]); if (headRect.right < map.width) $headCell = doc.resolve( start + map.map[map.width * (headRect.top + 1) - 1], ); } else { if (headRect.left > 0) $headCell = doc.resolve(start + map.map[headRect.top * map.width]); if (anchorRect.right < map.width) $anchorCell = doc.resolve( start + 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 | null = 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 { if (!(state.selection instanceof CellSelection)) return null; const cells = []; 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, beforeTo = $to.pos, 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; let toCellBoundaryNode; 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 { const sel = (tr || state).selection; const doc = (tr || state).doc; let normalize; let role; 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), 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; }