]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
d9164a778ce5094cf19285424dd7a25ec78dc7e1
[bookstack] / resources / js / wysiwyg / lexical / table / LexicalTableSelectionHelpers.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 {TableCellNode} from './LexicalTableCellNode';
10 import type {TableNode} from './LexicalTableNode';
11 import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver';
12 import type {
13   TableMapType,
14   TableMapValueType,
15   TableSelection,
16 } from './LexicalTableSelection';
17 import type {
18   BaseSelection,
19   LexicalCommand,
20   LexicalEditor,
21   LexicalNode,
22   RangeSelection,
23   TextFormatType,
24 } from 'lexical';
25
26 import {
27   $getClipboardDataFromSelection,
28   copyToClipboard,
29 } from '@lexical/clipboard';
30 import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
31 import {
32   $createParagraphNode,
33   $createRangeSelectionFromDom,
34   $createTextNode,
35   $getNearestNodeFromDOMNode,
36   $getPreviousSelection,
37   $getSelection,
38   $isDecoratorNode,
39   $isElementNode,
40   $isRangeSelection,
41   $isRootOrShadowRoot,
42   $isTextNode,
43   $setSelection,
44   COMMAND_PRIORITY_CRITICAL,
45   COMMAND_PRIORITY_HIGH,
46   CONTROLLED_TEXT_INSERTION_COMMAND,
47   CUT_COMMAND,
48   DELETE_CHARACTER_COMMAND,
49   DELETE_LINE_COMMAND,
50   DELETE_WORD_COMMAND,
51   FOCUS_COMMAND,
52   FORMAT_TEXT_COMMAND,
53   INSERT_PARAGRAPH_COMMAND,
54   KEY_ARROW_DOWN_COMMAND,
55   KEY_ARROW_LEFT_COMMAND,
56   KEY_ARROW_RIGHT_COMMAND,
57   KEY_ARROW_UP_COMMAND,
58   KEY_BACKSPACE_COMMAND,
59   KEY_DELETE_COMMAND,
60   KEY_ESCAPE_COMMAND,
61   KEY_TAB_COMMAND,
62   SELECTION_CHANGE_COMMAND,
63   SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
64 } from 'lexical';
65 import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
66 import invariant from 'lexical/shared/invariant';
67
68 import {$isTableCellNode} from './LexicalTableCellNode';
69 import {$isTableNode} from './LexicalTableNode';
70 import {TableDOMTable, TableObserver} from './LexicalTableObserver';
71 import {$isTableRowNode} from './LexicalTableRowNode';
72 import {$isTableSelection} from './LexicalTableSelection';
73 import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
74
75 const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
76
77 export const getDOMSelection = (
78   targetWindow: Window | null,
79 ): Selection | null =>
80   CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
81
82 const isMouseDownOnEvent = (event: MouseEvent) => {
83   return (event.buttons & 1) === 1;
84 };
85
86 export function applyTableHandlers(
87   tableNode: TableNode,
88   tableElement: HTMLTableElementWithWithTableSelectionState,
89   editor: LexicalEditor,
90   hasTabHandler: boolean,
91 ): TableObserver {
92   const rootElement = editor.getRootElement();
93
94   if (rootElement === null) {
95     throw new Error('No root element.');
96   }
97
98   const tableObserver = new TableObserver(editor, tableNode.getKey());
99   const editorWindow = editor._window || window;
100
101   attachTableObserverToTableElement(tableElement, tableObserver);
102
103   const createMouseHandlers = () => {
104     const onMouseUp = () => {
105       tableObserver.isSelecting = false;
106       editorWindow.removeEventListener('mouseup', onMouseUp);
107       editorWindow.removeEventListener('mousemove', onMouseMove);
108     };
109
110     const onMouseMove = (moveEvent: MouseEvent) => {
111       // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
112       setTimeout(() => {
113         if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
114           tableObserver.isSelecting = false;
115           editorWindow.removeEventListener('mouseup', onMouseUp);
116           editorWindow.removeEventListener('mousemove', onMouseMove);
117           return;
118         }
119         const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
120         if (
121           focusCell !== null &&
122           (tableObserver.anchorX !== focusCell.x ||
123             tableObserver.anchorY !== focusCell.y)
124         ) {
125           moveEvent.preventDefault();
126           tableObserver.setFocusCellForSelection(focusCell);
127         }
128       }, 0);
129     };
130     return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
131   };
132
133   tableElement.addEventListener('mousedown', (event: MouseEvent) => {
134     setTimeout(() => {
135       if (event.button !== 0) {
136         return;
137       }
138
139       if (!editorWindow) {
140         return;
141       }
142
143       const anchorCell = getDOMCellFromTarget(event.target as Node);
144       if (anchorCell !== null) {
145         stopEvent(event);
146         tableObserver.setAnchorCellForSelection(anchorCell);
147       }
148
149       const {onMouseUp, onMouseMove} = createMouseHandlers();
150       tableObserver.isSelecting = true;
151       editorWindow.addEventListener('mouseup', onMouseUp);
152       editorWindow.addEventListener('mousemove', onMouseMove);
153     }, 0);
154   });
155
156   // Clear selection when clicking outside of dom.
157   const mouseDownCallback = (event: MouseEvent) => {
158     if (event.button !== 0) {
159       return;
160     }
161
162     editor.update(() => {
163       const selection = $getSelection();
164       const target = event.target as Node;
165       if (
166         $isTableSelection(selection) &&
167         selection.tableKey === tableObserver.tableNodeKey &&
168         rootElement.contains(target)
169       ) {
170         tableObserver.clearHighlight();
171       }
172     });
173   };
174
175   editorWindow.addEventListener('mousedown', mouseDownCallback);
176
177   tableObserver.listenersToRemove.add(() =>
178     editorWindow.removeEventListener('mousedown', mouseDownCallback),
179   );
180
181   tableObserver.listenersToRemove.add(
182     editor.registerCommand<KeyboardEvent>(
183       KEY_ARROW_DOWN_COMMAND,
184       (event) =>
185         $handleArrowKey(editor, event, 'down', tableNode, tableObserver),
186       COMMAND_PRIORITY_HIGH,
187     ),
188   );
189
190   tableObserver.listenersToRemove.add(
191     editor.registerCommand<KeyboardEvent>(
192       KEY_ARROW_UP_COMMAND,
193       (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver),
194       COMMAND_PRIORITY_HIGH,
195     ),
196   );
197
198   tableObserver.listenersToRemove.add(
199     editor.registerCommand<KeyboardEvent>(
200       KEY_ARROW_LEFT_COMMAND,
201       (event) =>
202         $handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
203       COMMAND_PRIORITY_HIGH,
204     ),
205   );
206
207   tableObserver.listenersToRemove.add(
208     editor.registerCommand<KeyboardEvent>(
209       KEY_ARROW_RIGHT_COMMAND,
210       (event) =>
211         $handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
212       COMMAND_PRIORITY_HIGH,
213     ),
214   );
215
216   tableObserver.listenersToRemove.add(
217     editor.registerCommand<KeyboardEvent>(
218       KEY_ESCAPE_COMMAND,
219       (event) => {
220         const selection = $getSelection();
221         if ($isTableSelection(selection)) {
222           const focusCellNode = $findMatchingParent(
223             selection.focus.getNode(),
224             $isTableCellNode,
225           );
226           if ($isTableCellNode(focusCellNode)) {
227             stopEvent(event);
228             focusCellNode.selectEnd();
229             return true;
230           }
231         }
232
233         return false;
234       },
235       COMMAND_PRIORITY_HIGH,
236     ),
237   );
238
239   const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
240     const selection = $getSelection();
241
242     if (!$isSelectionInTable(selection, tableNode)) {
243       return false;
244     }
245
246     if ($isTableSelection(selection)) {
247       tableObserver.clearText();
248
249       return true;
250     } else if ($isRangeSelection(selection)) {
251       const tableCellNode = $findMatchingParent(
252         selection.anchor.getNode(),
253         (n) => $isTableCellNode(n),
254       );
255
256       if (!$isTableCellNode(tableCellNode)) {
257         return false;
258       }
259
260       const anchorNode = selection.anchor.getNode();
261       const focusNode = selection.focus.getNode();
262       const isAnchorInside = tableNode.isParentOf(anchorNode);
263       const isFocusInside = tableNode.isParentOf(focusNode);
264
265       const selectionContainsPartialTable =
266         (isAnchorInside && !isFocusInside) ||
267         (isFocusInside && !isAnchorInside);
268
269       if (selectionContainsPartialTable) {
270         tableObserver.clearText();
271         return true;
272       }
273
274       const nearestElementNode = $findMatchingParent(
275         selection.anchor.getNode(),
276         (n) => $isElementNode(n),
277       );
278
279       const topLevelCellElementNode =
280         nearestElementNode &&
281         $findMatchingParent(
282           nearestElementNode,
283           (n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
284         );
285
286       if (
287         !$isElementNode(topLevelCellElementNode) ||
288         !$isElementNode(nearestElementNode)
289       ) {
290         return false;
291       }
292
293       if (
294         command === DELETE_LINE_COMMAND &&
295         topLevelCellElementNode.getPreviousSibling() === null
296       ) {
297         // TODO: Fix Delete Line in Table Cells.
298         return true;
299       }
300     }
301
302     return false;
303   };
304
305   [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
306     (command) => {
307       tableObserver.listenersToRemove.add(
308         editor.registerCommand(
309           command,
310           deleteTextHandler(command),
311           COMMAND_PRIORITY_CRITICAL,
312         ),
313       );
314     },
315   );
316
317   const $deleteCellHandler = (
318     event: KeyboardEvent | ClipboardEvent | null,
319   ): boolean => {
320     const selection = $getSelection();
321
322     if (!$isSelectionInTable(selection, tableNode)) {
323       const nodes = selection ? selection.getNodes() : null;
324       if (nodes) {
325         const table = nodes.find(
326           (node) =>
327             $isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
328         );
329         if ($isTableNode(table)) {
330           const parentNode = table.getParent();
331           if (!parentNode) {
332             return false;
333           }
334           table.remove();
335         }
336       }
337       return false;
338     }
339
340     if ($isTableSelection(selection)) {
341       if (event) {
342         event.preventDefault();
343         event.stopPropagation();
344       }
345       tableObserver.clearText();
346
347       return true;
348     } else if ($isRangeSelection(selection)) {
349       const tableCellNode = $findMatchingParent(
350         selection.anchor.getNode(),
351         (n) => $isTableCellNode(n),
352       );
353
354       if (!$isTableCellNode(tableCellNode)) {
355         return false;
356       }
357     }
358
359     return false;
360   };
361
362   tableObserver.listenersToRemove.add(
363     editor.registerCommand<KeyboardEvent>(
364       KEY_BACKSPACE_COMMAND,
365       $deleteCellHandler,
366       COMMAND_PRIORITY_CRITICAL,
367     ),
368   );
369
370   tableObserver.listenersToRemove.add(
371     editor.registerCommand<KeyboardEvent>(
372       KEY_DELETE_COMMAND,
373       $deleteCellHandler,
374       COMMAND_PRIORITY_CRITICAL,
375     ),
376   );
377
378   tableObserver.listenersToRemove.add(
379     editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
380       CUT_COMMAND,
381       (event) => {
382         const selection = $getSelection();
383         if (selection) {
384           if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
385             return false;
386           }
387           // Copying to the clipboard is async so we must capture the data
388           // before we delete it
389           void copyToClipboard(
390             editor,
391             objectKlassEquals(event, ClipboardEvent)
392               ? (event as ClipboardEvent)
393               : null,
394             $getClipboardDataFromSelection(selection),
395           );
396           const intercepted = $deleteCellHandler(event);
397           if ($isRangeSelection(selection)) {
398             selection.removeText();
399           }
400           return intercepted;
401         }
402         return false;
403       },
404       COMMAND_PRIORITY_CRITICAL,
405     ),
406   );
407
408   tableObserver.listenersToRemove.add(
409     editor.registerCommand<TextFormatType>(
410       FORMAT_TEXT_COMMAND,
411       (payload) => {
412         const selection = $getSelection();
413
414         if (!$isSelectionInTable(selection, tableNode)) {
415           return false;
416         }
417
418         if ($isTableSelection(selection)) {
419           tableObserver.formatCells(payload);
420
421           return true;
422         } else if ($isRangeSelection(selection)) {
423           const tableCellNode = $findMatchingParent(
424             selection.anchor.getNode(),
425             (n) => $isTableCellNode(n),
426           );
427
428           if (!$isTableCellNode(tableCellNode)) {
429             return false;
430           }
431         }
432
433         return false;
434       },
435       COMMAND_PRIORITY_CRITICAL,
436     ),
437   );
438
439   tableObserver.listenersToRemove.add(
440     editor.registerCommand(
441       CONTROLLED_TEXT_INSERTION_COMMAND,
442       (payload) => {
443         const selection = $getSelection();
444
445         if (!$isSelectionInTable(selection, tableNode)) {
446           return false;
447         }
448
449         if ($isTableSelection(selection)) {
450           tableObserver.clearHighlight();
451
452           return false;
453         } else if ($isRangeSelection(selection)) {
454           const tableCellNode = $findMatchingParent(
455             selection.anchor.getNode(),
456             (n) => $isTableCellNode(n),
457           );
458
459           if (!$isTableCellNode(tableCellNode)) {
460             return false;
461           }
462
463           if (typeof payload === 'string') {
464             const edgePosition = $getTableEdgeCursorPosition(
465               editor,
466               selection,
467               tableNode,
468             );
469             if (edgePosition) {
470               $insertParagraphAtTableEdge(edgePosition, tableNode, [
471                 $createTextNode(payload),
472               ]);
473               return true;
474             }
475           }
476         }
477
478         return false;
479       },
480       COMMAND_PRIORITY_CRITICAL,
481     ),
482   );
483
484   if (hasTabHandler) {
485     tableObserver.listenersToRemove.add(
486       editor.registerCommand<KeyboardEvent>(
487         KEY_TAB_COMMAND,
488         (event) => {
489           const selection = $getSelection();
490           if (
491             !$isRangeSelection(selection) ||
492             !selection.isCollapsed() ||
493             !$isSelectionInTable(selection, tableNode)
494           ) {
495             return false;
496           }
497
498           const tableCellNode = $findCellNode(selection.anchor.getNode());
499           if (tableCellNode === null) {
500             return false;
501           }
502
503           stopEvent(event);
504
505           const currentCords = tableNode.getCordsFromCellNode(
506             tableCellNode,
507             tableObserver.table,
508           );
509
510           selectTableNodeInDirection(
511             tableObserver,
512             tableNode,
513             currentCords.x,
514             currentCords.y,
515             !event.shiftKey ? 'forward' : 'backward',
516           );
517
518           return true;
519         },
520         COMMAND_PRIORITY_CRITICAL,
521       ),
522     );
523   }
524
525   tableObserver.listenersToRemove.add(
526     editor.registerCommand(
527       FOCUS_COMMAND,
528       (payload) => {
529         return tableNode.isSelected();
530       },
531       COMMAND_PRIORITY_HIGH,
532     ),
533   );
534
535   function getObserverCellFromCellNode(
536     tableCellNode: TableCellNode,
537   ): TableDOMCell {
538     const currentCords = tableNode.getCordsFromCellNode(
539       tableCellNode,
540       tableObserver.table,
541     );
542     return tableNode.getDOMCellFromCordsOrThrow(
543       currentCords.x,
544       currentCords.y,
545       tableObserver.table,
546     );
547   }
548
549   tableObserver.listenersToRemove.add(
550     editor.registerCommand(
551       SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
552       (selectionPayload) => {
553         const {nodes, selection} = selectionPayload;
554         const anchorAndFocus = selection.getStartEndPoints();
555         const isTableSelection = $isTableSelection(selection);
556         const isRangeSelection = $isRangeSelection(selection);
557         const isSelectionInsideOfGrid =
558           (isRangeSelection &&
559             $findMatchingParent(selection.anchor.getNode(), (n) =>
560               $isTableCellNode(n),
561             ) !== null &&
562             $findMatchingParent(selection.focus.getNode(), (n) =>
563               $isTableCellNode(n),
564             ) !== null) ||
565           isTableSelection;
566
567         if (
568           nodes.length !== 1 ||
569           !$isTableNode(nodes[0]) ||
570           !isSelectionInsideOfGrid ||
571           anchorAndFocus === null
572         ) {
573           return false;
574         }
575         const [anchor] = anchorAndFocus;
576
577         const newGrid = nodes[0];
578         const newGridRows = newGrid.getChildren();
579         const newColumnCount = newGrid
580           .getFirstChildOrThrow<TableNode>()
581           .getChildrenSize();
582         const newRowCount = newGrid.getChildrenSize();
583         const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
584           $isTableCellNode(n),
585         );
586         const gridRowNode =
587           gridCellNode &&
588           $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
589         const gridNode =
590           gridRowNode &&
591           $findMatchingParent(gridRowNode, (n) => $isTableNode(n));
592
593         if (
594           !$isTableCellNode(gridCellNode) ||
595           !$isTableRowNode(gridRowNode) ||
596           !$isTableNode(gridNode)
597         ) {
598           return false;
599         }
600
601         const startY = gridRowNode.getIndexWithinParent();
602         const stopY = Math.min(
603           gridNode.getChildrenSize() - 1,
604           startY + newRowCount - 1,
605         );
606         const startX = gridCellNode.getIndexWithinParent();
607         const stopX = Math.min(
608           gridRowNode.getChildrenSize() - 1,
609           startX + newColumnCount - 1,
610         );
611         const fromX = Math.min(startX, stopX);
612         const fromY = Math.min(startY, stopY);
613         const toX = Math.max(startX, stopX);
614         const toY = Math.max(startY, stopY);
615         const gridRowNodes = gridNode.getChildren();
616         let newRowIdx = 0;
617
618         for (let r = fromY; r <= toY; r++) {
619           const currentGridRowNode = gridRowNodes[r];
620
621           if (!$isTableRowNode(currentGridRowNode)) {
622             return false;
623           }
624
625           const newGridRowNode = newGridRows[newRowIdx];
626
627           if (!$isTableRowNode(newGridRowNode)) {
628             return false;
629           }
630
631           const gridCellNodes = currentGridRowNode.getChildren();
632           const newGridCellNodes = newGridRowNode.getChildren();
633           let newColumnIdx = 0;
634
635           for (let c = fromX; c <= toX; c++) {
636             const currentGridCellNode = gridCellNodes[c];
637
638             if (!$isTableCellNode(currentGridCellNode)) {
639               return false;
640             }
641
642             const newGridCellNode = newGridCellNodes[newColumnIdx];
643
644             if (!$isTableCellNode(newGridCellNode)) {
645               return false;
646             }
647
648             const originalChildren = currentGridCellNode.getChildren();
649             newGridCellNode.getChildren().forEach((child) => {
650               if ($isTextNode(child)) {
651                 const paragraphNode = $createParagraphNode();
652                 paragraphNode.append(child);
653                 currentGridCellNode.append(child);
654               } else {
655                 currentGridCellNode.append(child);
656               }
657             });
658             originalChildren.forEach((n) => n.remove());
659             newColumnIdx++;
660           }
661
662           newRowIdx++;
663         }
664         return true;
665       },
666       COMMAND_PRIORITY_CRITICAL,
667     ),
668   );
669
670   tableObserver.listenersToRemove.add(
671     editor.registerCommand(
672       SELECTION_CHANGE_COMMAND,
673       () => {
674         const selection = $getSelection();
675         const prevSelection = $getPreviousSelection();
676
677         if ($isRangeSelection(selection)) {
678           const {anchor, focus} = selection;
679           const anchorNode = anchor.getNode();
680           const focusNode = focus.getNode();
681           // Using explicit comparison with table node to ensure it's not a nested table
682           // as in that case we'll leave selection resolving to that table
683           const anchorCellNode = $findCellNode(anchorNode);
684           const focusCellNode = $findCellNode(focusNode);
685           const isAnchorInside = !!(
686             anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
687           );
688           const isFocusInside = !!(
689             focusCellNode && tableNode.is($findTableNode(focusCellNode))
690           );
691           const isPartialyWithinTable = isAnchorInside !== isFocusInside;
692           const isWithinTable = isAnchorInside && isFocusInside;
693           const isBackward = selection.isBackward();
694
695           if (isPartialyWithinTable) {
696             const newSelection = selection.clone();
697             if (isFocusInside) {
698               const [tableMap] = $computeTableMap(
699                 tableNode,
700                 focusCellNode,
701                 focusCellNode,
702               );
703               const firstCell = tableMap[0][0].cell;
704               const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
705               newSelection.focus.set(
706                 isBackward ? firstCell.getKey() : lastCell.getKey(),
707                 isBackward
708                   ? firstCell.getChildrenSize()
709                   : lastCell.getChildrenSize(),
710                 'element',
711               );
712             }
713             $setSelection(newSelection);
714             $addHighlightStyleToTable(editor, tableObserver);
715           } else if (isWithinTable) {
716             // Handle case when selection spans across multiple cells but still
717             // has range selection, then we convert it into grid selection
718             if (!anchorCellNode.is(focusCellNode)) {
719               tableObserver.setAnchorCellForSelection(
720                 getObserverCellFromCellNode(anchorCellNode),
721               );
722               tableObserver.setFocusCellForSelection(
723                 getObserverCellFromCellNode(focusCellNode),
724                 true,
725               );
726               if (!tableObserver.isSelecting) {
727                 setTimeout(() => {
728                   const {onMouseUp, onMouseMove} = createMouseHandlers();
729                   tableObserver.isSelecting = true;
730                   editorWindow.addEventListener('mouseup', onMouseUp);
731                   editorWindow.addEventListener('mousemove', onMouseMove);
732                 }, 0);
733               }
734             }
735           }
736         } else if (
737           selection &&
738           $isTableSelection(selection) &&
739           selection.is(prevSelection) &&
740           selection.tableKey === tableNode.getKey()
741         ) {
742           // if selection goes outside of the table we need to change it to Range selection
743           const domSelection = getDOMSelection(editor._window);
744           if (
745             domSelection &&
746             domSelection.anchorNode &&
747             domSelection.focusNode
748           ) {
749             const focusNode = $getNearestNodeFromDOMNode(
750               domSelection.focusNode,
751             );
752             const isFocusOutside =
753               focusNode && !tableNode.is($findTableNode(focusNode));
754
755             const anchorNode = $getNearestNodeFromDOMNode(
756               domSelection.anchorNode,
757             );
758             const isAnchorInside =
759               anchorNode && tableNode.is($findTableNode(anchorNode));
760
761             if (
762               isFocusOutside &&
763               isAnchorInside &&
764               domSelection.rangeCount > 0
765             ) {
766               const newSelection = $createRangeSelectionFromDom(
767                 domSelection,
768                 editor,
769               );
770               if (newSelection) {
771                 newSelection.anchor.set(
772                   tableNode.getKey(),
773                   selection.isBackward() ? tableNode.getChildrenSize() : 0,
774                   'element',
775                 );
776                 domSelection.removeAllRanges();
777                 $setSelection(newSelection);
778               }
779             }
780           }
781         }
782
783         if (
784           selection &&
785           !selection.is(prevSelection) &&
786           ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
787           tableObserver.tableSelection &&
788           !tableObserver.tableSelection.is(prevSelection)
789         ) {
790           if (
791             $isTableSelection(selection) &&
792             selection.tableKey === tableObserver.tableNodeKey
793           ) {
794             tableObserver.updateTableTableSelection(selection);
795           } else if (
796             !$isTableSelection(selection) &&
797             $isTableSelection(prevSelection) &&
798             prevSelection.tableKey === tableObserver.tableNodeKey
799           ) {
800             tableObserver.updateTableTableSelection(null);
801           }
802           return false;
803         }
804
805         if (
806           tableObserver.hasHijackedSelectionStyles &&
807           !tableNode.isSelected()
808         ) {
809           $removeHighlightStyleToTable(editor, tableObserver);
810         } else if (
811           !tableObserver.hasHijackedSelectionStyles &&
812           tableNode.isSelected()
813         ) {
814           $addHighlightStyleToTable(editor, tableObserver);
815         }
816
817         return false;
818       },
819       COMMAND_PRIORITY_CRITICAL,
820     ),
821   );
822
823   tableObserver.listenersToRemove.add(
824     editor.registerCommand(
825       INSERT_PARAGRAPH_COMMAND,
826       () => {
827         const selection = $getSelection();
828         if (
829           !$isRangeSelection(selection) ||
830           !selection.isCollapsed() ||
831           !$isSelectionInTable(selection, tableNode)
832         ) {
833           return false;
834         }
835         const edgePosition = $getTableEdgeCursorPosition(
836           editor,
837           selection,
838           tableNode,
839         );
840         if (edgePosition) {
841           $insertParagraphAtTableEdge(edgePosition, tableNode);
842           return true;
843         }
844         return false;
845       },
846       COMMAND_PRIORITY_CRITICAL,
847     ),
848   );
849
850   return tableObserver;
851 }
852
853 export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
854   Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
855
856 export function attachTableObserverToTableElement(
857   tableElement: HTMLTableElementWithWithTableSelectionState,
858   tableObserver: TableObserver,
859 ) {
860   tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
861 }
862
863 export function getTableObserverFromTableElement(
864   tableElement: HTMLTableElementWithWithTableSelectionState,
865 ): TableObserver | null {
866   return tableElement[LEXICAL_ELEMENT_KEY];
867 }
868
869 export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
870   let currentNode: ParentNode | Node | null = node;
871
872   while (currentNode != null) {
873     const nodeName = currentNode.nodeName;
874
875     if (nodeName === 'TD' || nodeName === 'TH') {
876       // @ts-expect-error: internal field
877       const cell = currentNode._cell;
878
879       if (cell === undefined) {
880         return null;
881       }
882
883       return cell;
884     }
885
886     currentNode = currentNode.parentNode;
887   }
888
889   return null;
890 }
891
892 export function doesTargetContainText(node: Node): boolean {
893   const currentNode: ParentNode | Node | null = node;
894
895   if (currentNode !== null) {
896     const nodeName = currentNode.nodeName;
897
898     if (nodeName === 'SPAN') {
899       return true;
900     }
901   }
902   return false;
903 }
904
905 export function getTable(tableElement: HTMLElement): TableDOMTable {
906   const domRows: TableDOMRows = [];
907   const grid = {
908     columns: 0,
909     domRows,
910     rows: 0,
911   };
912   let currentNode = tableElement.firstChild;
913   let x = 0;
914   let y = 0;
915   domRows.length = 0;
916
917   while (currentNode != null) {
918     const nodeMame = currentNode.nodeName;
919
920     if (nodeMame === 'COLGROUP') {
921       currentNode = currentNode.nextSibling;
922       continue;
923     }
924
925     if (nodeMame === 'TD' || nodeMame === 'TH') {
926       const elem = currentNode as HTMLElement;
927       const cell = {
928         elem,
929         hasBackgroundColor: elem.style.backgroundColor !== '',
930         highlighted: false,
931         x,
932         y,
933       };
934
935       // @ts-expect-error: internal field
936       currentNode._cell = cell;
937
938       let row = domRows[y];
939       if (row === undefined) {
940         row = domRows[y] = [];
941       }
942
943       row[x] = cell;
944     } else {
945       const child = currentNode.firstChild;
946
947       if (child != null) {
948         currentNode = child;
949         continue;
950       }
951     }
952
953     const sibling = currentNode.nextSibling;
954
955     if (sibling != null) {
956       x++;
957       currentNode = sibling;
958       continue;
959     }
960
961     const parent = currentNode.parentNode;
962
963     if (parent != null) {
964       const parentSibling = parent.nextSibling;
965
966       if (parentSibling == null) {
967         break;
968       }
969
970       y++;
971       x = 0;
972       currentNode = parentSibling;
973     }
974   }
975
976   grid.columns = x + 1;
977   grid.rows = y + 1;
978
979   return grid;
980 }
981
982 export function $updateDOMForSelection(
983   editor: LexicalEditor,
984   table: TableDOMTable,
985   selection: TableSelection | RangeSelection | null,
986 ) {
987   const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
988   $forEachTableCell(table, (cell, lexicalNode) => {
989     const elem = cell.elem;
990
991     if (selectedCellNodes.has(lexicalNode)) {
992       cell.highlighted = true;
993       $addHighlightToDOM(editor, cell);
994     } else {
995       cell.highlighted = false;
996       $removeHighlightFromDOM(editor, cell);
997       if (!elem.getAttribute('style')) {
998         elem.removeAttribute('style');
999       }
1000     }
1001   });
1002 }
1003
1004 export function $forEachTableCell(
1005   grid: TableDOMTable,
1006   cb: (
1007     cell: TableDOMCell,
1008     lexicalNode: LexicalNode,
1009     cords: {
1010       x: number;
1011       y: number;
1012     },
1013   ) => void,
1014 ) {
1015   const {domRows} = grid;
1016
1017   for (let y = 0; y < domRows.length; y++) {
1018     const row = domRows[y];
1019     if (!row) {
1020       continue;
1021     }
1022
1023     for (let x = 0; x < row.length; x++) {
1024       const cell = row[x];
1025       if (!cell) {
1026         continue;
1027       }
1028       const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1029
1030       if (lexicalNode !== null) {
1031         cb(cell, lexicalNode, {
1032           x,
1033           y,
1034         });
1035       }
1036     }
1037   }
1038 }
1039
1040 export function $addHighlightStyleToTable(
1041   editor: LexicalEditor,
1042   tableSelection: TableObserver,
1043 ) {
1044   tableSelection.disableHighlightStyle();
1045   $forEachTableCell(tableSelection.table, (cell) => {
1046     cell.highlighted = true;
1047     $addHighlightToDOM(editor, cell);
1048   });
1049 }
1050
1051 export function $removeHighlightStyleToTable(
1052   editor: LexicalEditor,
1053   tableObserver: TableObserver,
1054 ) {
1055   tableObserver.enableHighlightStyle();
1056   $forEachTableCell(tableObserver.table, (cell) => {
1057     const elem = cell.elem;
1058     cell.highlighted = false;
1059     $removeHighlightFromDOM(editor, cell);
1060
1061     if (!elem.getAttribute('style')) {
1062       elem.removeAttribute('style');
1063     }
1064   });
1065 }
1066
1067 type Direction = 'backward' | 'forward' | 'up' | 'down';
1068
1069 const selectTableNodeInDirection = (
1070   tableObserver: TableObserver,
1071   tableNode: TableNode,
1072   x: number,
1073   y: number,
1074   direction: Direction,
1075 ): boolean => {
1076   const isForward = direction === 'forward';
1077
1078   switch (direction) {
1079     case 'backward':
1080     case 'forward':
1081       if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1082         selectTableCellNode(
1083           tableNode.getCellNodeFromCordsOrThrow(
1084             x + (isForward ? 1 : -1),
1085             y,
1086             tableObserver.table,
1087           ),
1088           isForward,
1089         );
1090       } else {
1091         if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1092           selectTableCellNode(
1093             tableNode.getCellNodeFromCordsOrThrow(
1094               isForward ? 0 : tableObserver.table.columns - 1,
1095               y + (isForward ? 1 : -1),
1096               tableObserver.table,
1097             ),
1098             isForward,
1099           );
1100         } else if (!isForward) {
1101           tableNode.selectPrevious();
1102         } else {
1103           tableNode.selectNext();
1104         }
1105       }
1106
1107       return true;
1108
1109     case 'up':
1110       if (y !== 0) {
1111         selectTableCellNode(
1112           tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1113           false,
1114         );
1115       } else {
1116         tableNode.selectPrevious();
1117       }
1118
1119       return true;
1120
1121     case 'down':
1122       if (y !== tableObserver.table.rows - 1) {
1123         selectTableCellNode(
1124           tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1125           true,
1126         );
1127       } else {
1128         tableNode.selectNext();
1129       }
1130
1131       return true;
1132     default:
1133       return false;
1134   }
1135 };
1136
1137 const adjustFocusNodeInDirection = (
1138   tableObserver: TableObserver,
1139   tableNode: TableNode,
1140   x: number,
1141   y: number,
1142   direction: Direction,
1143 ): boolean => {
1144   const isForward = direction === 'forward';
1145
1146   switch (direction) {
1147     case 'backward':
1148     case 'forward':
1149       if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1150         tableObserver.setFocusCellForSelection(
1151           tableNode.getDOMCellFromCordsOrThrow(
1152             x + (isForward ? 1 : -1),
1153             y,
1154             tableObserver.table,
1155           ),
1156         );
1157       }
1158
1159       return true;
1160     case 'up':
1161       if (y !== 0) {
1162         tableObserver.setFocusCellForSelection(
1163           tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1164         );
1165
1166         return true;
1167       } else {
1168         return false;
1169       }
1170     case 'down':
1171       if (y !== tableObserver.table.rows - 1) {
1172         tableObserver.setFocusCellForSelection(
1173           tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1174         );
1175
1176         return true;
1177       } else {
1178         return false;
1179       }
1180     default:
1181       return false;
1182   }
1183 };
1184
1185 function $isSelectionInTable(
1186   selection: null | BaseSelection,
1187   tableNode: TableNode,
1188 ): boolean {
1189   if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1190     const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1191     const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1192
1193     return isAnchorInside && isFocusInside;
1194   }
1195
1196   return false;
1197 }
1198
1199 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1200   if (fromStart) {
1201     tableCell.selectStart();
1202   } else {
1203     tableCell.selectEnd();
1204   }
1205 }
1206
1207 const BROWSER_BLUE_RGB = '172,206,247';
1208 function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1209   const element = cell.elem;
1210   const node = $getNearestNodeFromDOMNode(element);
1211   invariant(
1212     $isTableCellNode(node),
1213     'Expected to find LexicalNode from Table Cell DOMNode',
1214   );
1215   const backgroundColor = node.getBackgroundColor();
1216   if (backgroundColor === null) {
1217     element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1218   } else {
1219     element.style.setProperty(
1220       'background-image',
1221       `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1222     );
1223   }
1224   element.style.setProperty('caret-color', 'transparent');
1225 }
1226
1227 function $removeHighlightFromDOM(
1228   editor: LexicalEditor,
1229   cell: TableDOMCell,
1230 ): void {
1231   const element = cell.elem;
1232   const node = $getNearestNodeFromDOMNode(element);
1233   invariant(
1234     $isTableCellNode(node),
1235     'Expected to find LexicalNode from Table Cell DOMNode',
1236   );
1237   const backgroundColor = node.getBackgroundColor();
1238   if (backgroundColor === null) {
1239     element.style.removeProperty('background-color');
1240   }
1241   element.style.removeProperty('background-image');
1242   element.style.removeProperty('caret-color');
1243 }
1244
1245 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1246   const cellNode = $findMatchingParent(node, $isTableCellNode);
1247   return $isTableCellNode(cellNode) ? cellNode : null;
1248 }
1249
1250 export function $findTableNode(node: LexicalNode): null | TableNode {
1251   const tableNode = $findMatchingParent(node, $isTableNode);
1252   return $isTableNode(tableNode) ? tableNode : null;
1253 }
1254
1255 function $handleArrowKey(
1256   editor: LexicalEditor,
1257   event: KeyboardEvent,
1258   direction: Direction,
1259   tableNode: TableNode,
1260   tableObserver: TableObserver,
1261 ): boolean {
1262   if (
1263     (direction === 'up' || direction === 'down') &&
1264     isTypeaheadMenuInView(editor)
1265   ) {
1266     return false;
1267   }
1268
1269   const selection = $getSelection();
1270
1271   if (!$isSelectionInTable(selection, tableNode)) {
1272     if ($isRangeSelection(selection)) {
1273       if (selection.isCollapsed() && direction === 'backward') {
1274         const anchorType = selection.anchor.type;
1275         const anchorOffset = selection.anchor.offset;
1276         if (
1277           anchorType !== 'element' &&
1278           !(anchorType === 'text' && anchorOffset === 0)
1279         ) {
1280           return false;
1281         }
1282         const anchorNode = selection.anchor.getNode();
1283         if (!anchorNode) {
1284           return false;
1285         }
1286         const parentNode = $findMatchingParent(
1287           anchorNode,
1288           (n) => $isElementNode(n) && !n.isInline(),
1289         );
1290         if (!parentNode) {
1291           return false;
1292         }
1293         const siblingNode = parentNode.getPreviousSibling();
1294         if (!siblingNode || !$isTableNode(siblingNode)) {
1295           return false;
1296         }
1297         stopEvent(event);
1298         siblingNode.selectEnd();
1299         return true;
1300       } else if (
1301         event.shiftKey &&
1302         (direction === 'up' || direction === 'down')
1303       ) {
1304         const focusNode = selection.focus.getNode();
1305         if ($isRootOrShadowRoot(focusNode)) {
1306           const selectedNode = selection.getNodes()[0];
1307           if (selectedNode) {
1308             const tableCellNode = $findMatchingParent(
1309               selectedNode,
1310               $isTableCellNode,
1311             );
1312             if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1313               const firstDescendant = tableNode.getFirstDescendant();
1314               const lastDescendant = tableNode.getLastDescendant();
1315               if (!firstDescendant || !lastDescendant) {
1316                 return false;
1317               }
1318               const [firstCellNode] = $getNodeTriplet(firstDescendant);
1319               const [lastCellNode] = $getNodeTriplet(lastDescendant);
1320               const firstCellCoords = tableNode.getCordsFromCellNode(
1321                 firstCellNode,
1322                 tableObserver.table,
1323               );
1324               const lastCellCoords = tableNode.getCordsFromCellNode(
1325                 lastCellNode,
1326                 tableObserver.table,
1327               );
1328               const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1329                 firstCellCoords.x,
1330                 firstCellCoords.y,
1331                 tableObserver.table,
1332               );
1333               const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1334                 lastCellCoords.x,
1335                 lastCellCoords.y,
1336                 tableObserver.table,
1337               );
1338               tableObserver.setAnchorCellForSelection(firstCellDOM);
1339               tableObserver.setFocusCellForSelection(lastCellDOM, true);
1340               return true;
1341             }
1342           }
1343           return false;
1344         } else {
1345           const focusParentNode = $findMatchingParent(
1346             focusNode,
1347             (n) => $isElementNode(n) && !n.isInline(),
1348           );
1349           if (!focusParentNode) {
1350             return false;
1351           }
1352           const sibling =
1353             direction === 'down'
1354               ? focusParentNode.getNextSibling()
1355               : focusParentNode.getPreviousSibling();
1356           if (
1357             $isTableNode(sibling) &&
1358             tableObserver.tableNodeKey === sibling.getKey()
1359           ) {
1360             const firstDescendant = sibling.getFirstDescendant();
1361             const lastDescendant = sibling.getLastDescendant();
1362             if (!firstDescendant || !lastDescendant) {
1363               return false;
1364             }
1365             const [firstCellNode] = $getNodeTriplet(firstDescendant);
1366             const [lastCellNode] = $getNodeTriplet(lastDescendant);
1367             const newSelection = selection.clone();
1368             newSelection.focus.set(
1369               (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
1370               direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
1371               'element',
1372             );
1373             $setSelection(newSelection);
1374             return true;
1375           }
1376         }
1377       }
1378     }
1379     return false;
1380   }
1381
1382   if ($isRangeSelection(selection) && selection.isCollapsed()) {
1383     const {anchor, focus} = selection;
1384     const anchorCellNode = $findMatchingParent(
1385       anchor.getNode(),
1386       $isTableCellNode,
1387     );
1388     const focusCellNode = $findMatchingParent(
1389       focus.getNode(),
1390       $isTableCellNode,
1391     );
1392     if (
1393       !$isTableCellNode(anchorCellNode) ||
1394       !anchorCellNode.is(focusCellNode)
1395     ) {
1396       return false;
1397     }
1398     const anchorCellTable = $findTableNode(anchorCellNode);
1399     if (anchorCellTable !== tableNode && anchorCellTable != null) {
1400       const anchorCellTableElement = editor.getElementByKey(
1401         anchorCellTable.getKey(),
1402       );
1403       if (anchorCellTableElement != null) {
1404         tableObserver.table = getTable(anchorCellTableElement);
1405         return $handleArrowKey(
1406           editor,
1407           event,
1408           direction,
1409           anchorCellTable,
1410           tableObserver,
1411         );
1412       }
1413     }
1414
1415     if (direction === 'backward' || direction === 'forward') {
1416       const anchorType = anchor.type;
1417       const anchorOffset = anchor.offset;
1418       const anchorNode = anchor.getNode();
1419       if (!anchorNode) {
1420         return false;
1421       }
1422
1423       const selectedNodes = selection.getNodes();
1424       if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1425         return false;
1426       }
1427
1428       if (
1429         isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1430       ) {
1431         return $handleTableExit(event, anchorNode, tableNode, direction);
1432       }
1433
1434       return false;
1435     }
1436
1437     const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1438     const anchorDOM = editor.getElementByKey(anchor.key);
1439     if (anchorDOM == null || anchorCellDom == null) {
1440       return false;
1441     }
1442
1443     let edgeSelectionRect;
1444     if (anchor.type === 'element') {
1445       edgeSelectionRect = anchorDOM.getBoundingClientRect();
1446     } else {
1447       const domSelection = window.getSelection();
1448       if (domSelection === null || domSelection.rangeCount === 0) {
1449         return false;
1450       }
1451
1452       const range = domSelection.getRangeAt(0);
1453       edgeSelectionRect = range.getBoundingClientRect();
1454     }
1455
1456     const edgeChild =
1457       direction === 'up'
1458         ? anchorCellNode.getFirstChild()
1459         : anchorCellNode.getLastChild();
1460     if (edgeChild == null) {
1461       return false;
1462     }
1463
1464     const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1465
1466     if (edgeChildDOM == null) {
1467       return false;
1468     }
1469
1470     const edgeRect = edgeChildDOM.getBoundingClientRect();
1471     const isExiting =
1472       direction === 'up'
1473         ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1474         : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1475
1476     if (isExiting) {
1477       stopEvent(event);
1478
1479       const cords = tableNode.getCordsFromCellNode(
1480         anchorCellNode,
1481         tableObserver.table,
1482       );
1483
1484       if (event.shiftKey) {
1485         const cell = tableNode.getDOMCellFromCordsOrThrow(
1486           cords.x,
1487           cords.y,
1488           tableObserver.table,
1489         );
1490         tableObserver.setAnchorCellForSelection(cell);
1491         tableObserver.setFocusCellForSelection(cell, true);
1492       } else {
1493         return selectTableNodeInDirection(
1494           tableObserver,
1495           tableNode,
1496           cords.x,
1497           cords.y,
1498           direction,
1499         );
1500       }
1501
1502       return true;
1503     }
1504   } else if ($isTableSelection(selection)) {
1505     const {anchor, focus} = selection;
1506     const anchorCellNode = $findMatchingParent(
1507       anchor.getNode(),
1508       $isTableCellNode,
1509     );
1510     const focusCellNode = $findMatchingParent(
1511       focus.getNode(),
1512       $isTableCellNode,
1513     );
1514
1515     const [tableNodeFromSelection] = selection.getNodes();
1516     const tableElement = editor.getElementByKey(
1517       tableNodeFromSelection.getKey(),
1518     );
1519     if (
1520       !$isTableCellNode(anchorCellNode) ||
1521       !$isTableCellNode(focusCellNode) ||
1522       !$isTableNode(tableNodeFromSelection) ||
1523       tableElement == null
1524     ) {
1525       return false;
1526     }
1527     tableObserver.updateTableTableSelection(selection);
1528
1529     const grid = getTable(tableElement);
1530     const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1531     const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1532       cordsAnchor.x,
1533       cordsAnchor.y,
1534       grid,
1535     );
1536     tableObserver.setAnchorCellForSelection(anchorCell);
1537
1538     stopEvent(event);
1539
1540     if (event.shiftKey) {
1541       const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1542       return adjustFocusNodeInDirection(
1543         tableObserver,
1544         tableNodeFromSelection,
1545         cords.x,
1546         cords.y,
1547         direction,
1548       );
1549     } else {
1550       focusCellNode.selectEnd();
1551     }
1552
1553     return true;
1554   }
1555
1556   return false;
1557 }
1558
1559 function stopEvent(event: Event) {
1560   event.preventDefault();
1561   event.stopImmediatePropagation();
1562   event.stopPropagation();
1563 }
1564
1565 function isTypeaheadMenuInView(editor: LexicalEditor) {
1566   // There is no inbuilt way to check if the component picker is in view
1567   // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
1568   const root = editor.getRootElement();
1569   if (!root) {
1570     return false;
1571   }
1572   return (
1573     root.hasAttribute('aria-controls') &&
1574     root.getAttribute('aria-controls') === 'typeahead-menu'
1575   );
1576 }
1577
1578 function isExitingTableAnchor(
1579   type: string,
1580   offset: number,
1581   anchorNode: LexicalNode,
1582   direction: 'backward' | 'forward',
1583 ) {
1584   return (
1585     isExitingTableElementAnchor(type, anchorNode, direction) ||
1586     $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1587   );
1588 }
1589
1590 function isExitingTableElementAnchor(
1591   type: string,
1592   anchorNode: LexicalNode,
1593   direction: 'backward' | 'forward',
1594 ) {
1595   return (
1596     type === 'element' &&
1597     (direction === 'backward'
1598       ? anchorNode.getPreviousSibling() === null
1599       : anchorNode.getNextSibling() === null)
1600   );
1601 }
1602
1603 function $isExitingTableTextAnchor(
1604   type: string,
1605   offset: number,
1606   anchorNode: LexicalNode,
1607   direction: 'backward' | 'forward',
1608 ) {
1609   const parentNode = $findMatchingParent(
1610     anchorNode,
1611     (n) => $isElementNode(n) && !n.isInline(),
1612   );
1613   if (!parentNode) {
1614     return false;
1615   }
1616   const hasValidOffset =
1617     direction === 'backward'
1618       ? offset === 0
1619       : offset === anchorNode.getTextContentSize();
1620   return (
1621     type === 'text' &&
1622     hasValidOffset &&
1623     (direction === 'backward'
1624       ? parentNode.getPreviousSibling() === null
1625       : parentNode.getNextSibling() === null)
1626   );
1627 }
1628
1629 function $handleTableExit(
1630   event: KeyboardEvent,
1631   anchorNode: LexicalNode,
1632   tableNode: TableNode,
1633   direction: 'backward' | 'forward',
1634 ) {
1635   const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1636   if (!$isTableCellNode(anchorCellNode)) {
1637     return false;
1638   }
1639   const [tableMap, cellValue] = $computeTableMap(
1640     tableNode,
1641     anchorCellNode,
1642     anchorCellNode,
1643   );
1644   if (!isExitingCell(tableMap, cellValue, direction)) {
1645     return false;
1646   }
1647
1648   const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1649   if (!toNode || $isTableNode(toNode)) {
1650     return false;
1651   }
1652
1653   stopEvent(event);
1654   if (direction === 'backward') {
1655     toNode.selectEnd();
1656   } else {
1657     toNode.selectStart();
1658   }
1659   return true;
1660 }
1661
1662 function isExitingCell(
1663   tableMap: TableMapType,
1664   cellValue: TableMapValueType,
1665   direction: 'backward' | 'forward',
1666 ) {
1667   const firstCell = tableMap[0][0];
1668   const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1669   const {startColumn, startRow} = cellValue;
1670   return direction === 'backward'
1671     ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
1672     : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
1673 }
1674
1675 function $getExitingToNode(
1676   anchorNode: LexicalNode,
1677   direction: 'backward' | 'forward',
1678   tableNode: TableNode,
1679 ) {
1680   const parentNode = $findMatchingParent(
1681     anchorNode,
1682     (n) => $isElementNode(n) && !n.isInline(),
1683   );
1684   if (!parentNode) {
1685     return undefined;
1686   }
1687   const anchorSibling =
1688     direction === 'backward'
1689       ? parentNode.getPreviousSibling()
1690       : parentNode.getNextSibling();
1691   return anchorSibling && $isTableNode(anchorSibling)
1692     ? anchorSibling
1693     : direction === 'backward'
1694     ? tableNode.getPreviousSibling()
1695     : tableNode.getNextSibling();
1696 }
1697
1698 function $insertParagraphAtTableEdge(
1699   edgePosition: 'first' | 'last',
1700   tableNode: TableNode,
1701   children?: LexicalNode[],
1702 ) {
1703   const paragraphNode = $createParagraphNode();
1704   if (edgePosition === 'first') {
1705     tableNode.insertBefore(paragraphNode);
1706   } else {
1707     tableNode.insertAfter(paragraphNode);
1708   }
1709   paragraphNode.append(...(children || []));
1710   paragraphNode.selectEnd();
1711 }
1712
1713 function $getTableEdgeCursorPosition(
1714   editor: LexicalEditor,
1715   selection: RangeSelection,
1716   tableNode: TableNode,
1717 ) {
1718   const tableNodeParent = tableNode.getParent();
1719   if (!tableNodeParent) {
1720     return undefined;
1721   }
1722
1723   const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1724   if (!tableNodeParentDOM) {
1725     return undefined;
1726   }
1727
1728   // TODO: Add support for nested tables
1729   const domSelection = window.getSelection();
1730   if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1731     return undefined;
1732   }
1733
1734   const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1735     $isTableCellNode(n),
1736   ) as TableCellNode | null;
1737   if (!anchorCellNode) {
1738     return undefined;
1739   }
1740
1741   const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1742     $isTableNode(n),
1743   );
1744   if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1745     return undefined;
1746   }
1747
1748   const [tableMap, cellValue] = $computeTableMap(
1749     tableNode,
1750     anchorCellNode,
1751     anchorCellNode,
1752   );
1753   const firstCell = tableMap[0][0];
1754   const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1755   const {startRow, startColumn} = cellValue;
1756
1757   const isAtFirstCell =
1758     startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1759   const isAtLastCell =
1760     startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1761
1762   if (isAtFirstCell) {
1763     return 'first';
1764   } else if (isAtLastCell) {
1765     return 'last';
1766   } else {
1767     return undefined;
1768   }
1769 }