]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
e098a21e498a64988efaa5960c1ab8842e4662a3
[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 === 'TD' || nodeMame === 'TH') {
921       const elem = currentNode as HTMLElement;
922       const cell = {
923         elem,
924         hasBackgroundColor: elem.style.backgroundColor !== '',
925         highlighted: false,
926         x,
927         y,
928       };
929
930       // @ts-expect-error: internal field
931       currentNode._cell = cell;
932
933       let row = domRows[y];
934       if (row === undefined) {
935         row = domRows[y] = [];
936       }
937
938       row[x] = cell;
939     } else {
940       const child = currentNode.firstChild;
941
942       if (child != null) {
943         currentNode = child;
944         continue;
945       }
946     }
947
948     const sibling = currentNode.nextSibling;
949
950     if (sibling != null) {
951       x++;
952       currentNode = sibling;
953       continue;
954     }
955
956     const parent = currentNode.parentNode;
957
958     if (parent != null) {
959       const parentSibling = parent.nextSibling;
960
961       if (parentSibling == null) {
962         break;
963       }
964
965       y++;
966       x = 0;
967       currentNode = parentSibling;
968     }
969   }
970
971   grid.columns = x + 1;
972   grid.rows = y + 1;
973
974   return grid;
975 }
976
977 export function $updateDOMForSelection(
978   editor: LexicalEditor,
979   table: TableDOMTable,
980   selection: TableSelection | RangeSelection | null,
981 ) {
982   const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
983   $forEachTableCell(table, (cell, lexicalNode) => {
984     const elem = cell.elem;
985
986     if (selectedCellNodes.has(lexicalNode)) {
987       cell.highlighted = true;
988       $addHighlightToDOM(editor, cell);
989     } else {
990       cell.highlighted = false;
991       $removeHighlightFromDOM(editor, cell);
992       if (!elem.getAttribute('style')) {
993         elem.removeAttribute('style');
994       }
995     }
996   });
997 }
998
999 export function $forEachTableCell(
1000   grid: TableDOMTable,
1001   cb: (
1002     cell: TableDOMCell,
1003     lexicalNode: LexicalNode,
1004     cords: {
1005       x: number;
1006       y: number;
1007     },
1008   ) => void,
1009 ) {
1010   const {domRows} = grid;
1011
1012   for (let y = 0; y < domRows.length; y++) {
1013     const row = domRows[y];
1014     if (!row) {
1015       continue;
1016     }
1017
1018     for (let x = 0; x < row.length; x++) {
1019       const cell = row[x];
1020       if (!cell) {
1021         continue;
1022       }
1023       const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1024
1025       if (lexicalNode !== null) {
1026         cb(cell, lexicalNode, {
1027           x,
1028           y,
1029         });
1030       }
1031     }
1032   }
1033 }
1034
1035 export function $addHighlightStyleToTable(
1036   editor: LexicalEditor,
1037   tableSelection: TableObserver,
1038 ) {
1039   tableSelection.disableHighlightStyle();
1040   $forEachTableCell(tableSelection.table, (cell) => {
1041     cell.highlighted = true;
1042     $addHighlightToDOM(editor, cell);
1043   });
1044 }
1045
1046 export function $removeHighlightStyleToTable(
1047   editor: LexicalEditor,
1048   tableObserver: TableObserver,
1049 ) {
1050   tableObserver.enableHighlightStyle();
1051   $forEachTableCell(tableObserver.table, (cell) => {
1052     const elem = cell.elem;
1053     cell.highlighted = false;
1054     $removeHighlightFromDOM(editor, cell);
1055
1056     if (!elem.getAttribute('style')) {
1057       elem.removeAttribute('style');
1058     }
1059   });
1060 }
1061
1062 type Direction = 'backward' | 'forward' | 'up' | 'down';
1063
1064 const selectTableNodeInDirection = (
1065   tableObserver: TableObserver,
1066   tableNode: TableNode,
1067   x: number,
1068   y: number,
1069   direction: Direction,
1070 ): boolean => {
1071   const isForward = direction === 'forward';
1072
1073   switch (direction) {
1074     case 'backward':
1075     case 'forward':
1076       if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1077         selectTableCellNode(
1078           tableNode.getCellNodeFromCordsOrThrow(
1079             x + (isForward ? 1 : -1),
1080             y,
1081             tableObserver.table,
1082           ),
1083           isForward,
1084         );
1085       } else {
1086         if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1087           selectTableCellNode(
1088             tableNode.getCellNodeFromCordsOrThrow(
1089               isForward ? 0 : tableObserver.table.columns - 1,
1090               y + (isForward ? 1 : -1),
1091               tableObserver.table,
1092             ),
1093             isForward,
1094           );
1095         } else if (!isForward) {
1096           tableNode.selectPrevious();
1097         } else {
1098           tableNode.selectNext();
1099         }
1100       }
1101
1102       return true;
1103
1104     case 'up':
1105       if (y !== 0) {
1106         selectTableCellNode(
1107           tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1108           false,
1109         );
1110       } else {
1111         tableNode.selectPrevious();
1112       }
1113
1114       return true;
1115
1116     case 'down':
1117       if (y !== tableObserver.table.rows - 1) {
1118         selectTableCellNode(
1119           tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1120           true,
1121         );
1122       } else {
1123         tableNode.selectNext();
1124       }
1125
1126       return true;
1127     default:
1128       return false;
1129   }
1130 };
1131
1132 const adjustFocusNodeInDirection = (
1133   tableObserver: TableObserver,
1134   tableNode: TableNode,
1135   x: number,
1136   y: number,
1137   direction: Direction,
1138 ): boolean => {
1139   const isForward = direction === 'forward';
1140
1141   switch (direction) {
1142     case 'backward':
1143     case 'forward':
1144       if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1145         tableObserver.setFocusCellForSelection(
1146           tableNode.getDOMCellFromCordsOrThrow(
1147             x + (isForward ? 1 : -1),
1148             y,
1149             tableObserver.table,
1150           ),
1151         );
1152       }
1153
1154       return true;
1155     case 'up':
1156       if (y !== 0) {
1157         tableObserver.setFocusCellForSelection(
1158           tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1159         );
1160
1161         return true;
1162       } else {
1163         return false;
1164       }
1165     case 'down':
1166       if (y !== tableObserver.table.rows - 1) {
1167         tableObserver.setFocusCellForSelection(
1168           tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1169         );
1170
1171         return true;
1172       } else {
1173         return false;
1174       }
1175     default:
1176       return false;
1177   }
1178 };
1179
1180 function $isSelectionInTable(
1181   selection: null | BaseSelection,
1182   tableNode: TableNode,
1183 ): boolean {
1184   if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1185     const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1186     const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1187
1188     return isAnchorInside && isFocusInside;
1189   }
1190
1191   return false;
1192 }
1193
1194 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1195   if (fromStart) {
1196     tableCell.selectStart();
1197   } else {
1198     tableCell.selectEnd();
1199   }
1200 }
1201
1202 const BROWSER_BLUE_RGB = '172,206,247';
1203 function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1204   const element = cell.elem;
1205   const node = $getNearestNodeFromDOMNode(element);
1206   invariant(
1207     $isTableCellNode(node),
1208     'Expected to find LexicalNode from Table Cell DOMNode',
1209   );
1210   const backgroundColor = node.getBackgroundColor();
1211   if (backgroundColor === null) {
1212     element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1213   } else {
1214     element.style.setProperty(
1215       'background-image',
1216       `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1217     );
1218   }
1219   element.style.setProperty('caret-color', 'transparent');
1220 }
1221
1222 function $removeHighlightFromDOM(
1223   editor: LexicalEditor,
1224   cell: TableDOMCell,
1225 ): void {
1226   const element = cell.elem;
1227   const node = $getNearestNodeFromDOMNode(element);
1228   invariant(
1229     $isTableCellNode(node),
1230     'Expected to find LexicalNode from Table Cell DOMNode',
1231   );
1232   const backgroundColor = node.getBackgroundColor();
1233   if (backgroundColor === null) {
1234     element.style.removeProperty('background-color');
1235   }
1236   element.style.removeProperty('background-image');
1237   element.style.removeProperty('caret-color');
1238 }
1239
1240 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1241   const cellNode = $findMatchingParent(node, $isTableCellNode);
1242   return $isTableCellNode(cellNode) ? cellNode : null;
1243 }
1244
1245 export function $findTableNode(node: LexicalNode): null | TableNode {
1246   const tableNode = $findMatchingParent(node, $isTableNode);
1247   return $isTableNode(tableNode) ? tableNode : null;
1248 }
1249
1250 function $handleArrowKey(
1251   editor: LexicalEditor,
1252   event: KeyboardEvent,
1253   direction: Direction,
1254   tableNode: TableNode,
1255   tableObserver: TableObserver,
1256 ): boolean {
1257   if (
1258     (direction === 'up' || direction === 'down') &&
1259     isTypeaheadMenuInView(editor)
1260   ) {
1261     return false;
1262   }
1263
1264   const selection = $getSelection();
1265
1266   if (!$isSelectionInTable(selection, tableNode)) {
1267     if ($isRangeSelection(selection)) {
1268       if (selection.isCollapsed() && direction === 'backward') {
1269         const anchorType = selection.anchor.type;
1270         const anchorOffset = selection.anchor.offset;
1271         if (
1272           anchorType !== 'element' &&
1273           !(anchorType === 'text' && anchorOffset === 0)
1274         ) {
1275           return false;
1276         }
1277         const anchorNode = selection.anchor.getNode();
1278         if (!anchorNode) {
1279           return false;
1280         }
1281         const parentNode = $findMatchingParent(
1282           anchorNode,
1283           (n) => $isElementNode(n) && !n.isInline(),
1284         );
1285         if (!parentNode) {
1286           return false;
1287         }
1288         const siblingNode = parentNode.getPreviousSibling();
1289         if (!siblingNode || !$isTableNode(siblingNode)) {
1290           return false;
1291         }
1292         stopEvent(event);
1293         siblingNode.selectEnd();
1294         return true;
1295       } else if (
1296         event.shiftKey &&
1297         (direction === 'up' || direction === 'down')
1298       ) {
1299         const focusNode = selection.focus.getNode();
1300         if ($isRootOrShadowRoot(focusNode)) {
1301           const selectedNode = selection.getNodes()[0];
1302           if (selectedNode) {
1303             const tableCellNode = $findMatchingParent(
1304               selectedNode,
1305               $isTableCellNode,
1306             );
1307             if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1308               const firstDescendant = tableNode.getFirstDescendant();
1309               const lastDescendant = tableNode.getLastDescendant();
1310               if (!firstDescendant || !lastDescendant) {
1311                 return false;
1312               }
1313               const [firstCellNode] = $getNodeTriplet(firstDescendant);
1314               const [lastCellNode] = $getNodeTriplet(lastDescendant);
1315               const firstCellCoords = tableNode.getCordsFromCellNode(
1316                 firstCellNode,
1317                 tableObserver.table,
1318               );
1319               const lastCellCoords = tableNode.getCordsFromCellNode(
1320                 lastCellNode,
1321                 tableObserver.table,
1322               );
1323               const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1324                 firstCellCoords.x,
1325                 firstCellCoords.y,
1326                 tableObserver.table,
1327               );
1328               const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1329                 lastCellCoords.x,
1330                 lastCellCoords.y,
1331                 tableObserver.table,
1332               );
1333               tableObserver.setAnchorCellForSelection(firstCellDOM);
1334               tableObserver.setFocusCellForSelection(lastCellDOM, true);
1335               return true;
1336             }
1337           }
1338           return false;
1339         } else {
1340           const focusParentNode = $findMatchingParent(
1341             focusNode,
1342             (n) => $isElementNode(n) && !n.isInline(),
1343           );
1344           if (!focusParentNode) {
1345             return false;
1346           }
1347           const sibling =
1348             direction === 'down'
1349               ? focusParentNode.getNextSibling()
1350               : focusParentNode.getPreviousSibling();
1351           if (
1352             $isTableNode(sibling) &&
1353             tableObserver.tableNodeKey === sibling.getKey()
1354           ) {
1355             const firstDescendant = sibling.getFirstDescendant();
1356             const lastDescendant = sibling.getLastDescendant();
1357             if (!firstDescendant || !lastDescendant) {
1358               return false;
1359             }
1360             const [firstCellNode] = $getNodeTriplet(firstDescendant);
1361             const [lastCellNode] = $getNodeTriplet(lastDescendant);
1362             const newSelection = selection.clone();
1363             newSelection.focus.set(
1364               (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
1365               direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
1366               'element',
1367             );
1368             $setSelection(newSelection);
1369             return true;
1370           }
1371         }
1372       }
1373     }
1374     return false;
1375   }
1376
1377   if ($isRangeSelection(selection) && selection.isCollapsed()) {
1378     const {anchor, focus} = selection;
1379     const anchorCellNode = $findMatchingParent(
1380       anchor.getNode(),
1381       $isTableCellNode,
1382     );
1383     const focusCellNode = $findMatchingParent(
1384       focus.getNode(),
1385       $isTableCellNode,
1386     );
1387     if (
1388       !$isTableCellNode(anchorCellNode) ||
1389       !anchorCellNode.is(focusCellNode)
1390     ) {
1391       return false;
1392     }
1393     const anchorCellTable = $findTableNode(anchorCellNode);
1394     if (anchorCellTable !== tableNode && anchorCellTable != null) {
1395       const anchorCellTableElement = editor.getElementByKey(
1396         anchorCellTable.getKey(),
1397       );
1398       if (anchorCellTableElement != null) {
1399         tableObserver.table = getTable(anchorCellTableElement);
1400         return $handleArrowKey(
1401           editor,
1402           event,
1403           direction,
1404           anchorCellTable,
1405           tableObserver,
1406         );
1407       }
1408     }
1409
1410     if (direction === 'backward' || direction === 'forward') {
1411       const anchorType = anchor.type;
1412       const anchorOffset = anchor.offset;
1413       const anchorNode = anchor.getNode();
1414       if (!anchorNode) {
1415         return false;
1416       }
1417
1418       const selectedNodes = selection.getNodes();
1419       if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1420         return false;
1421       }
1422
1423       if (
1424         isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1425       ) {
1426         return $handleTableExit(event, anchorNode, tableNode, direction);
1427       }
1428
1429       return false;
1430     }
1431
1432     const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1433     const anchorDOM = editor.getElementByKey(anchor.key);
1434     if (anchorDOM == null || anchorCellDom == null) {
1435       return false;
1436     }
1437
1438     let edgeSelectionRect;
1439     if (anchor.type === 'element') {
1440       edgeSelectionRect = anchorDOM.getBoundingClientRect();
1441     } else {
1442       const domSelection = window.getSelection();
1443       if (domSelection === null || domSelection.rangeCount === 0) {
1444         return false;
1445       }
1446
1447       const range = domSelection.getRangeAt(0);
1448       edgeSelectionRect = range.getBoundingClientRect();
1449     }
1450
1451     const edgeChild =
1452       direction === 'up'
1453         ? anchorCellNode.getFirstChild()
1454         : anchorCellNode.getLastChild();
1455     if (edgeChild == null) {
1456       return false;
1457     }
1458
1459     const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1460
1461     if (edgeChildDOM == null) {
1462       return false;
1463     }
1464
1465     const edgeRect = edgeChildDOM.getBoundingClientRect();
1466     const isExiting =
1467       direction === 'up'
1468         ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1469         : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1470
1471     if (isExiting) {
1472       stopEvent(event);
1473
1474       const cords = tableNode.getCordsFromCellNode(
1475         anchorCellNode,
1476         tableObserver.table,
1477       );
1478
1479       if (event.shiftKey) {
1480         const cell = tableNode.getDOMCellFromCordsOrThrow(
1481           cords.x,
1482           cords.y,
1483           tableObserver.table,
1484         );
1485         tableObserver.setAnchorCellForSelection(cell);
1486         tableObserver.setFocusCellForSelection(cell, true);
1487       } else {
1488         return selectTableNodeInDirection(
1489           tableObserver,
1490           tableNode,
1491           cords.x,
1492           cords.y,
1493           direction,
1494         );
1495       }
1496
1497       return true;
1498     }
1499   } else if ($isTableSelection(selection)) {
1500     const {anchor, focus} = selection;
1501     const anchorCellNode = $findMatchingParent(
1502       anchor.getNode(),
1503       $isTableCellNode,
1504     );
1505     const focusCellNode = $findMatchingParent(
1506       focus.getNode(),
1507       $isTableCellNode,
1508     );
1509
1510     const [tableNodeFromSelection] = selection.getNodes();
1511     const tableElement = editor.getElementByKey(
1512       tableNodeFromSelection.getKey(),
1513     );
1514     if (
1515       !$isTableCellNode(anchorCellNode) ||
1516       !$isTableCellNode(focusCellNode) ||
1517       !$isTableNode(tableNodeFromSelection) ||
1518       tableElement == null
1519     ) {
1520       return false;
1521     }
1522     tableObserver.updateTableTableSelection(selection);
1523
1524     const grid = getTable(tableElement);
1525     const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1526     const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1527       cordsAnchor.x,
1528       cordsAnchor.y,
1529       grid,
1530     );
1531     tableObserver.setAnchorCellForSelection(anchorCell);
1532
1533     stopEvent(event);
1534
1535     if (event.shiftKey) {
1536       const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1537       return adjustFocusNodeInDirection(
1538         tableObserver,
1539         tableNodeFromSelection,
1540         cords.x,
1541         cords.y,
1542         direction,
1543       );
1544     } else {
1545       focusCellNode.selectEnd();
1546     }
1547
1548     return true;
1549   }
1550
1551   return false;
1552 }
1553
1554 function stopEvent(event: Event) {
1555   event.preventDefault();
1556   event.stopImmediatePropagation();
1557   event.stopPropagation();
1558 }
1559
1560 function isTypeaheadMenuInView(editor: LexicalEditor) {
1561   // There is no inbuilt way to check if the component picker is in view
1562   // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
1563   const root = editor.getRootElement();
1564   if (!root) {
1565     return false;
1566   }
1567   return (
1568     root.hasAttribute('aria-controls') &&
1569     root.getAttribute('aria-controls') === 'typeahead-menu'
1570   );
1571 }
1572
1573 function isExitingTableAnchor(
1574   type: string,
1575   offset: number,
1576   anchorNode: LexicalNode,
1577   direction: 'backward' | 'forward',
1578 ) {
1579   return (
1580     isExitingTableElementAnchor(type, anchorNode, direction) ||
1581     $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1582   );
1583 }
1584
1585 function isExitingTableElementAnchor(
1586   type: string,
1587   anchorNode: LexicalNode,
1588   direction: 'backward' | 'forward',
1589 ) {
1590   return (
1591     type === 'element' &&
1592     (direction === 'backward'
1593       ? anchorNode.getPreviousSibling() === null
1594       : anchorNode.getNextSibling() === null)
1595   );
1596 }
1597
1598 function $isExitingTableTextAnchor(
1599   type: string,
1600   offset: number,
1601   anchorNode: LexicalNode,
1602   direction: 'backward' | 'forward',
1603 ) {
1604   const parentNode = $findMatchingParent(
1605     anchorNode,
1606     (n) => $isElementNode(n) && !n.isInline(),
1607   );
1608   if (!parentNode) {
1609     return false;
1610   }
1611   const hasValidOffset =
1612     direction === 'backward'
1613       ? offset === 0
1614       : offset === anchorNode.getTextContentSize();
1615   return (
1616     type === 'text' &&
1617     hasValidOffset &&
1618     (direction === 'backward'
1619       ? parentNode.getPreviousSibling() === null
1620       : parentNode.getNextSibling() === null)
1621   );
1622 }
1623
1624 function $handleTableExit(
1625   event: KeyboardEvent,
1626   anchorNode: LexicalNode,
1627   tableNode: TableNode,
1628   direction: 'backward' | 'forward',
1629 ) {
1630   const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1631   if (!$isTableCellNode(anchorCellNode)) {
1632     return false;
1633   }
1634   const [tableMap, cellValue] = $computeTableMap(
1635     tableNode,
1636     anchorCellNode,
1637     anchorCellNode,
1638   );
1639   if (!isExitingCell(tableMap, cellValue, direction)) {
1640     return false;
1641   }
1642
1643   const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1644   if (!toNode || $isTableNode(toNode)) {
1645     return false;
1646   }
1647
1648   stopEvent(event);
1649   if (direction === 'backward') {
1650     toNode.selectEnd();
1651   } else {
1652     toNode.selectStart();
1653   }
1654   return true;
1655 }
1656
1657 function isExitingCell(
1658   tableMap: TableMapType,
1659   cellValue: TableMapValueType,
1660   direction: 'backward' | 'forward',
1661 ) {
1662   const firstCell = tableMap[0][0];
1663   const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1664   const {startColumn, startRow} = cellValue;
1665   return direction === 'backward'
1666     ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
1667     : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
1668 }
1669
1670 function $getExitingToNode(
1671   anchorNode: LexicalNode,
1672   direction: 'backward' | 'forward',
1673   tableNode: TableNode,
1674 ) {
1675   const parentNode = $findMatchingParent(
1676     anchorNode,
1677     (n) => $isElementNode(n) && !n.isInline(),
1678   );
1679   if (!parentNode) {
1680     return undefined;
1681   }
1682   const anchorSibling =
1683     direction === 'backward'
1684       ? parentNode.getPreviousSibling()
1685       : parentNode.getNextSibling();
1686   return anchorSibling && $isTableNode(anchorSibling)
1687     ? anchorSibling
1688     : direction === 'backward'
1689     ? tableNode.getPreviousSibling()
1690     : tableNode.getNextSibling();
1691 }
1692
1693 function $insertParagraphAtTableEdge(
1694   edgePosition: 'first' | 'last',
1695   tableNode: TableNode,
1696   children?: LexicalNode[],
1697 ) {
1698   const paragraphNode = $createParagraphNode();
1699   if (edgePosition === 'first') {
1700     tableNode.insertBefore(paragraphNode);
1701   } else {
1702     tableNode.insertAfter(paragraphNode);
1703   }
1704   paragraphNode.append(...(children || []));
1705   paragraphNode.selectEnd();
1706 }
1707
1708 function $getTableEdgeCursorPosition(
1709   editor: LexicalEditor,
1710   selection: RangeSelection,
1711   tableNode: TableNode,
1712 ) {
1713   const tableNodeParent = tableNode.getParent();
1714   if (!tableNodeParent) {
1715     return undefined;
1716   }
1717
1718   const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1719   if (!tableNodeParentDOM) {
1720     return undefined;
1721   }
1722
1723   // TODO: Add support for nested tables
1724   const domSelection = window.getSelection();
1725   if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1726     return undefined;
1727   }
1728
1729   const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1730     $isTableCellNode(n),
1731   ) as TableCellNode | null;
1732   if (!anchorCellNode) {
1733     return undefined;
1734   }
1735
1736   const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1737     $isTableNode(n),
1738   );
1739   if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1740     return undefined;
1741   }
1742
1743   const [tableMap, cellValue] = $computeTableMap(
1744     tableNode,
1745     anchorCellNode,
1746     anchorCellNode,
1747   );
1748   const firstCell = tableMap[0][0];
1749   const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1750   const {startRow, startColumn} = cellValue;
1751
1752   const isAtFirstCell =
1753     startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1754   const isAtLastCell =
1755     startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1756
1757   if (isAtFirstCell) {
1758     return 'first';
1759   } else if (isAtLastCell) {
1760     return 'last';
1761   } else {
1762     return undefined;
1763   }
1764 }