]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts
Images: Added testing to cover animated avif handling
[bookstack] / resources / js / wysiwyg / lexical / table / LexicalTableSelection.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import {$findMatchingParent} from '@lexical/utils';
10 import {
11   $createPoint,
12   $getNodeByKey,
13   $isElementNode,
14   $normalizeSelection__EXPERIMENTAL,
15   BaseSelection,
16   isCurrentlyReadOnlyMode,
17   LexicalNode,
18   NodeKey,
19   PointType,
20 } from 'lexical';
21 import invariant from 'lexical/shared/invariant';
22
23 import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
24 import {$isTableNode} from './LexicalTableNode';
25 import {$isTableRowNode} from './LexicalTableRowNode';
26 import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils';
27
28 export type TableSelectionShape = {
29   fromX: number;
30   fromY: number;
31   toX: number;
32   toY: number;
33 };
34
35 export type TableMapValueType = {
36   cell: TableCellNode;
37   startRow: number;
38   startColumn: number;
39 };
40 export type TableMapType = Array<Array<TableMapValueType>>;
41
42 export class TableSelection implements BaseSelection {
43   tableKey: NodeKey;
44   anchor: PointType;
45   focus: PointType;
46   _cachedNodes: Array<LexicalNode> | null;
47   dirty: boolean;
48
49   constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) {
50     this.anchor = anchor;
51     this.focus = focus;
52     anchor._selection = this;
53     focus._selection = this;
54     this._cachedNodes = null;
55     this.dirty = false;
56     this.tableKey = tableKey;
57   }
58
59   getStartEndPoints(): [PointType, PointType] {
60     return [this.anchor, this.focus];
61   }
62
63   /**
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.
67    */
68   isBackward(): boolean {
69     return this.focus.isBefore(this.anchor);
70   }
71
72   getCachedNodes(): LexicalNode[] | null {
73     return this._cachedNodes;
74   }
75
76   setCachedNodes(nodes: LexicalNode[] | null): void {
77     this._cachedNodes = nodes;
78   }
79
80   is(selection: null | BaseSelection): boolean {
81     if (!$isTableSelection(selection)) {
82       return false;
83     }
84     return (
85       this.tableKey === selection.tableKey &&
86       this.anchor.is(selection.anchor) &&
87       this.focus.is(selection.focus)
88     );
89   }
90
91   set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void {
92     this.dirty = true;
93     this.tableKey = tableKey;
94     this.anchor.key = anchorCellKey;
95     this.focus.key = focusCellKey;
96     this._cachedNodes = null;
97   }
98
99   clone(): TableSelection {
100     return new TableSelection(this.tableKey, this.anchor, this.focus);
101   }
102
103   isCollapsed(): boolean {
104     return false;
105   }
106
107   extract(): Array<LexicalNode> {
108     return this.getNodes();
109   }
110
111   insertRawText(text: string): void {
112     // Do nothing?
113   }
114
115   insertText(): void {
116     // Do nothing?
117   }
118
119   insertNodes(nodes: Array<LexicalNode>) {
120     const focusNode = this.focus.getNode();
121     invariant(
122       $isElementNode(focusNode),
123       'Expected TableSelection focus to be an ElementNode',
124     );
125     const selection = $normalizeSelection__EXPERIMENTAL(
126       focusNode.select(0, focusNode.getChildrenSize()),
127     );
128     selection.insertNodes(nodes);
129   }
130
131   // TODO Deprecate this method. It's confusing when used with colspan|rowspan
132   getShape(): TableSelectionShape {
133     const anchorCellNode = $getNodeByKey(this.anchor.key);
134     invariant(
135       $isTableCellNode(anchorCellNode),
136       'Expected TableSelection anchor to be (or a child of) TableCellNode',
137     );
138     const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode);
139     invariant(
140       anchorCellNodeRect !== null,
141       'getCellRect: expected to find AnchorNode',
142     );
143
144     const focusCellNode = $getNodeByKey(this.focus.key);
145     invariant(
146       $isTableCellNode(focusCellNode),
147       'Expected TableSelection focus to be (or a child of) TableCellNode',
148     );
149     const focusCellNodeRect = $getTableCellNodeRect(focusCellNode);
150     invariant(
151       focusCellNodeRect !== null,
152       'getCellRect: expected to find focusCellNode',
153     );
154
155     const startX = Math.min(
156       anchorCellNodeRect.columnIndex,
157       focusCellNodeRect.columnIndex,
158     );
159     const stopX = Math.max(
160       anchorCellNodeRect.columnIndex,
161       focusCellNodeRect.columnIndex,
162     );
163
164     const startY = Math.min(
165       anchorCellNodeRect.rowIndex,
166       focusCellNodeRect.rowIndex,
167     );
168     const stopY = Math.max(
169       anchorCellNodeRect.rowIndex,
170       focusCellNodeRect.rowIndex,
171     );
172
173     return {
174       fromX: Math.min(startX, stopX),
175       fromY: Math.min(startY, stopY),
176       toX: Math.max(startX, stopX),
177       toY: Math.max(startY, stopY),
178     };
179   }
180
181   getNodes(): Array<LexicalNode> {
182     const cachedNodes = this._cachedNodes;
183     if (cachedNodes !== null) {
184       return cachedNodes;
185     }
186
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);
192     invariant(
193       $isTableCellNode(anchorCell),
194       'Expected TableSelection anchor to be (or a child of) TableCellNode',
195     );
196     invariant(
197       $isTableCellNode(focusCell),
198       'Expected TableSelection focus to be (or a child of) TableCellNode',
199     );
200     const anchorRow = anchorCell.getParent();
201     invariant(
202       $isTableRowNode(anchorRow),
203       'Expected anchorCell to have a parent TableRowNode',
204     );
205     const tableNode = anchorRow.getParent();
206     invariant(
207       $isTableNode(tableNode),
208       'Expected tableNode to have a parent TableNode',
209     );
210
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());
218       } else {
219         // anchor is on higher Grid level than focus
220         const focusCellParent = focusCellGrid.getParent();
221         invariant(
222           focusCellParent != null,
223           'Expected focusCellParent to have a parent',
224         );
225         this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey());
226       }
227       return this.getNodes();
228     }
229
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
233     // this possible.
234     const [map, cellAMap, cellBMap] = $computeTableMap(
235       tableNode,
236       anchorCell,
237       focusCell,
238     );
239
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,
245     );
246     let maxRow = Math.max(
247       cellAMap.startRow + cellAMap.cell.__rowSpan - 1,
248       cellBMap.startRow + cellBMap.cell.__rowSpan - 1,
249     );
250     let exploredMinColumn = minColumn;
251     let exploredMinRow = minRow;
252     let exploredMaxColumn = minColumn;
253     let exploredMaxRow = minRow;
254     function expandBoundary(mapValue: TableMapValueType): void {
255       const {
256         cell,
257         startColumn: cellStartColumn,
258         startRow: cellStartRow,
259       } = mapValue;
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);
264     }
265     while (
266       minColumn < exploredMinColumn ||
267       minRow < exploredMinRow ||
268       maxColumn > exploredMaxColumn ||
269       maxRow > exploredMaxRow
270     ) {
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]);
277         }
278         exploredMinColumn = previousColumn;
279       }
280       if (minRow < exploredMinRow) {
281         // Expand on top
282         const columnDiff = exploredMaxColumn - exploredMinColumn;
283         const previousRow = exploredMinRow - 1;
284         for (let i = 0; i <= columnDiff; i++) {
285           expandBoundary(map[previousRow][exploredMinColumn + i]);
286         }
287         exploredMinRow = previousRow;
288       }
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]);
295         }
296         exploredMaxColumn = nextColumn;
297       }
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]);
304         }
305         exploredMaxRow = nextRow;
306       }
307     }
308
309     const nodes: Array<LexicalNode> = [tableNode];
310     let lastRow = null;
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();
315         invariant(
316           $isTableRowNode(currentRow),
317           'Expected TableCellNode parent to be a TableRowNode',
318         );
319         if (currentRow !== lastRow) {
320           nodes.push(currentRow);
321         }
322         nodes.push(cell, ...$getChildrenRecursively(cell));
323         lastRow = currentRow;
324       }
325     }
326
327     if (!isCurrentlyReadOnlyMode()) {
328       this._cachedNodes = nodes;
329     }
330     return nodes;
331   }
332
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');
341     }
342     return textContent;
343   }
344 }
345
346 export function $isTableSelection(x: unknown): x is TableSelection {
347   return x instanceof TableSelection;
348 }
349
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);
354 }
355
356 export function $getChildrenRecursively(node: LexicalNode): Array<LexicalNode> {
357   const nodes = [];
358   const stack = [node];
359   while (stack.length > 0) {
360     const currentNode = stack.pop();
361     invariant(
362       currentNode !== undefined,
363       "Stack.length > 0; can't be undefined",
364     );
365     if ($isElementNode(currentNode)) {
366       stack.unshift(...currentNode.getChildren());
367     }
368     if (currentNode !== node) {
369       nodes.push(currentNode);
370     }
371   }
372   return nodes;
373 }