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