]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / table / LexicalTableObserver.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 type {LexicalEditor, NodeKey, TextFormatType} from 'lexical';
10
11 import {
12   addClassNamesToElement,
13   removeClassNamesFromElement,
14 } from '@lexical/utils';
15 import {
16   $createParagraphNode,
17   $createRangeSelection,
18   $createTextNode,
19   $getNearestNodeFromDOMNode,
20   $getNodeByKey,
21   $getRoot,
22   $getSelection,
23   $isElementNode,
24   $setSelection,
25   SELECTION_CHANGE_COMMAND,
26 } from 'lexical';
27 import invariant from 'lexical/shared/invariant';
28
29 import {$isTableCellNode} from './LexicalTableCellNode';
30 import {$isTableNode} from './LexicalTableNode';
31 import {
32   $createTableSelection,
33   $isTableSelection,
34   type TableSelection,
35 } from './LexicalTableSelection';
36 import {
37   $findTableNode,
38   $updateDOMForSelection,
39   getDOMSelection,
40   getTable,
41 } from './LexicalTableSelectionHelpers';
42
43 export type TableDOMCell = {
44   elem: HTMLElement;
45   highlighted: boolean;
46   hasBackgroundColor: boolean;
47   x: number;
48   y: number;
49 };
50
51 export type TableDOMRows = Array<Array<TableDOMCell | undefined> | undefined>;
52
53 export type TableDOMTable = {
54   domRows: TableDOMRows;
55   columns: number;
56   rows: number;
57 };
58
59 export class TableObserver {
60   focusX: number;
61   focusY: number;
62   listenersToRemove: Set<() => void>;
63   table: TableDOMTable;
64   isHighlightingCells: boolean;
65   anchorX: number;
66   anchorY: number;
67   tableNodeKey: NodeKey;
68   anchorCell: TableDOMCell | null;
69   focusCell: TableDOMCell | null;
70   anchorCellNodeKey: NodeKey | null;
71   focusCellNodeKey: NodeKey | null;
72   editor: LexicalEditor;
73   tableSelection: TableSelection | null;
74   hasHijackedSelectionStyles: boolean;
75   isSelecting: boolean;
76
77   constructor(editor: LexicalEditor, tableNodeKey: string) {
78     this.isHighlightingCells = false;
79     this.anchorX = -1;
80     this.anchorY = -1;
81     this.focusX = -1;
82     this.focusY = -1;
83     this.listenersToRemove = new Set();
84     this.tableNodeKey = tableNodeKey;
85     this.editor = editor;
86     this.table = {
87       columns: 0,
88       domRows: [],
89       rows: 0,
90     };
91     this.tableSelection = null;
92     this.anchorCellNodeKey = null;
93     this.focusCellNodeKey = null;
94     this.anchorCell = null;
95     this.focusCell = null;
96     this.hasHijackedSelectionStyles = false;
97     this.trackTable();
98     this.isSelecting = false;
99   }
100
101   getTable(): TableDOMTable {
102     return this.table;
103   }
104
105   removeListeners() {
106     Array.from(this.listenersToRemove).forEach((removeListener) =>
107       removeListener(),
108     );
109   }
110
111   trackTable() {
112     const observer = new MutationObserver((records) => {
113       this.editor.update(() => {
114         let gridNeedsRedraw = false;
115
116         for (let i = 0; i < records.length; i++) {
117           const record = records[i];
118           const target = record.target;
119           const nodeName = target.nodeName;
120
121           if (
122             nodeName === 'TABLE' ||
123             nodeName === 'TBODY' ||
124             nodeName === 'THEAD' ||
125             nodeName === 'TR'
126           ) {
127             gridNeedsRedraw = true;
128             break;
129           }
130         }
131
132         if (!gridNeedsRedraw) {
133           return;
134         }
135
136         const tableElement = this.editor.getElementByKey(this.tableNodeKey);
137
138         if (!tableElement) {
139           throw new Error('Expected to find TableElement in DOM');
140         }
141
142         this.table = getTable(tableElement);
143       });
144     });
145     this.editor.update(() => {
146       const tableElement = this.editor.getElementByKey(this.tableNodeKey);
147
148       if (!tableElement) {
149         throw new Error('Expected to find TableElement in DOM');
150       }
151
152       this.table = getTable(tableElement);
153       observer.observe(tableElement, {
154         attributes: true,
155         childList: true,
156         subtree: true,
157       });
158     });
159   }
160
161   clearHighlight() {
162     const editor = this.editor;
163     this.isHighlightingCells = false;
164     this.anchorX = -1;
165     this.anchorY = -1;
166     this.focusX = -1;
167     this.focusY = -1;
168     this.tableSelection = null;
169     this.anchorCellNodeKey = null;
170     this.focusCellNodeKey = null;
171     this.anchorCell = null;
172     this.focusCell = null;
173     this.hasHijackedSelectionStyles = false;
174
175     this.enableHighlightStyle();
176
177     editor.update(() => {
178       const tableNode = $getNodeByKey(this.tableNodeKey);
179
180       if (!$isTableNode(tableNode)) {
181         throw new Error('Expected TableNode.');
182       }
183
184       const tableElement = editor.getElementByKey(this.tableNodeKey);
185
186       if (!tableElement) {
187         throw new Error('Expected to find TableElement in DOM');
188       }
189
190       const grid = getTable(tableElement);
191       $updateDOMForSelection(editor, grid, null);
192       $setSelection(null);
193       editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
194     });
195   }
196
197   enableHighlightStyle() {
198     const editor = this.editor;
199     editor.update(() => {
200       const tableElement = editor.getElementByKey(this.tableNodeKey);
201
202       if (!tableElement) {
203         throw new Error('Expected to find TableElement in DOM');
204       }
205
206       removeClassNamesFromElement(
207         tableElement,
208         editor._config.theme.tableSelection,
209       );
210       tableElement.classList.remove('disable-selection');
211       this.hasHijackedSelectionStyles = false;
212     });
213   }
214
215   disableHighlightStyle() {
216     const editor = this.editor;
217     editor.update(() => {
218       const tableElement = editor.getElementByKey(this.tableNodeKey);
219
220       if (!tableElement) {
221         throw new Error('Expected to find TableElement in DOM');
222       }
223
224       addClassNamesToElement(tableElement, editor._config.theme.tableSelection);
225       this.hasHijackedSelectionStyles = true;
226     });
227   }
228
229   updateTableTableSelection(selection: TableSelection | null): void {
230     if (selection !== null && selection.tableKey === this.tableNodeKey) {
231       const editor = this.editor;
232       this.tableSelection = selection;
233       this.isHighlightingCells = true;
234       this.disableHighlightStyle();
235       $updateDOMForSelection(editor, this.table, this.tableSelection);
236     } else if (selection == null) {
237       this.clearHighlight();
238     } else {
239       this.tableNodeKey = selection.tableKey;
240       this.updateTableTableSelection(selection);
241     }
242   }
243
244   setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) {
245     const editor = this.editor;
246     editor.update(() => {
247       const tableNode = $getNodeByKey(this.tableNodeKey);
248
249       if (!$isTableNode(tableNode)) {
250         throw new Error('Expected TableNode.');
251       }
252
253       const tableElement = editor.getElementByKey(this.tableNodeKey);
254
255       if (!tableElement) {
256         throw new Error('Expected to find TableElement in DOM');
257       }
258
259       const cellX = cell.x;
260       const cellY = cell.y;
261       this.focusCell = cell;
262
263       if (this.anchorCell !== null) {
264         const domSelection = getDOMSelection(editor._window);
265         // Collapse the selection
266         if (domSelection) {
267           domSelection.setBaseAndExtent(
268             this.anchorCell.elem,
269             0,
270             this.focusCell.elem,
271             0,
272           );
273         }
274       }
275
276       if (
277         !this.isHighlightingCells &&
278         (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart)
279       ) {
280         this.isHighlightingCells = true;
281         this.disableHighlightStyle();
282       } else if (cellX === this.focusX && cellY === this.focusY) {
283         return;
284       }
285
286       this.focusX = cellX;
287       this.focusY = cellY;
288
289       if (this.isHighlightingCells) {
290         const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
291
292         if (
293           this.tableSelection != null &&
294           this.anchorCellNodeKey != null &&
295           $isTableCellNode(focusTableCellNode) &&
296           tableNode.is($findTableNode(focusTableCellNode))
297         ) {
298           const focusNodeKey = focusTableCellNode.getKey();
299
300           this.tableSelection =
301             this.tableSelection.clone() || $createTableSelection();
302
303           this.focusCellNodeKey = focusNodeKey;
304           this.tableSelection.set(
305             this.tableNodeKey,
306             this.anchorCellNodeKey,
307             this.focusCellNodeKey,
308           );
309
310           $setSelection(this.tableSelection);
311
312           editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
313
314           $updateDOMForSelection(editor, this.table, this.tableSelection);
315         }
316       }
317     });
318   }
319
320   setAnchorCellForSelection(cell: TableDOMCell) {
321     this.isHighlightingCells = false;
322     this.anchorCell = cell;
323     this.anchorX = cell.x;
324     this.anchorY = cell.y;
325
326     this.editor.update(() => {
327       const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem);
328
329       if ($isTableCellNode(anchorTableCellNode)) {
330         const anchorNodeKey = anchorTableCellNode.getKey();
331         this.tableSelection =
332           this.tableSelection != null
333             ? this.tableSelection.clone()
334             : $createTableSelection();
335         this.anchorCellNodeKey = anchorNodeKey;
336       }
337     });
338   }
339
340   formatCells(type: TextFormatType) {
341     this.editor.update(() => {
342       const selection = $getSelection();
343
344       if (!$isTableSelection(selection)) {
345         invariant(false, 'Expected grid selection');
346       }
347
348       const formatSelection = $createRangeSelection();
349
350       const anchor = formatSelection.anchor;
351       const focus = formatSelection.focus;
352
353       selection.getNodes().forEach((cellNode) => {
354         if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) {
355           anchor.set(cellNode.getKey(), 0, 'element');
356           focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element');
357           formatSelection.formatText(type);
358         }
359       });
360
361       $setSelection(selection);
362
363       this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
364     });
365   }
366
367   clearText() {
368     const editor = this.editor;
369     editor.update(() => {
370       const tableNode = $getNodeByKey(this.tableNodeKey);
371
372       if (!$isTableNode(tableNode)) {
373         throw new Error('Expected TableNode.');
374       }
375
376       const selection = $getSelection();
377
378       if (!$isTableSelection(selection)) {
379         invariant(false, 'Expected grid selection');
380       }
381
382       const selectedNodes = selection.getNodes().filter($isTableCellNode);
383
384       if (selectedNodes.length === this.table.columns * this.table.rows) {
385         tableNode.selectPrevious();
386         // Delete entire table
387         tableNode.remove();
388         const rootNode = $getRoot();
389         rootNode.selectStart();
390         return;
391       }
392
393       selectedNodes.forEach((cellNode) => {
394         if ($isElementNode(cellNode)) {
395           const paragraphNode = $createParagraphNode();
396           const textNode = $createTextNode();
397           paragraphNode.append(textNode);
398           cellNode.append(paragraphNode);
399           cellNode.getChildren().forEach((child) => {
400             if (child !== paragraphNode) {
401               child.remove();
402             }
403           });
404         }
405       });
406
407       $updateDOMForSelection(editor, this.table, null);
408
409       $setSelection(null);
410
411       editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
412     });
413   }
414 }