/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import {$findMatchingParent} from '@lexical/utils'; import { $createPoint, $getNodeByKey, $isElementNode, $normalizeSelection__EXPERIMENTAL, BaseSelection, isCurrentlyReadOnlyMode, LexicalNode, NodeKey, PointType, } from 'lexical'; import invariant from 'lexical/shared/invariant'; import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; import {$isTableNode} from './LexicalTableNode'; import {$isTableRowNode} from './LexicalTableRowNode'; import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils'; export type TableSelectionShape = { fromX: number; fromY: number; toX: number; toY: number; }; export type TableMapValueType = { cell: TableCellNode; startRow: number; startColumn: number; }; export type TableMapType = Array>; export class TableSelection implements BaseSelection { tableKey: NodeKey; anchor: PointType; focus: PointType; _cachedNodes: Array | null; dirty: boolean; constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) { this.anchor = anchor; this.focus = focus; anchor._selection = this; focus._selection = this; this._cachedNodes = null; this.dirty = false; this.tableKey = tableKey; } getStartEndPoints(): [PointType, PointType] { return [this.anchor, this.focus]; } /** * Returns whether the Selection is "backwards", meaning the focus * logically precedes the anchor in the EditorState. * @returns true if the Selection is backwards, false otherwise. */ isBackward(): boolean { return this.focus.isBefore(this.anchor); } getCachedNodes(): LexicalNode[] | null { return this._cachedNodes; } setCachedNodes(nodes: LexicalNode[] | null): void { this._cachedNodes = nodes; } is(selection: null | BaseSelection): boolean { if (!$isTableSelection(selection)) { return false; } return ( this.tableKey === selection.tableKey && this.anchor.is(selection.anchor) && this.focus.is(selection.focus) ); } set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void { this.dirty = true; this.tableKey = tableKey; this.anchor.key = anchorCellKey; this.focus.key = focusCellKey; this._cachedNodes = null; } clone(): TableSelection { return new TableSelection(this.tableKey, this.anchor, this.focus); } isCollapsed(): boolean { return false; } extract(): Array { return this.getNodes(); } insertRawText(text: string): void { // Do nothing? } insertText(): void { // Do nothing? } insertNodes(nodes: Array) { const focusNode = this.focus.getNode(); invariant( $isElementNode(focusNode), 'Expected TableSelection focus to be an ElementNode', ); const selection = $normalizeSelection__EXPERIMENTAL( focusNode.select(0, focusNode.getChildrenSize()), ); selection.insertNodes(nodes); } // TODO Deprecate this method. It's confusing when used with colspan|rowspan getShape(): TableSelectionShape { const anchorCellNode = $getNodeByKey(this.anchor.key); invariant( $isTableCellNode(anchorCellNode), 'Expected TableSelection anchor to be (or a child of) TableCellNode', ); const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode); invariant( anchorCellNodeRect !== null, 'getCellRect: expected to find AnchorNode', ); const focusCellNode = $getNodeByKey(this.focus.key); invariant( $isTableCellNode(focusCellNode), 'Expected TableSelection focus to be (or a child of) TableCellNode', ); const focusCellNodeRect = $getTableCellNodeRect(focusCellNode); invariant( focusCellNodeRect !== null, 'getCellRect: expected to find focusCellNode', ); const startX = Math.min( anchorCellNodeRect.columnIndex, focusCellNodeRect.columnIndex, ); const stopX = Math.max( anchorCellNodeRect.columnIndex, focusCellNodeRect.columnIndex, ); const startY = Math.min( anchorCellNodeRect.rowIndex, focusCellNodeRect.rowIndex, ); const stopY = Math.max( anchorCellNodeRect.rowIndex, focusCellNodeRect.rowIndex, ); return { fromX: Math.min(startX, stopX), fromY: Math.min(startY, stopY), toX: Math.max(startX, stopX), toY: Math.max(startY, stopY), }; } getNodes(): Array { const cachedNodes = this._cachedNodes; if (cachedNodes !== null) { return cachedNodes; } const anchorNode = this.anchor.getNode(); const focusNode = this.focus.getNode(); const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode); // todo replace with triplet const focusCell = $findMatchingParent(focusNode, $isTableCellNode); invariant( $isTableCellNode(anchorCell), 'Expected TableSelection anchor to be (or a child of) TableCellNode', ); invariant( $isTableCellNode(focusCell), 'Expected TableSelection focus to be (or a child of) TableCellNode', ); const anchorRow = anchorCell.getParent(); invariant( $isTableRowNode(anchorRow), 'Expected anchorCell to have a parent TableRowNode', ); const tableNode = anchorRow.getParent(); invariant( $isTableNode(tableNode), 'Expected tableNode to have a parent TableNode', ); const focusCellGrid = focusCell.getParents()[1]; if (focusCellGrid !== tableNode) { if (!tableNode.isParentOf(focusCell)) { // focus is on higher Grid level than anchor const gridParent = tableNode.getParent(); invariant(gridParent != null, 'Expected gridParent to have a parent'); this.set(this.tableKey, gridParent.getKey(), focusCell.getKey()); } else { // anchor is on higher Grid level than focus const focusCellParent = focusCellGrid.getParent(); invariant( focusCellParent != null, 'Expected focusCellParent to have a parent', ); this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey()); } return this.getNodes(); } // TODO Mapping the whole Grid every time not efficient. We need to compute the entire state only // once (on load) and iterate on it as updates occur. However, to do this we need to have the // ability to store a state. Killing TableSelection and moving the logic to the plugin would make // this possible. const [map, cellAMap, cellBMap] = $computeTableMap( tableNode, anchorCell, focusCell, ); let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn); let minRow = Math.min(cellAMap.startRow, cellBMap.startRow); let maxColumn = Math.max( cellAMap.startColumn + cellAMap.cell.__colSpan - 1, cellBMap.startColumn + cellBMap.cell.__colSpan - 1, ); let maxRow = Math.max( cellAMap.startRow + cellAMap.cell.__rowSpan - 1, cellBMap.startRow + cellBMap.cell.__rowSpan - 1, ); let exploredMinColumn = minColumn; let exploredMinRow = minRow; let exploredMaxColumn = minColumn; let exploredMaxRow = minRow; function expandBoundary(mapValue: TableMapValueType): void { const { cell, startColumn: cellStartColumn, startRow: cellStartRow, } = mapValue; minColumn = Math.min(minColumn, cellStartColumn); minRow = Math.min(minRow, cellStartRow); maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1); maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1); } while ( minColumn < exploredMinColumn || minRow < exploredMinRow || maxColumn > exploredMaxColumn || maxRow > exploredMaxRow ) { if (minColumn < exploredMinColumn) { // Expand on the left const rowDiff = exploredMaxRow - exploredMinRow; const previousColumn = exploredMinColumn - 1; for (let i = 0; i <= rowDiff; i++) { expandBoundary(map[exploredMinRow + i][previousColumn]); } exploredMinColumn = previousColumn; } if (minRow < exploredMinRow) { // Expand on top const columnDiff = exploredMaxColumn - exploredMinColumn; const previousRow = exploredMinRow - 1; for (let i = 0; i <= columnDiff; i++) { expandBoundary(map[previousRow][exploredMinColumn + i]); } exploredMinRow = previousRow; } if (maxColumn > exploredMaxColumn) { // Expand on the right const rowDiff = exploredMaxRow - exploredMinRow; const nextColumn = exploredMaxColumn + 1; for (let i = 0; i <= rowDiff; i++) { expandBoundary(map[exploredMinRow + i][nextColumn]); } exploredMaxColumn = nextColumn; } if (maxRow > exploredMaxRow) { // Expand on the bottom const columnDiff = exploredMaxColumn - exploredMinColumn; const nextRow = exploredMaxRow + 1; for (let i = 0; i <= columnDiff; i++) { expandBoundary(map[nextRow][exploredMinColumn + i]); } exploredMaxRow = nextRow; } } const nodes: Array = [tableNode]; let lastRow = null; for (let i = minRow; i <= maxRow; i++) { for (let j = minColumn; j <= maxColumn; j++) { const {cell} = map[i][j]; const currentRow = cell.getParent(); invariant( $isTableRowNode(currentRow), 'Expected TableCellNode parent to be a TableRowNode', ); if (currentRow !== lastRow) { nodes.push(currentRow); } nodes.push(cell, ...$getChildrenRecursively(cell)); lastRow = currentRow; } } if (!isCurrentlyReadOnlyMode()) { this._cachedNodes = nodes; } return nodes; } getTextContent(): string { const nodes = this.getNodes().filter((node) => $isTableCellNode(node)); let textContent = ''; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const row = node.__parent; const nextRow = (nodes[i + 1] || {}).__parent; textContent += node.getTextContent() + (nextRow !== row ? '\n' : '\t'); } return textContent; } } export function $isTableSelection(x: unknown): x is TableSelection { return x instanceof TableSelection; } export function $createTableSelection(): TableSelection { const anchor = $createPoint('root', 0, 'element'); const focus = $createPoint('root', 0, 'element'); return new TableSelection('root', anchor, focus); } export function $getChildrenRecursively(node: LexicalNode): Array { const nodes = []; const stack = [node]; while (stack.length > 0) { const currentNode = stack.pop(); invariant( currentNode !== undefined, "Stack.length > 0; can't be undefined", ); if ($isElementNode(currentNode)) { stack.unshift(...currentNode.getChildren()); } if (currentNode !== node) { nodes.push(currentNode); } } return nodes; }