// 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 { Selection, TextSelection, NodeSelection, SelectionRange, } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { Fragment, Slice } from 'prosemirror-model'; import { inSameTable, pointsAtCell, setAttr, removeColSpan } from './util'; import { TableMap } from './tablemap'; // ::- 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. export class CellSelection extends Selection { // :: (ResolvedPos, ?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, $headCell = $anchorCell) { let table = $anchorCell.node(-1), map = TableMap.get(table), start = $anchorCell.start(-1); let rect = map.rectBetween($anchorCell.pos - start, $headCell.pos - start); let doc = $anchorCell.node(0); let 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); let ranges = cells.map((pos) => { let 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); // :: ResolvedPos // A resolved position pointing _in front of_ the anchor cell (the one // that doesn't move when extending the selection). this.$anchorCell = $anchorCell; // :: ResolvedPos // A resolved position pointing in front of the head cell (the one // moves when extending the selection). this.$headCell = $headCell; } map(doc, mapping) { let $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos)); let $headCell = doc.resolve(mapping.map(this.$headCell.pos)); if ( pointsAtCell($anchorCell) && pointsAtCell($headCell) && inSameTable($anchorCell, $headCell) ) { let 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); } // :: () → Slice // Returns a rectangular slice of table rows containing the selected // cells. content() { let table = this.$anchorCell.node(-1), map = TableMap.get(table), start = this.$anchorCell.start(-1); let rect = map.rectBetween( this.$anchorCell.pos - start, this.$headCell.pos - start, ); let seen = {}, rows = []; for (let row = rect.top; row < rect.bottom; row++) { let rowContent = []; for ( let index = row * map.width + rect.left, col = rect.left; col < rect.right; col++, index++ ) { let pos = map.map[index]; if (!seen[pos]) { seen[pos] = true; let cellRect = map.findCell(pos), cell = table.nodeAt(pos); let 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) { let 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); } replace(tr, content = Slice.empty) { let mapFrom = tr.steps.length, ranges = this.ranges; for (let i = 0; i < ranges.length; i++) { let { $from, $to } = ranges[i], mapping = tr.mapping.slice(mapFrom); tr.replace( mapping.map($from.pos), mapping.map($to.pos), i ? Slice.empty : content, ); } let sel = Selection.findFrom( tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)), -1, ); if (sel) tr.setSelection(sel); } replaceWith(tr, node) { this.replace(tr, new Slice(Fragment.from(node), 0, 0)); } forEachCell(f) { let table = this.$anchorCell.node(-1), map = TableMap.get(table), start = this.$anchorCell.start(-1); let 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]); } // :: () → bool // True if this selection goes all the way from the top to the // bottom of the table. isColSelection() { let anchorTop = this.$anchorCell.index(-1), headTop = this.$headCell.index(-1); if (Math.min(anchorTop, headTop) > 0) return false; let anchorBot = anchorTop + this.$anchorCell.nodeAfter.attrs.rowspan, headBot = headTop + this.$headCell.nodeAfter.attrs.rowspan; return Math.max(anchorBot, headBot) == this.$headCell.node(-1).childCount; } // :: (ResolvedPos, ?ResolvedPos) → CellSelection // Returns the smallest column selection that covers the given anchor // and head cell. static colSelection($anchorCell, $headCell = $anchorCell) { let map = TableMap.get($anchorCell.node(-1)), start = $anchorCell.start(-1); let anchorRect = map.findCell($anchorCell.pos - start), headRect = map.findCell($headCell.pos - start); let 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); } // :: () → bool // True if this selection goes all the way from the left to the // right of the table. isRowSelection() { let map = TableMap.get(this.$anchorCell.node(-1)), start = this.$anchorCell.start(-1); let anchorLeft = map.colCount(this.$anchorCell.pos - start), headLeft = map.colCount(this.$headCell.pos - start); if (Math.min(anchorLeft, headLeft) > 0) return false; let anchorRight = anchorLeft + this.$anchorCell.nodeAfter.attrs.colspan, headRight = headLeft + this.$headCell.nodeAfter.attrs.colspan; return Math.max(anchorRight, headRight) == map.width; } eq(other) { return ( other instanceof CellSelection && other.$anchorCell.pos == this.$anchorCell.pos && other.$headCell.pos == this.$headCell.pos ); } // :: (ResolvedPos, ?ResolvedPos) → CellSelection // Returns the smallest row selection that covers the given anchor // and head cell. static rowSelection($anchorCell, $headCell = $anchorCell) { let map = TableMap.get($anchorCell.node(-1)), start = $anchorCell.start(-1); let anchorRect = map.findCell($anchorCell.pos - start), headRect = map.findCell($headCell.pos - start); let 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); } toJSON() { return { type: 'cell', anchor: this.$anchorCell.pos, head: this.$headCell.pos, }; } static fromJSON(doc, json) { return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head)); } // :: (Node, number, ?number) → CellSelection static create(doc, anchorCell, headCell = anchorCell) { return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell)); } getBookmark() { return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos); } } CellSelection.prototype.visible = false; Selection.jsonID('cell', CellSelection); class CellBookmark { constructor(anchor, head) { this.anchor = anchor; this.head = head; } map(mapping) { return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head)); } resolve(doc) { let $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) { if (!(state.selection instanceof CellSelection)) return null; let 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 }) { 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 }) { let fromCellBoundaryNode; let toCellBoundaryNode; for (let i = $from.depth; i > 0; i--) { let 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--) { let 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, tr, allowTableNodeSelection) { let sel = (tr || state).selection, doc = (tr || state).doc, normalize, 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') { let $cell = doc.resolve(sel.from + 1); normalize = CellSelection.rowSelection($cell, $cell); } else if (!allowTableNodeSelection) { let map = TableMap.get(sel.node), start = sel.from + 1; let 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; }