2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import {$findMatchingParent} from '@lexical/utils';
14 $normalizeSelection__EXPERIMENTAL,
16 isCurrentlyReadOnlyMode,
21 import invariant from 'lexical/shared/invariant';
23 import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
24 import {$isTableNode} from './LexicalTableNode';
25 import {$isTableRowNode} from './LexicalTableRowNode';
26 import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils';
28 export type TableSelectionShape = {
35 export type TableMapValueType = {
40 export type TableMapType = Array<Array<TableMapValueType>>;
42 export class TableSelection implements BaseSelection {
46 _cachedNodes: Array<LexicalNode> | null;
49 constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) {
52 anchor._selection = this;
53 focus._selection = this;
54 this._cachedNodes = null;
56 this.tableKey = tableKey;
59 getStartEndPoints(): [PointType, PointType] {
60 return [this.anchor, this.focus];
64 * Returns whether the Selection is "backwards", meaning the focus
65 * logically precedes the anchor in the EditorState.
66 * @returns true if the Selection is backwards, false otherwise.
68 isBackward(): boolean {
69 return this.focus.isBefore(this.anchor);
72 getCachedNodes(): LexicalNode[] | null {
73 return this._cachedNodes;
76 setCachedNodes(nodes: LexicalNode[] | null): void {
77 this._cachedNodes = nodes;
80 is(selection: null | BaseSelection): boolean {
81 if (!$isTableSelection(selection)) {
85 this.tableKey === selection.tableKey &&
86 this.anchor.is(selection.anchor) &&
87 this.focus.is(selection.focus)
91 set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void {
93 this.tableKey = tableKey;
94 this.anchor.key = anchorCellKey;
95 this.focus.key = focusCellKey;
96 this._cachedNodes = null;
99 clone(): TableSelection {
100 return new TableSelection(this.tableKey, this.anchor, this.focus);
103 isCollapsed(): boolean {
107 extract(): Array<LexicalNode> {
108 return this.getNodes();
111 insertRawText(text: string): void {
119 insertNodes(nodes: Array<LexicalNode>) {
120 const focusNode = this.focus.getNode();
122 $isElementNode(focusNode),
123 'Expected TableSelection focus to be an ElementNode',
125 const selection = $normalizeSelection__EXPERIMENTAL(
126 focusNode.select(0, focusNode.getChildrenSize()),
128 selection.insertNodes(nodes);
131 // TODO Deprecate this method. It's confusing when used with colspan|rowspan
132 getShape(): TableSelectionShape {
133 const anchorCellNode = $getNodeByKey(this.anchor.key);
135 $isTableCellNode(anchorCellNode),
136 'Expected TableSelection anchor to be (or a child of) TableCellNode',
138 const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode);
140 anchorCellNodeRect !== null,
141 'getCellRect: expected to find AnchorNode',
144 const focusCellNode = $getNodeByKey(this.focus.key);
146 $isTableCellNode(focusCellNode),
147 'Expected TableSelection focus to be (or a child of) TableCellNode',
149 const focusCellNodeRect = $getTableCellNodeRect(focusCellNode);
151 focusCellNodeRect !== null,
152 'getCellRect: expected to find focusCellNode',
155 const startX = Math.min(
156 anchorCellNodeRect.columnIndex,
157 focusCellNodeRect.columnIndex,
159 const stopX = Math.max(
160 anchorCellNodeRect.columnIndex,
161 focusCellNodeRect.columnIndex,
164 const startY = Math.min(
165 anchorCellNodeRect.rowIndex,
166 focusCellNodeRect.rowIndex,
168 const stopY = Math.max(
169 anchorCellNodeRect.rowIndex,
170 focusCellNodeRect.rowIndex,
174 fromX: Math.min(startX, stopX),
175 fromY: Math.min(startY, stopY),
176 toX: Math.max(startX, stopX),
177 toY: Math.max(startY, stopY),
181 getNodes(): Array<LexicalNode> {
182 const cachedNodes = this._cachedNodes;
183 if (cachedNodes !== null) {
187 const anchorNode = this.anchor.getNode();
188 const focusNode = this.focus.getNode();
189 const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode);
190 // todo replace with triplet
191 const focusCell = $findMatchingParent(focusNode, $isTableCellNode);
193 $isTableCellNode(anchorCell),
194 'Expected TableSelection anchor to be (or a child of) TableCellNode',
197 $isTableCellNode(focusCell),
198 'Expected TableSelection focus to be (or a child of) TableCellNode',
200 const anchorRow = anchorCell.getParent();
202 $isTableRowNode(anchorRow),
203 'Expected anchorCell to have a parent TableRowNode',
205 const tableNode = anchorRow.getParent();
207 $isTableNode(tableNode),
208 'Expected tableNode to have a parent TableNode',
211 const focusCellGrid = focusCell.getParents()[1];
212 if (focusCellGrid !== tableNode) {
213 if (!tableNode.isParentOf(focusCell)) {
214 // focus is on higher Grid level than anchor
215 const gridParent = tableNode.getParent();
216 invariant(gridParent != null, 'Expected gridParent to have a parent');
217 this.set(this.tableKey, gridParent.getKey(), focusCell.getKey());
219 // anchor is on higher Grid level than focus
220 const focusCellParent = focusCellGrid.getParent();
222 focusCellParent != null,
223 'Expected focusCellParent to have a parent',
225 this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey());
227 return this.getNodes();
230 // TODO Mapping the whole Grid every time not efficient. We need to compute the entire state only
231 // once (on load) and iterate on it as updates occur. However, to do this we need to have the
232 // ability to store a state. Killing TableSelection and moving the logic to the plugin would make
234 const [map, cellAMap, cellBMap] = $computeTableMap(
240 let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn);
241 let minRow = Math.min(cellAMap.startRow, cellBMap.startRow);
242 let maxColumn = Math.max(
243 cellAMap.startColumn + cellAMap.cell.__colSpan - 1,
244 cellBMap.startColumn + cellBMap.cell.__colSpan - 1,
246 let maxRow = Math.max(
247 cellAMap.startRow + cellAMap.cell.__rowSpan - 1,
248 cellBMap.startRow + cellBMap.cell.__rowSpan - 1,
250 let exploredMinColumn = minColumn;
251 let exploredMinRow = minRow;
252 let exploredMaxColumn = minColumn;
253 let exploredMaxRow = minRow;
254 function expandBoundary(mapValue: TableMapValueType): void {
257 startColumn: cellStartColumn,
258 startRow: cellStartRow,
260 minColumn = Math.min(minColumn, cellStartColumn);
261 minRow = Math.min(minRow, cellStartRow);
262 maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1);
263 maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1);
266 minColumn < exploredMinColumn ||
267 minRow < exploredMinRow ||
268 maxColumn > exploredMaxColumn ||
269 maxRow > exploredMaxRow
271 if (minColumn < exploredMinColumn) {
272 // Expand on the left
273 const rowDiff = exploredMaxRow - exploredMinRow;
274 const previousColumn = exploredMinColumn - 1;
275 for (let i = 0; i <= rowDiff; i++) {
276 expandBoundary(map[exploredMinRow + i][previousColumn]);
278 exploredMinColumn = previousColumn;
280 if (minRow < exploredMinRow) {
282 const columnDiff = exploredMaxColumn - exploredMinColumn;
283 const previousRow = exploredMinRow - 1;
284 for (let i = 0; i <= columnDiff; i++) {
285 expandBoundary(map[previousRow][exploredMinColumn + i]);
287 exploredMinRow = previousRow;
289 if (maxColumn > exploredMaxColumn) {
290 // Expand on the right
291 const rowDiff = exploredMaxRow - exploredMinRow;
292 const nextColumn = exploredMaxColumn + 1;
293 for (let i = 0; i <= rowDiff; i++) {
294 expandBoundary(map[exploredMinRow + i][nextColumn]);
296 exploredMaxColumn = nextColumn;
298 if (maxRow > exploredMaxRow) {
299 // Expand on the bottom
300 const columnDiff = exploredMaxColumn - exploredMinColumn;
301 const nextRow = exploredMaxRow + 1;
302 for (let i = 0; i <= columnDiff; i++) {
303 expandBoundary(map[nextRow][exploredMinColumn + i]);
305 exploredMaxRow = nextRow;
309 const nodes: Array<LexicalNode> = [tableNode];
311 for (let i = minRow; i <= maxRow; i++) {
312 for (let j = minColumn; j <= maxColumn; j++) {
313 const {cell} = map[i][j];
314 const currentRow = cell.getParent();
316 $isTableRowNode(currentRow),
317 'Expected TableCellNode parent to be a TableRowNode',
319 if (currentRow !== lastRow) {
320 nodes.push(currentRow);
322 nodes.push(cell, ...$getChildrenRecursively(cell));
323 lastRow = currentRow;
327 if (!isCurrentlyReadOnlyMode()) {
328 this._cachedNodes = nodes;
333 getTextContent(): string {
334 const nodes = this.getNodes().filter((node) => $isTableCellNode(node));
335 let textContent = '';
336 for (let i = 0; i < nodes.length; i++) {
337 const node = nodes[i];
338 const row = node.__parent;
339 const nextRow = (nodes[i + 1] || {}).__parent;
340 textContent += node.getTextContent() + (nextRow !== row ? '\n' : '\t');
346 export function $isTableSelection(x: unknown): x is TableSelection {
347 return x instanceof TableSelection;
350 export function $createTableSelection(): TableSelection {
351 const anchor = $createPoint('root', 0, 'element');
352 const focus = $createPoint('root', 0, 'element');
353 return new TableSelection('root', anchor, focus);
356 export function $getChildrenRecursively(node: LexicalNode): Array<LexicalNode> {
358 const stack = [node];
359 while (stack.length > 0) {
360 const currentNode = stack.pop();
362 currentNode !== undefined,
363 "Stack.length > 0; can't be undefined",
365 if ($isElementNode(currentNode)) {
366 stack.unshift(...currentNode.getChildren());
368 if (currentNode !== node) {
369 nodes.push(currentNode);