2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {TableCellNode} from './LexicalTableCellNode';
10 import type {TableNode} from './LexicalTableNode';
11 import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver';
16 } from './LexicalTableSelection';
27 $getClipboardDataFromSelection,
29 } from '@lexical/clipboard';
30 import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
33 $createRangeSelectionFromDom,
35 $getNearestNodeFromDOMNode,
36 $getPreviousSelection,
44 COMMAND_PRIORITY_CRITICAL,
45 COMMAND_PRIORITY_HIGH,
46 CONTROLLED_TEXT_INSERTION_COMMAND,
48 DELETE_CHARACTER_COMMAND,
53 INSERT_PARAGRAPH_COMMAND,
54 KEY_ARROW_DOWN_COMMAND,
55 KEY_ARROW_LEFT_COMMAND,
56 KEY_ARROW_RIGHT_COMMAND,
58 KEY_BACKSPACE_COMMAND,
62 SELECTION_CHANGE_COMMAND,
63 SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
65 import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
66 import invariant from 'lexical/shared/invariant';
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";
76 const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
78 export const getDOMSelection = (
79 targetWindow: Window | null,
80 ): Selection | null =>
81 CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
83 const isMouseDownOnEvent = (event: MouseEvent) => {
84 return (event.buttons & 1) === 1;
87 export function applyTableHandlers(
89 tableElement: HTMLTableElementWithWithTableSelectionState,
90 editor: LexicalEditor,
91 hasTabHandler: boolean,
93 const rootElement = editor.getRootElement();
95 if (rootElement === null) {
96 throw new Error('No root element.');
99 const tableObserver = new TableObserver(editor, tableNode.getKey());
100 const editorWindow = editor._window || window;
102 attachTableObserverToTableElement(tableElement, tableObserver);
104 const createMouseHandlers = () => {
105 const onMouseUp = () => {
106 tableObserver.isSelecting = false;
107 editorWindow.removeEventListener('mouseup', onMouseUp);
108 editorWindow.removeEventListener('mousemove', onMouseMove);
111 const onMouseMove = (moveEvent: MouseEvent) => {
112 // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
114 if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
115 tableObserver.isSelecting = false;
116 editorWindow.removeEventListener('mouseup', onMouseUp);
117 editorWindow.removeEventListener('mousemove', onMouseMove);
120 const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
122 focusCell !== null &&
123 (tableObserver.anchorX !== focusCell.x ||
124 tableObserver.anchorY !== focusCell.y)
126 moveEvent.preventDefault();
127 tableObserver.setFocusCellForSelection(focusCell);
131 return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
134 tableElement.addEventListener('mousedown', (event: MouseEvent) => {
136 if (event.button !== 0) {
144 const anchorCell = getDOMCellFromTarget(event.target as Node);
145 if (anchorCell !== null) {
147 tableObserver.setAnchorCellForSelection(anchorCell);
150 const {onMouseUp, onMouseMove} = createMouseHandlers();
151 tableObserver.isSelecting = true;
152 editorWindow.addEventListener('mouseup', onMouseUp);
153 editorWindow.addEventListener('mousemove', onMouseMove);
157 // Clear selection when clicking outside of dom.
158 const mouseDownCallback = (event: MouseEvent) => {
159 if (event.button !== 0) {
163 editor.update(() => {
164 const selection = $getSelection();
165 const target = event.target as Node;
167 $isTableSelection(selection) &&
168 selection.tableKey === tableObserver.tableNodeKey &&
169 rootElement.contains(target)
171 tableObserver.clearHighlight();
176 editorWindow.addEventListener('mousedown', mouseDownCallback);
178 tableObserver.listenersToRemove.add(() =>
179 editorWindow.removeEventListener('mousedown', mouseDownCallback),
182 tableObserver.listenersToRemove.add(
183 editor.registerCommand<KeyboardEvent>(
184 KEY_ARROW_DOWN_COMMAND,
186 $handleArrowKey(editor, event, 'down', tableNode, tableObserver),
187 COMMAND_PRIORITY_HIGH,
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,
199 tableObserver.listenersToRemove.add(
200 editor.registerCommand<KeyboardEvent>(
201 KEY_ARROW_LEFT_COMMAND,
203 $handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
204 COMMAND_PRIORITY_HIGH,
208 tableObserver.listenersToRemove.add(
209 editor.registerCommand<KeyboardEvent>(
210 KEY_ARROW_RIGHT_COMMAND,
212 $handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
213 COMMAND_PRIORITY_HIGH,
217 tableObserver.listenersToRemove.add(
218 editor.registerCommand<KeyboardEvent>(
221 const selection = $getSelection();
222 if ($isTableSelection(selection)) {
223 const focusCellNode = $findMatchingParent(
224 selection.focus.getNode(),
227 if ($isTableCellNode(focusCellNode)) {
229 focusCellNode.selectEnd();
236 COMMAND_PRIORITY_HIGH,
240 const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
241 const selection = $getSelection();
243 if (!$isSelectionInTable(selection, tableNode)) {
247 if ($isTableSelection(selection)) {
248 tableObserver.clearText();
251 } else if ($isRangeSelection(selection)) {
252 const tableCellNode = $findMatchingParent(
253 selection.anchor.getNode(),
254 (n) => $isTableCellNode(n),
257 if (!$isTableCellNode(tableCellNode)) {
261 const anchorNode = selection.anchor.getNode();
262 const focusNode = selection.focus.getNode();
263 const isAnchorInside = tableNode.isParentOf(anchorNode);
264 const isFocusInside = tableNode.isParentOf(focusNode);
266 const selectionContainsPartialTable =
267 (isAnchorInside && !isFocusInside) ||
268 (isFocusInside && !isAnchorInside);
270 if (selectionContainsPartialTable) {
271 tableObserver.clearText();
275 const nearestElementNode = $findMatchingParent(
276 selection.anchor.getNode(),
277 (n) => $isElementNode(n),
280 const topLevelCellElementNode =
281 nearestElementNode &&
284 (n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
288 !$isElementNode(topLevelCellElementNode) ||
289 !$isElementNode(nearestElementNode)
295 command === DELETE_LINE_COMMAND &&
296 topLevelCellElementNode.getPreviousSibling() === null
298 // TODO: Fix Delete Line in Table Cells.
306 [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
308 tableObserver.listenersToRemove.add(
309 editor.registerCommand(
311 deleteTextHandler(command),
312 COMMAND_PRIORITY_CRITICAL,
318 const $deleteCellHandler = (
319 event: KeyboardEvent | ClipboardEvent | null,
321 const selection = $getSelection();
323 if (!$isSelectionInTable(selection, tableNode)) {
324 const nodes = selection ? selection.getNodes() : null;
326 const table = nodes.find(
328 $isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
330 if ($isTableNode(table)) {
331 const parentNode = table.getParent();
341 if ($isTableSelection(selection)) {
343 event.preventDefault();
344 event.stopPropagation();
346 tableObserver.clearText();
349 } else if ($isRangeSelection(selection)) {
350 const tableCellNode = $findMatchingParent(
351 selection.anchor.getNode(),
352 (n) => $isTableCellNode(n),
355 if (!$isTableCellNode(tableCellNode)) {
363 tableObserver.listenersToRemove.add(
364 editor.registerCommand<KeyboardEvent>(
365 KEY_BACKSPACE_COMMAND,
367 COMMAND_PRIORITY_CRITICAL,
371 tableObserver.listenersToRemove.add(
372 editor.registerCommand<KeyboardEvent>(
375 COMMAND_PRIORITY_CRITICAL,
379 tableObserver.listenersToRemove.add(
380 editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
383 const selection = $getSelection();
385 if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
388 // Copying to the clipboard is async so we must capture the data
389 // before we delete it
390 void copyToClipboard(
392 objectKlassEquals(event, ClipboardEvent)
393 ? (event as ClipboardEvent)
395 $getClipboardDataFromSelection(selection),
397 const intercepted = $deleteCellHandler(event);
398 if ($isRangeSelection(selection)) {
399 selection.removeText();
405 COMMAND_PRIORITY_CRITICAL,
409 tableObserver.listenersToRemove.add(
410 editor.registerCommand<TextFormatType>(
413 const selection = $getSelection();
415 if (!$isSelectionInTable(selection, tableNode)) {
419 if ($isTableSelection(selection)) {
420 tableObserver.formatCells(payload);
423 } else if ($isRangeSelection(selection)) {
424 const tableCellNode = $findMatchingParent(
425 selection.anchor.getNode(),
426 (n) => $isTableCellNode(n),
429 if (!$isTableCellNode(tableCellNode)) {
436 COMMAND_PRIORITY_CRITICAL,
440 tableObserver.listenersToRemove.add(
441 editor.registerCommand(
442 CONTROLLED_TEXT_INSERTION_COMMAND,
444 const selection = $getSelection();
446 if (!$isSelectionInTable(selection, tableNode)) {
450 if ($isTableSelection(selection)) {
451 tableObserver.clearHighlight();
454 } else if ($isRangeSelection(selection)) {
455 const tableCellNode = $findMatchingParent(
456 selection.anchor.getNode(),
457 (n) => $isTableCellNode(n),
460 if (!$isTableCellNode(tableCellNode)) {
464 if (typeof payload === 'string') {
465 const edgePosition = $getTableEdgeCursorPosition(
471 $insertParagraphAtTableEdge(edgePosition, tableNode, [
472 $createTextNode(payload),
481 COMMAND_PRIORITY_CRITICAL,
486 tableObserver.listenersToRemove.add(
487 editor.registerCommand<KeyboardEvent>(
490 const selection = $getSelection();
492 !$isRangeSelection(selection) ||
493 !selection.isCollapsed() ||
494 !$isSelectionInTable(selection, tableNode)
499 const tableCellNode = $findCellNode(selection.anchor.getNode());
500 if (tableCellNode === null) {
506 const currentCords = tableNode.getCordsFromCellNode(
511 selectTableNodeInDirection(
516 !event.shiftKey ? 'forward' : 'backward',
521 COMMAND_PRIORITY_CRITICAL,
526 tableObserver.listenersToRemove.add(
527 editor.registerCommand(
530 return tableNode.isSelected();
532 COMMAND_PRIORITY_HIGH,
536 function getObserverCellFromCellNode(
537 tableCellNode: TableCellNode,
539 const currentCords = tableNode.getCordsFromCellNode(
543 return tableNode.getDOMCellFromCordsOrThrow(
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 =
560 $findMatchingParent(selection.anchor.getNode(), (n) =>
563 $findMatchingParent(selection.focus.getNode(), (n) =>
569 nodes.length !== 1 ||
570 !$isTableNode(nodes[0]) ||
571 !isSelectionInsideOfGrid ||
572 anchorAndFocus === null
576 const [anchor] = anchorAndFocus;
578 const newGrid = nodes[0];
579 const newGridRows = newGrid.getChildren();
580 const newColumnCount = newGrid
581 .getFirstChildOrThrow<TableNode>()
583 const newRowCount = newGrid.getChildrenSize();
584 const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
589 $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
592 $findMatchingParent(gridRowNode, (n) => $isTableNode(n));
595 !$isTableCellNode(gridCellNode) ||
596 !$isTableRowNode(gridRowNode) ||
597 !$isTableNode(gridNode)
602 const startY = gridRowNode.getIndexWithinParent();
603 const stopY = Math.min(
604 gridNode.getChildrenSize() - 1,
605 startY + newRowCount - 1,
607 const startX = gridCellNode.getIndexWithinParent();
608 const stopX = Math.min(
609 gridRowNode.getChildrenSize() - 1,
610 startX + newColumnCount - 1,
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();
619 for (let r = fromY; r <= toY; r++) {
620 const currentGridRowNode = gridRowNodes[r];
622 if (!$isTableRowNode(currentGridRowNode)) {
626 const newGridRowNode = newGridRows[newRowIdx];
628 if (!$isTableRowNode(newGridRowNode)) {
632 const gridCellNodes = currentGridRowNode.getChildren();
633 const newGridCellNodes = newGridRowNode.getChildren();
634 let newColumnIdx = 0;
636 for (let c = fromX; c <= toX; c++) {
637 const currentGridCellNode = gridCellNodes[c];
639 if (!$isTableCellNode(currentGridCellNode)) {
643 const newGridCellNode = newGridCellNodes[newColumnIdx];
645 if (!$isTableCellNode(newGridCellNode)) {
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);
656 currentGridCellNode.append(child);
659 originalChildren.forEach((n) => n.remove());
667 COMMAND_PRIORITY_CRITICAL,
671 tableObserver.listenersToRemove.add(
672 editor.registerCommand(
673 SELECTION_CHANGE_COMMAND,
675 const selection = $getSelection();
676 const prevSelection = $getPreviousSelection();
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))
689 const isFocusInside = !!(
690 focusCellNode && tableNode.is($findTableNode(focusCellNode))
692 const isPartialyWithinTable = isAnchorInside !== isFocusInside;
693 const isWithinTable = isAnchorInside && isFocusInside;
694 const isBackward = selection.isBackward();
696 if (isPartialyWithinTable) {
697 const newSelection = selection.clone();
699 const [tableMap] = $computeTableMap(
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(),
709 ? firstCell.getChildrenSize()
710 : lastCell.getChildrenSize(),
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),
723 tableObserver.setFocusCellForSelection(
724 getObserverCellFromCellNode(focusCellNode),
727 if (!tableObserver.isSelecting) {
729 const {onMouseUp, onMouseMove} = createMouseHandlers();
730 tableObserver.isSelecting = true;
731 editorWindow.addEventListener('mouseup', onMouseUp);
732 editorWindow.addEventListener('mousemove', onMouseMove);
739 $isTableSelection(selection) &&
740 selection.is(prevSelection) &&
741 selection.tableKey === tableNode.getKey()
743 // if selection goes outside of the table we need to change it to Range selection
744 const domSelection = getDOMSelection(editor._window);
747 domSelection.anchorNode &&
748 domSelection.focusNode
750 const focusNode = $getNearestNodeFromDOMNode(
751 domSelection.focusNode,
753 const isFocusOutside =
754 focusNode && !tableNode.is($findTableNode(focusNode));
756 const anchorNode = $getNearestNodeFromDOMNode(
757 domSelection.anchorNode,
759 const isAnchorInside =
760 anchorNode && tableNode.is($findTableNode(anchorNode));
765 domSelection.rangeCount > 0
767 const newSelection = $createRangeSelectionFromDom(
772 newSelection.anchor.set(
774 selection.isBackward() ? tableNode.getChildrenSize() : 0,
777 domSelection.removeAllRanges();
778 $setSelection(newSelection);
786 !selection.is(prevSelection) &&
787 ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
788 tableObserver.tableSelection &&
789 !tableObserver.tableSelection.is(prevSelection)
792 $isTableSelection(selection) &&
793 selection.tableKey === tableObserver.tableNodeKey
795 tableObserver.updateTableTableSelection(selection);
797 !$isTableSelection(selection) &&
798 $isTableSelection(prevSelection) &&
799 prevSelection.tableKey === tableObserver.tableNodeKey
801 tableObserver.updateTableTableSelection(null);
807 tableObserver.hasHijackedSelectionStyles &&
808 !tableNode.isSelected()
810 $removeHighlightStyleToTable(editor, tableObserver);
812 !tableObserver.hasHijackedSelectionStyles &&
813 tableNode.isSelected()
815 $addHighlightStyleToTable(editor, tableObserver);
820 COMMAND_PRIORITY_CRITICAL,
824 tableObserver.listenersToRemove.add(
825 editor.registerCommand(
826 INSERT_PARAGRAPH_COMMAND,
828 const selection = $getSelection();
830 !$isRangeSelection(selection) ||
831 !selection.isCollapsed() ||
832 !$isSelectionInTable(selection, tableNode)
836 const edgePosition = $getTableEdgeCursorPosition(
842 $insertParagraphAtTableEdge(edgePosition, tableNode);
847 COMMAND_PRIORITY_CRITICAL,
851 return tableObserver;
854 export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
855 Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
857 export function attachTableObserverToTableElement(
858 tableElement: HTMLTableElementWithWithTableSelectionState,
859 tableObserver: TableObserver,
861 tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
864 export function getTableObserverFromTableElement(
865 tableElement: HTMLTableElementWithWithTableSelectionState,
866 ): TableObserver | null {
867 return tableElement[LEXICAL_ELEMENT_KEY];
870 export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
871 let currentNode: ParentNode | Node | null = node;
873 while (currentNode != null) {
874 const nodeName = currentNode.nodeName;
876 if (nodeName === 'TD' || nodeName === 'TH') {
877 // @ts-expect-error: internal field
878 const cell = currentNode._cell;
880 if (cell === undefined) {
887 currentNode = currentNode.parentNode;
893 export function doesTargetContainText(node: Node): boolean {
894 const currentNode: ParentNode | Node | null = node;
896 if (currentNode !== null) {
897 const nodeName = currentNode.nodeName;
899 if (nodeName === 'SPAN') {
906 export function getTable(tableElement: HTMLElement): TableDOMTable {
907 const domRows: TableDOMRows = [];
913 let currentNode = tableElement.firstChild;
918 while (currentNode != null) {
919 const nodeMame = currentNode.nodeName;
921 if (nodeMame === 'COLGROUP') {
922 currentNode = currentNode.nextSibling;
926 if (nodeMame === 'TD' || nodeMame === 'TH') {
927 const elem = currentNode as HTMLElement;
930 hasBackgroundColor: elem.style.backgroundColor !== '',
936 // @ts-expect-error: internal field
937 currentNode._cell = cell;
939 let row = domRows[y];
940 if (row === undefined) {
941 row = domRows[y] = [];
946 const child = currentNode.firstChild;
954 const sibling = currentNode.nextSibling;
956 if (sibling != null) {
958 currentNode = sibling;
962 const parent = currentNode.parentNode;
964 if (parent != null) {
965 const parentSibling = parent.nextSibling;
967 if (parentSibling == null) {
973 currentNode = parentSibling;
977 grid.columns = x + 1;
983 export function $updateDOMForSelection(
984 editor: LexicalEditor,
985 table: TableDOMTable,
986 selection: TableSelection | RangeSelection | null,
988 const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
989 $forEachTableCell(table, (cell, lexicalNode) => {
990 const elem = cell.elem;
992 if (selectedCellNodes.has(lexicalNode)) {
993 cell.highlighted = true;
994 $addHighlightToDOM(editor, cell);
996 cell.highlighted = false;
997 $removeHighlightFromDOM(editor, cell);
998 if (!elem.getAttribute('style')) {
999 elem.removeAttribute('style');
1005 export function $forEachTableCell(
1006 grid: TableDOMTable,
1009 lexicalNode: LexicalNode,
1016 const {domRows} = grid;
1018 for (let y = 0; y < domRows.length; y++) {
1019 const row = domRows[y];
1024 for (let x = 0; x < row.length; x++) {
1025 const cell = row[x];
1029 const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1031 if (lexicalNode !== null) {
1032 cb(cell, lexicalNode, {
1041 export function $addHighlightStyleToTable(
1042 editor: LexicalEditor,
1043 tableSelection: TableObserver,
1045 tableSelection.disableHighlightStyle();
1046 $forEachTableCell(tableSelection.table, (cell) => {
1047 cell.highlighted = true;
1048 $addHighlightToDOM(editor, cell);
1052 export function $removeHighlightStyleToTable(
1053 editor: LexicalEditor,
1054 tableObserver: TableObserver,
1056 tableObserver.enableHighlightStyle();
1057 $forEachTableCell(tableObserver.table, (cell) => {
1058 const elem = cell.elem;
1059 cell.highlighted = false;
1060 $removeHighlightFromDOM(editor, cell);
1062 if (!elem.getAttribute('style')) {
1063 elem.removeAttribute('style');
1068 type Direction = 'backward' | 'forward' | 'up' | 'down';
1070 const selectTableNodeInDirection = (
1071 tableObserver: TableObserver,
1072 tableNode: TableNode,
1075 direction: Direction,
1077 const isForward = direction === 'forward';
1079 switch (direction) {
1082 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1083 selectTableCellNode(
1084 tableNode.getCellNodeFromCordsOrThrow(
1085 x + (isForward ? 1 : -1),
1087 tableObserver.table,
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,
1101 } else if (!isForward) {
1102 tableNode.selectPrevious();
1104 tableNode.selectNext();
1112 selectTableCellNode(
1113 tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1117 $selectOrCreateAdjacent(tableNode, false);
1123 if (y !== tableObserver.table.rows - 1) {
1124 selectTableCellNode(
1125 tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1129 $selectOrCreateAdjacent(tableNode, true);
1138 const adjustFocusNodeInDirection = (
1139 tableObserver: TableObserver,
1140 tableNode: TableNode,
1143 direction: Direction,
1145 const isForward = direction === 'forward';
1147 switch (direction) {
1150 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1151 tableObserver.setFocusCellForSelection(
1152 tableNode.getDOMCellFromCordsOrThrow(
1153 x + (isForward ? 1 : -1),
1155 tableObserver.table,
1163 tableObserver.setFocusCellForSelection(
1164 tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1172 if (y !== tableObserver.table.rows - 1) {
1173 tableObserver.setFocusCellForSelection(
1174 tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1186 function $isSelectionInTable(
1187 selection: null | BaseSelection,
1188 tableNode: TableNode,
1190 if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1191 const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1192 const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1194 return isAnchorInside && isFocusInside;
1200 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1202 tableCell.selectStart();
1204 tableCell.selectEnd();
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);
1213 $isTableCellNode(node),
1214 'Expected to find LexicalNode from Table Cell DOMNode',
1216 const backgroundColor = node.getBackgroundColor();
1217 if (backgroundColor === null) {
1218 element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1220 element.style.setProperty(
1222 `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1225 element.style.setProperty('caret-color', 'transparent');
1228 function $removeHighlightFromDOM(
1229 editor: LexicalEditor,
1232 const element = cell.elem;
1233 const node = $getNearestNodeFromDOMNode(element);
1235 $isTableCellNode(node),
1236 'Expected to find LexicalNode from Table Cell DOMNode',
1238 const backgroundColor = node.getBackgroundColor();
1239 if (backgroundColor === null) {
1240 element.style.removeProperty('background-color');
1242 element.style.removeProperty('background-image');
1243 element.style.removeProperty('caret-color');
1246 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1247 const cellNode = $findMatchingParent(node, $isTableCellNode);
1248 return $isTableCellNode(cellNode) ? cellNode : null;
1251 export function $findTableNode(node: LexicalNode): null | TableNode {
1252 const tableNode = $findMatchingParent(node, $isTableNode);
1253 return $isTableNode(tableNode) ? tableNode : null;
1256 function $handleArrowKey(
1257 editor: LexicalEditor,
1258 event: KeyboardEvent,
1259 direction: Direction,
1260 tableNode: TableNode,
1261 tableObserver: TableObserver,
1264 (direction === 'up' || direction === 'down') &&
1265 isTypeaheadMenuInView(editor)
1270 const selection = $getSelection();
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;
1278 anchorType !== 'element' &&
1279 !(anchorType === 'text' && anchorOffset === 0)
1283 const anchorNode = selection.anchor.getNode();
1287 const parentNode = $findMatchingParent(
1289 (n) => $isElementNode(n) && !n.isInline(),
1294 const siblingNode = parentNode.getPreviousSibling();
1295 if (!siblingNode || !$isTableNode(siblingNode)) {
1299 siblingNode.selectEnd();
1303 (direction === 'up' || direction === 'down')
1305 const focusNode = selection.focus.getNode();
1306 if ($isRootOrShadowRoot(focusNode)) {
1307 const selectedNode = selection.getNodes()[0];
1309 const tableCellNode = $findMatchingParent(
1313 if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1314 const firstDescendant = tableNode.getFirstDescendant();
1315 const lastDescendant = tableNode.getLastDescendant();
1316 if (!firstDescendant || !lastDescendant) {
1319 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1320 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1321 const firstCellCoords = tableNode.getCordsFromCellNode(
1323 tableObserver.table,
1325 const lastCellCoords = tableNode.getCordsFromCellNode(
1327 tableObserver.table,
1329 const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1332 tableObserver.table,
1334 const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1337 tableObserver.table,
1339 tableObserver.setAnchorCellForSelection(firstCellDOM);
1340 tableObserver.setFocusCellForSelection(lastCellDOM, true);
1346 const focusParentNode = $findMatchingParent(
1348 (n) => $isElementNode(n) && !n.isInline(),
1350 if (!focusParentNode) {
1354 direction === 'down'
1355 ? focusParentNode.getNextSibling()
1356 : focusParentNode.getPreviousSibling();
1358 $isTableNode(sibling) &&
1359 tableObserver.tableNodeKey === sibling.getKey()
1361 const firstDescendant = sibling.getFirstDescendant();
1362 const lastDescendant = sibling.getLastDescendant();
1363 if (!firstDescendant || !lastDescendant) {
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(),
1374 $setSelection(newSelection);
1383 if ($isRangeSelection(selection) && selection.isCollapsed()) {
1384 const {anchor, focus} = selection;
1385 const anchorCellNode = $findMatchingParent(
1389 const focusCellNode = $findMatchingParent(
1394 !$isTableCellNode(anchorCellNode) ||
1395 !anchorCellNode.is(focusCellNode)
1399 const anchorCellTable = $findTableNode(anchorCellNode);
1400 if (anchorCellTable !== tableNode && anchorCellTable != null) {
1401 const anchorCellTableElement = editor.getElementByKey(
1402 anchorCellTable.getKey(),
1404 if (anchorCellTableElement != null) {
1405 tableObserver.table = getTable(anchorCellTableElement);
1406 return $handleArrowKey(
1416 if (direction === 'backward' || direction === 'forward') {
1417 const anchorType = anchor.type;
1418 const anchorOffset = anchor.offset;
1419 const anchorNode = anchor.getNode();
1424 const selectedNodes = selection.getNodes();
1425 if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1430 isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1432 return $handleTableExit(event, anchorNode, tableNode, direction);
1438 const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1439 const anchorDOM = editor.getElementByKey(anchor.key);
1440 if (anchorDOM == null || anchorCellDom == null) {
1444 let edgeSelectionRect;
1445 if (anchor.type === 'element') {
1446 edgeSelectionRect = anchorDOM.getBoundingClientRect();
1448 const domSelection = window.getSelection();
1449 if (domSelection === null || domSelection.rangeCount === 0) {
1453 const range = domSelection.getRangeAt(0);
1454 edgeSelectionRect = range.getBoundingClientRect();
1459 ? anchorCellNode.getFirstChild()
1460 : anchorCellNode.getLastChild();
1461 if (edgeChild == null) {
1465 const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1467 if (edgeChildDOM == null) {
1471 const edgeRect = edgeChildDOM.getBoundingClientRect();
1474 ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1475 : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1480 const cords = tableNode.getCordsFromCellNode(
1482 tableObserver.table,
1485 if (event.shiftKey) {
1486 const cell = tableNode.getDOMCellFromCordsOrThrow(
1489 tableObserver.table,
1491 tableObserver.setAnchorCellForSelection(cell);
1492 tableObserver.setFocusCellForSelection(cell, true);
1494 return selectTableNodeInDirection(
1505 } else if ($isTableSelection(selection)) {
1506 const {anchor, focus} = selection;
1507 const anchorCellNode = $findMatchingParent(
1511 const focusCellNode = $findMatchingParent(
1516 const [tableNodeFromSelection] = selection.getNodes();
1517 const tableElement = editor.getElementByKey(
1518 tableNodeFromSelection.getKey(),
1521 !$isTableCellNode(anchorCellNode) ||
1522 !$isTableCellNode(focusCellNode) ||
1523 !$isTableNode(tableNodeFromSelection) ||
1524 tableElement == null
1528 tableObserver.updateTableTableSelection(selection);
1530 const grid = getTable(tableElement);
1531 const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1532 const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1537 tableObserver.setAnchorCellForSelection(anchorCell);
1541 if (event.shiftKey) {
1542 const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1543 return adjustFocusNodeInDirection(
1545 tableNodeFromSelection,
1551 focusCellNode.selectEnd();
1560 function stopEvent(event: Event) {
1561 event.preventDefault();
1562 event.stopImmediatePropagation();
1563 event.stopPropagation();
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();
1574 root.hasAttribute('aria-controls') &&
1575 root.getAttribute('aria-controls') === 'typeahead-menu'
1579 function isExitingTableAnchor(
1582 anchorNode: LexicalNode,
1583 direction: 'backward' | 'forward',
1586 isExitingTableElementAnchor(type, anchorNode, direction) ||
1587 $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1591 function isExitingTableElementAnchor(
1593 anchorNode: LexicalNode,
1594 direction: 'backward' | 'forward',
1597 type === 'element' &&
1598 (direction === 'backward'
1599 ? anchorNode.getPreviousSibling() === null
1600 : anchorNode.getNextSibling() === null)
1604 function $isExitingTableTextAnchor(
1607 anchorNode: LexicalNode,
1608 direction: 'backward' | 'forward',
1610 const parentNode = $findMatchingParent(
1612 (n) => $isElementNode(n) && !n.isInline(),
1617 const hasValidOffset =
1618 direction === 'backward'
1620 : offset === anchorNode.getTextContentSize();
1624 (direction === 'backward'
1625 ? parentNode.getPreviousSibling() === null
1626 : parentNode.getNextSibling() === null)
1630 function $handleTableExit(
1631 event: KeyboardEvent,
1632 anchorNode: LexicalNode,
1633 tableNode: TableNode,
1634 direction: 'backward' | 'forward',
1636 const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1637 if (!$isTableCellNode(anchorCellNode)) {
1640 const [tableMap, cellValue] = $computeTableMap(
1645 if (!isExitingCell(tableMap, cellValue, direction)) {
1649 const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1650 if (!toNode || $isTableNode(toNode)) {
1655 if (direction === 'backward') {
1658 toNode.selectStart();
1663 function isExitingCell(
1664 tableMap: TableMapType,
1665 cellValue: TableMapValueType,
1666 direction: 'backward' | 'forward',
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;
1676 function $getExitingToNode(
1677 anchorNode: LexicalNode,
1678 direction: 'backward' | 'forward',
1679 tableNode: TableNode,
1681 const parentNode = $findMatchingParent(
1683 (n) => $isElementNode(n) && !n.isInline(),
1688 const anchorSibling =
1689 direction === 'backward'
1690 ? parentNode.getPreviousSibling()
1691 : parentNode.getNextSibling();
1692 return anchorSibling && $isTableNode(anchorSibling)
1694 : direction === 'backward'
1695 ? tableNode.getPreviousSibling()
1696 : tableNode.getNextSibling();
1699 function $insertParagraphAtTableEdge(
1700 edgePosition: 'first' | 'last',
1701 tableNode: TableNode,
1702 children?: LexicalNode[],
1704 const paragraphNode = $createParagraphNode();
1705 if (edgePosition === 'first') {
1706 tableNode.insertBefore(paragraphNode);
1708 tableNode.insertAfter(paragraphNode);
1710 paragraphNode.append(...(children || []));
1711 paragraphNode.selectEnd();
1714 function $getTableEdgeCursorPosition(
1715 editor: LexicalEditor,
1716 selection: RangeSelection,
1717 tableNode: TableNode,
1719 const tableNodeParent = tableNode.getParent();
1720 if (!tableNodeParent) {
1724 const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1725 if (!tableNodeParentDOM) {
1729 // TODO: Add support for nested tables
1730 const domSelection = window.getSelection();
1731 if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1735 const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1736 $isTableCellNode(n),
1737 ) as TableCellNode | null;
1738 if (!anchorCellNode) {
1742 const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1745 if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1749 const [tableMap, cellValue] = $computeTableMap(
1754 const firstCell = tableMap[0][0];
1755 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1756 const {startRow, startColumn} = cellValue;
1758 const isAtFirstCell =
1759 startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1760 const isAtLastCell =
1761 startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1763 if (isAtFirstCell) {
1765 } else if (isAtLastCell) {