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