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';
28 $getClipboardDataFromSelection,
30 } from '@lexical/clipboard';
31 import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
34 $createRangeSelectionFromDom,
36 $getNearestNodeFromDOMNode,
37 $getPreviousSelection,
45 COMMAND_PRIORITY_CRITICAL,
46 COMMAND_PRIORITY_HIGH,
47 CONTROLLED_TEXT_INSERTION_COMMAND,
49 DELETE_CHARACTER_COMMAND,
53 FORMAT_ELEMENT_COMMAND,
55 INSERT_PARAGRAPH_COMMAND,
56 KEY_ARROW_DOWN_COMMAND,
57 KEY_ARROW_LEFT_COMMAND,
58 KEY_ARROW_RIGHT_COMMAND,
60 KEY_BACKSPACE_COMMAND,
64 SELECTION_CHANGE_COMMAND,
65 SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
67 import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
68 import invariant from 'lexical/shared/invariant';
70 import {$isTableCellNode} from './LexicalTableCellNode';
71 import {$isTableNode} from './LexicalTableNode';
72 import {TableDOMTable, TableObserver} from './LexicalTableObserver';
73 import {$isTableRowNode} from './LexicalTableRowNode';
74 import {$isTableSelection} from './LexicalTableSelection';
75 import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
77 const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
79 export const getDOMSelection = (
80 targetWindow: Window | null,
81 ): Selection | null =>
82 CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
84 const isMouseDownOnEvent = (event: MouseEvent) => {
85 return (event.buttons & 1) === 1;
88 export function applyTableHandlers(
90 tableElement: HTMLTableElementWithWithTableSelectionState,
91 editor: LexicalEditor,
92 hasTabHandler: boolean,
94 const rootElement = editor.getRootElement();
96 if (rootElement === null) {
97 throw new Error('No root element.');
100 const tableObserver = new TableObserver(editor, tableNode.getKey());
101 const editorWindow = editor._window || window;
103 attachTableObserverToTableElement(tableElement, tableObserver);
105 const createMouseHandlers = () => {
106 const onMouseUp = () => {
107 tableObserver.isSelecting = false;
108 editorWindow.removeEventListener('mouseup', onMouseUp);
109 editorWindow.removeEventListener('mousemove', onMouseMove);
112 const onMouseMove = (moveEvent: MouseEvent) => {
113 // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
115 if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
116 tableObserver.isSelecting = false;
117 editorWindow.removeEventListener('mouseup', onMouseUp);
118 editorWindow.removeEventListener('mousemove', onMouseMove);
121 const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
123 focusCell !== null &&
124 (tableObserver.anchorX !== focusCell.x ||
125 tableObserver.anchorY !== focusCell.y)
127 moveEvent.preventDefault();
128 tableObserver.setFocusCellForSelection(focusCell);
132 return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
135 tableElement.addEventListener('mousedown', (event: MouseEvent) => {
137 if (event.button !== 0) {
145 const anchorCell = getDOMCellFromTarget(event.target as Node);
146 if (anchorCell !== null) {
148 tableObserver.setAnchorCellForSelection(anchorCell);
151 const {onMouseUp, onMouseMove} = createMouseHandlers();
152 tableObserver.isSelecting = true;
153 editorWindow.addEventListener('mouseup', onMouseUp);
154 editorWindow.addEventListener('mousemove', onMouseMove);
158 // Clear selection when clicking outside of dom.
159 const mouseDownCallback = (event: MouseEvent) => {
160 if (event.button !== 0) {
164 editor.update(() => {
165 const selection = $getSelection();
166 const target = event.target as Node;
168 $isTableSelection(selection) &&
169 selection.tableKey === tableObserver.tableNodeKey &&
170 rootElement.contains(target)
172 tableObserver.clearHighlight();
177 editorWindow.addEventListener('mousedown', mouseDownCallback);
179 tableObserver.listenersToRemove.add(() =>
180 editorWindow.removeEventListener('mousedown', mouseDownCallback),
183 tableObserver.listenersToRemove.add(
184 editor.registerCommand<KeyboardEvent>(
185 KEY_ARROW_DOWN_COMMAND,
187 $handleArrowKey(editor, event, 'down', tableNode, tableObserver),
188 COMMAND_PRIORITY_HIGH,
192 tableObserver.listenersToRemove.add(
193 editor.registerCommand<KeyboardEvent>(
194 KEY_ARROW_UP_COMMAND,
195 (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver),
196 COMMAND_PRIORITY_HIGH,
200 tableObserver.listenersToRemove.add(
201 editor.registerCommand<KeyboardEvent>(
202 KEY_ARROW_LEFT_COMMAND,
204 $handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
205 COMMAND_PRIORITY_HIGH,
209 tableObserver.listenersToRemove.add(
210 editor.registerCommand<KeyboardEvent>(
211 KEY_ARROW_RIGHT_COMMAND,
213 $handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
214 COMMAND_PRIORITY_HIGH,
218 tableObserver.listenersToRemove.add(
219 editor.registerCommand<KeyboardEvent>(
222 const selection = $getSelection();
223 if ($isTableSelection(selection)) {
224 const focusCellNode = $findMatchingParent(
225 selection.focus.getNode(),
228 if ($isTableCellNode(focusCellNode)) {
230 focusCellNode.selectEnd();
237 COMMAND_PRIORITY_HIGH,
241 const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
242 const selection = $getSelection();
244 if (!$isSelectionInTable(selection, tableNode)) {
248 if ($isTableSelection(selection)) {
249 tableObserver.clearText();
252 } else if ($isRangeSelection(selection)) {
253 const tableCellNode = $findMatchingParent(
254 selection.anchor.getNode(),
255 (n) => $isTableCellNode(n),
258 if (!$isTableCellNode(tableCellNode)) {
262 const anchorNode = selection.anchor.getNode();
263 const focusNode = selection.focus.getNode();
264 const isAnchorInside = tableNode.isParentOf(anchorNode);
265 const isFocusInside = tableNode.isParentOf(focusNode);
267 const selectionContainsPartialTable =
268 (isAnchorInside && !isFocusInside) ||
269 (isFocusInside && !isAnchorInside);
271 if (selectionContainsPartialTable) {
272 tableObserver.clearText();
276 const nearestElementNode = $findMatchingParent(
277 selection.anchor.getNode(),
278 (n) => $isElementNode(n),
281 const topLevelCellElementNode =
282 nearestElementNode &&
285 (n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
289 !$isElementNode(topLevelCellElementNode) ||
290 !$isElementNode(nearestElementNode)
296 command === DELETE_LINE_COMMAND &&
297 topLevelCellElementNode.getPreviousSibling() === null
299 // TODO: Fix Delete Line in Table Cells.
307 [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
309 tableObserver.listenersToRemove.add(
310 editor.registerCommand(
312 deleteTextHandler(command),
313 COMMAND_PRIORITY_CRITICAL,
319 const $deleteCellHandler = (
320 event: KeyboardEvent | ClipboardEvent | null,
322 const selection = $getSelection();
324 if (!$isSelectionInTable(selection, tableNode)) {
325 const nodes = selection ? selection.getNodes() : null;
327 const table = nodes.find(
329 $isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
331 if ($isTableNode(table)) {
332 const parentNode = table.getParent();
342 if ($isTableSelection(selection)) {
344 event.preventDefault();
345 event.stopPropagation();
347 tableObserver.clearText();
350 } else if ($isRangeSelection(selection)) {
351 const tableCellNode = $findMatchingParent(
352 selection.anchor.getNode(),
353 (n) => $isTableCellNode(n),
356 if (!$isTableCellNode(tableCellNode)) {
364 tableObserver.listenersToRemove.add(
365 editor.registerCommand<KeyboardEvent>(
366 KEY_BACKSPACE_COMMAND,
368 COMMAND_PRIORITY_CRITICAL,
372 tableObserver.listenersToRemove.add(
373 editor.registerCommand<KeyboardEvent>(
376 COMMAND_PRIORITY_CRITICAL,
380 tableObserver.listenersToRemove.add(
381 editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
384 const selection = $getSelection();
386 if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
389 // Copying to the clipboard is async so we must capture the data
390 // before we delete it
391 void copyToClipboard(
393 objectKlassEquals(event, ClipboardEvent)
394 ? (event as ClipboardEvent)
396 $getClipboardDataFromSelection(selection),
398 const intercepted = $deleteCellHandler(event);
399 if ($isRangeSelection(selection)) {
400 selection.removeText();
406 COMMAND_PRIORITY_CRITICAL,
410 tableObserver.listenersToRemove.add(
411 editor.registerCommand<TextFormatType>(
414 const selection = $getSelection();
416 if (!$isSelectionInTable(selection, tableNode)) {
420 if ($isTableSelection(selection)) {
421 tableObserver.formatCells(payload);
424 } else if ($isRangeSelection(selection)) {
425 const tableCellNode = $findMatchingParent(
426 selection.anchor.getNode(),
427 (n) => $isTableCellNode(n),
430 if (!$isTableCellNode(tableCellNode)) {
437 COMMAND_PRIORITY_CRITICAL,
441 tableObserver.listenersToRemove.add(
442 editor.registerCommand(
443 CONTROLLED_TEXT_INSERTION_COMMAND,
445 const selection = $getSelection();
447 if (!$isSelectionInTable(selection, tableNode)) {
451 if ($isTableSelection(selection)) {
452 tableObserver.clearHighlight();
455 } else if ($isRangeSelection(selection)) {
456 const tableCellNode = $findMatchingParent(
457 selection.anchor.getNode(),
458 (n) => $isTableCellNode(n),
461 if (!$isTableCellNode(tableCellNode)) {
465 if (typeof payload === 'string') {
466 const edgePosition = $getTableEdgeCursorPosition(
472 $insertParagraphAtTableEdge(edgePosition, tableNode, [
473 $createTextNode(payload),
482 COMMAND_PRIORITY_CRITICAL,
487 tableObserver.listenersToRemove.add(
488 editor.registerCommand<KeyboardEvent>(
491 const selection = $getSelection();
493 !$isRangeSelection(selection) ||
494 !selection.isCollapsed() ||
495 !$isSelectionInTable(selection, tableNode)
500 const tableCellNode = $findCellNode(selection.anchor.getNode());
501 if (tableCellNode === null) {
507 const currentCords = tableNode.getCordsFromCellNode(
512 selectTableNodeInDirection(
517 !event.shiftKey ? 'forward' : 'backward',
522 COMMAND_PRIORITY_CRITICAL,
527 tableObserver.listenersToRemove.add(
528 editor.registerCommand(
531 return tableNode.isSelected();
533 COMMAND_PRIORITY_HIGH,
537 function getObserverCellFromCellNode(
538 tableCellNode: TableCellNode,
540 const currentCords = tableNode.getCordsFromCellNode(
544 return tableNode.getDOMCellFromCordsOrThrow(
551 tableObserver.listenersToRemove.add(
552 editor.registerCommand(
553 SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
554 (selectionPayload) => {
555 const {nodes, selection} = selectionPayload;
556 const anchorAndFocus = selection.getStartEndPoints();
557 const isTableSelection = $isTableSelection(selection);
558 const isRangeSelection = $isRangeSelection(selection);
559 const isSelectionInsideOfGrid =
561 $findMatchingParent(selection.anchor.getNode(), (n) =>
564 $findMatchingParent(selection.focus.getNode(), (n) =>
570 nodes.length !== 1 ||
571 !$isTableNode(nodes[0]) ||
572 !isSelectionInsideOfGrid ||
573 anchorAndFocus === null
577 const [anchor] = anchorAndFocus;
579 const newGrid = nodes[0];
580 const newGridRows = newGrid.getChildren();
581 const newColumnCount = newGrid
582 .getFirstChildOrThrow<TableNode>()
584 const newRowCount = newGrid.getChildrenSize();
585 const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
590 $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
593 $findMatchingParent(gridRowNode, (n) => $isTableNode(n));
596 !$isTableCellNode(gridCellNode) ||
597 !$isTableRowNode(gridRowNode) ||
598 !$isTableNode(gridNode)
603 const startY = gridRowNode.getIndexWithinParent();
604 const stopY = Math.min(
605 gridNode.getChildrenSize() - 1,
606 startY + newRowCount - 1,
608 const startX = gridCellNode.getIndexWithinParent();
609 const stopX = Math.min(
610 gridRowNode.getChildrenSize() - 1,
611 startX + newColumnCount - 1,
613 const fromX = Math.min(startX, stopX);
614 const fromY = Math.min(startY, stopY);
615 const toX = Math.max(startX, stopX);
616 const toY = Math.max(startY, stopY);
617 const gridRowNodes = gridNode.getChildren();
620 for (let r = fromY; r <= toY; r++) {
621 const currentGridRowNode = gridRowNodes[r];
623 if (!$isTableRowNode(currentGridRowNode)) {
627 const newGridRowNode = newGridRows[newRowIdx];
629 if (!$isTableRowNode(newGridRowNode)) {
633 const gridCellNodes = currentGridRowNode.getChildren();
634 const newGridCellNodes = newGridRowNode.getChildren();
635 let newColumnIdx = 0;
637 for (let c = fromX; c <= toX; c++) {
638 const currentGridCellNode = gridCellNodes[c];
640 if (!$isTableCellNode(currentGridCellNode)) {
644 const newGridCellNode = newGridCellNodes[newColumnIdx];
646 if (!$isTableCellNode(newGridCellNode)) {
650 const originalChildren = currentGridCellNode.getChildren();
651 newGridCellNode.getChildren().forEach((child) => {
652 if ($isTextNode(child)) {
653 const paragraphNode = $createParagraphNode();
654 paragraphNode.append(child);
655 currentGridCellNode.append(child);
657 currentGridCellNode.append(child);
660 originalChildren.forEach((n) => n.remove());
668 COMMAND_PRIORITY_CRITICAL,
672 tableObserver.listenersToRemove.add(
673 editor.registerCommand(
674 SELECTION_CHANGE_COMMAND,
676 const selection = $getSelection();
677 const prevSelection = $getPreviousSelection();
679 if ($isRangeSelection(selection)) {
680 const {anchor, focus} = selection;
681 const anchorNode = anchor.getNode();
682 const focusNode = focus.getNode();
683 // Using explicit comparison with table node to ensure it's not a nested table
684 // as in that case we'll leave selection resolving to that table
685 const anchorCellNode = $findCellNode(anchorNode);
686 const focusCellNode = $findCellNode(focusNode);
687 const isAnchorInside = !!(
688 anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
690 const isFocusInside = !!(
691 focusCellNode && tableNode.is($findTableNode(focusCellNode))
693 const isPartialyWithinTable = isAnchorInside !== isFocusInside;
694 const isWithinTable = isAnchorInside && isFocusInside;
695 const isBackward = selection.isBackward();
697 if (isPartialyWithinTable) {
698 const newSelection = selection.clone();
700 const [tableMap] = $computeTableMap(
705 const firstCell = tableMap[0][0].cell;
706 const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
707 newSelection.focus.set(
708 isBackward ? firstCell.getKey() : lastCell.getKey(),
710 ? firstCell.getChildrenSize()
711 : lastCell.getChildrenSize(),
715 $setSelection(newSelection);
716 $addHighlightStyleToTable(editor, tableObserver);
717 } else if (isWithinTable) {
718 // Handle case when selection spans across multiple cells but still
719 // has range selection, then we convert it into grid selection
720 if (!anchorCellNode.is(focusCellNode)) {
721 tableObserver.setAnchorCellForSelection(
722 getObserverCellFromCellNode(anchorCellNode),
724 tableObserver.setFocusCellForSelection(
725 getObserverCellFromCellNode(focusCellNode),
728 if (!tableObserver.isSelecting) {
730 const {onMouseUp, onMouseMove} = createMouseHandlers();
731 tableObserver.isSelecting = true;
732 editorWindow.addEventListener('mouseup', onMouseUp);
733 editorWindow.addEventListener('mousemove', onMouseMove);
740 $isTableSelection(selection) &&
741 selection.is(prevSelection) &&
742 selection.tableKey === tableNode.getKey()
744 // if selection goes outside of the table we need to change it to Range selection
745 const domSelection = getDOMSelection(editor._window);
748 domSelection.anchorNode &&
749 domSelection.focusNode
751 const focusNode = $getNearestNodeFromDOMNode(
752 domSelection.focusNode,
754 const isFocusOutside =
755 focusNode && !tableNode.is($findTableNode(focusNode));
757 const anchorNode = $getNearestNodeFromDOMNode(
758 domSelection.anchorNode,
760 const isAnchorInside =
761 anchorNode && tableNode.is($findTableNode(anchorNode));
766 domSelection.rangeCount > 0
768 const newSelection = $createRangeSelectionFromDom(
773 newSelection.anchor.set(
775 selection.isBackward() ? tableNode.getChildrenSize() : 0,
778 domSelection.removeAllRanges();
779 $setSelection(newSelection);
787 !selection.is(prevSelection) &&
788 ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
789 tableObserver.tableSelection &&
790 !tableObserver.tableSelection.is(prevSelection)
793 $isTableSelection(selection) &&
794 selection.tableKey === tableObserver.tableNodeKey
796 tableObserver.updateTableTableSelection(selection);
798 !$isTableSelection(selection) &&
799 $isTableSelection(prevSelection) &&
800 prevSelection.tableKey === tableObserver.tableNodeKey
802 tableObserver.updateTableTableSelection(null);
808 tableObserver.hasHijackedSelectionStyles &&
809 !tableNode.isSelected()
811 $removeHighlightStyleToTable(editor, tableObserver);
813 !tableObserver.hasHijackedSelectionStyles &&
814 tableNode.isSelected()
816 $addHighlightStyleToTable(editor, tableObserver);
821 COMMAND_PRIORITY_CRITICAL,
825 tableObserver.listenersToRemove.add(
826 editor.registerCommand(
827 INSERT_PARAGRAPH_COMMAND,
829 const selection = $getSelection();
831 !$isRangeSelection(selection) ||
832 !selection.isCollapsed() ||
833 !$isSelectionInTable(selection, tableNode)
837 const edgePosition = $getTableEdgeCursorPosition(
843 $insertParagraphAtTableEdge(edgePosition, tableNode);
848 COMMAND_PRIORITY_CRITICAL,
852 return tableObserver;
855 export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
856 Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
858 export function attachTableObserverToTableElement(
859 tableElement: HTMLTableElementWithWithTableSelectionState,
860 tableObserver: TableObserver,
862 tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
865 export function getTableObserverFromTableElement(
866 tableElement: HTMLTableElementWithWithTableSelectionState,
867 ): TableObserver | null {
868 return tableElement[LEXICAL_ELEMENT_KEY];
871 export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
872 let currentNode: ParentNode | Node | null = node;
874 while (currentNode != null) {
875 const nodeName = currentNode.nodeName;
877 if (nodeName === 'TD' || nodeName === 'TH') {
878 // @ts-expect-error: internal field
879 const cell = currentNode._cell;
881 if (cell === undefined) {
888 currentNode = currentNode.parentNode;
894 export function doesTargetContainText(node: Node): boolean {
895 const currentNode: ParentNode | Node | null = node;
897 if (currentNode !== null) {
898 const nodeName = currentNode.nodeName;
900 if (nodeName === 'SPAN') {
907 export function getTable(tableElement: HTMLElement): TableDOMTable {
908 const domRows: TableDOMRows = [];
914 let currentNode = tableElement.firstChild;
919 while (currentNode != null) {
920 const nodeMame = currentNode.nodeName;
922 if (nodeMame === 'TD' || nodeMame === 'TH') {
923 const elem = currentNode as HTMLElement;
926 hasBackgroundColor: elem.style.backgroundColor !== '',
932 // @ts-expect-error: internal field
933 currentNode._cell = cell;
935 let row = domRows[y];
936 if (row === undefined) {
937 row = domRows[y] = [];
942 const child = currentNode.firstChild;
950 const sibling = currentNode.nextSibling;
952 if (sibling != null) {
954 currentNode = sibling;
958 const parent = currentNode.parentNode;
960 if (parent != null) {
961 const parentSibling = parent.nextSibling;
963 if (parentSibling == null) {
969 currentNode = parentSibling;
973 grid.columns = x + 1;
979 export function $updateDOMForSelection(
980 editor: LexicalEditor,
981 table: TableDOMTable,
982 selection: TableSelection | RangeSelection | null,
984 const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
985 $forEachTableCell(table, (cell, lexicalNode) => {
986 const elem = cell.elem;
988 if (selectedCellNodes.has(lexicalNode)) {
989 cell.highlighted = true;
990 $addHighlightToDOM(editor, cell);
992 cell.highlighted = false;
993 $removeHighlightFromDOM(editor, cell);
994 if (!elem.getAttribute('style')) {
995 elem.removeAttribute('style');
1001 export function $forEachTableCell(
1002 grid: TableDOMTable,
1005 lexicalNode: LexicalNode,
1012 const {domRows} = grid;
1014 for (let y = 0; y < domRows.length; y++) {
1015 const row = domRows[y];
1020 for (let x = 0; x < row.length; x++) {
1021 const cell = row[x];
1025 const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1027 if (lexicalNode !== null) {
1028 cb(cell, lexicalNode, {
1037 export function $addHighlightStyleToTable(
1038 editor: LexicalEditor,
1039 tableSelection: TableObserver,
1041 tableSelection.disableHighlightStyle();
1042 $forEachTableCell(tableSelection.table, (cell) => {
1043 cell.highlighted = true;
1044 $addHighlightToDOM(editor, cell);
1048 export function $removeHighlightStyleToTable(
1049 editor: LexicalEditor,
1050 tableObserver: TableObserver,
1052 tableObserver.enableHighlightStyle();
1053 $forEachTableCell(tableObserver.table, (cell) => {
1054 const elem = cell.elem;
1055 cell.highlighted = false;
1056 $removeHighlightFromDOM(editor, cell);
1058 if (!elem.getAttribute('style')) {
1059 elem.removeAttribute('style');
1064 type Direction = 'backward' | 'forward' | 'up' | 'down';
1066 const selectTableNodeInDirection = (
1067 tableObserver: TableObserver,
1068 tableNode: TableNode,
1071 direction: Direction,
1073 const isForward = direction === 'forward';
1075 switch (direction) {
1078 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1079 selectTableCellNode(
1080 tableNode.getCellNodeFromCordsOrThrow(
1081 x + (isForward ? 1 : -1),
1083 tableObserver.table,
1088 if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1089 selectTableCellNode(
1090 tableNode.getCellNodeFromCordsOrThrow(
1091 isForward ? 0 : tableObserver.table.columns - 1,
1092 y + (isForward ? 1 : -1),
1093 tableObserver.table,
1097 } else if (!isForward) {
1098 tableNode.selectPrevious();
1100 tableNode.selectNext();
1108 selectTableCellNode(
1109 tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1113 tableNode.selectPrevious();
1119 if (y !== tableObserver.table.rows - 1) {
1120 selectTableCellNode(
1121 tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1125 tableNode.selectNext();
1134 const adjustFocusNodeInDirection = (
1135 tableObserver: TableObserver,
1136 tableNode: TableNode,
1139 direction: Direction,
1141 const isForward = direction === 'forward';
1143 switch (direction) {
1146 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1147 tableObserver.setFocusCellForSelection(
1148 tableNode.getDOMCellFromCordsOrThrow(
1149 x + (isForward ? 1 : -1),
1151 tableObserver.table,
1159 tableObserver.setFocusCellForSelection(
1160 tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1168 if (y !== tableObserver.table.rows - 1) {
1169 tableObserver.setFocusCellForSelection(
1170 tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1182 function $isSelectionInTable(
1183 selection: null | BaseSelection,
1184 tableNode: TableNode,
1186 if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1187 const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1188 const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1190 return isAnchorInside && isFocusInside;
1196 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1198 tableCell.selectStart();
1200 tableCell.selectEnd();
1204 const BROWSER_BLUE_RGB = '172,206,247';
1205 function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1206 const element = cell.elem;
1207 const node = $getNearestNodeFromDOMNode(element);
1209 $isTableCellNode(node),
1210 'Expected to find LexicalNode from Table Cell DOMNode',
1212 const backgroundColor = node.getBackgroundColor();
1213 if (backgroundColor === null) {
1214 element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1216 element.style.setProperty(
1218 `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1221 element.style.setProperty('caret-color', 'transparent');
1224 function $removeHighlightFromDOM(
1225 editor: LexicalEditor,
1228 const element = cell.elem;
1229 const node = $getNearestNodeFromDOMNode(element);
1231 $isTableCellNode(node),
1232 'Expected to find LexicalNode from Table Cell DOMNode',
1234 const backgroundColor = node.getBackgroundColor();
1235 if (backgroundColor === null) {
1236 element.style.removeProperty('background-color');
1238 element.style.removeProperty('background-image');
1239 element.style.removeProperty('caret-color');
1242 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1243 const cellNode = $findMatchingParent(node, $isTableCellNode);
1244 return $isTableCellNode(cellNode) ? cellNode : null;
1247 export function $findTableNode(node: LexicalNode): null | TableNode {
1248 const tableNode = $findMatchingParent(node, $isTableNode);
1249 return $isTableNode(tableNode) ? tableNode : null;
1252 function $handleArrowKey(
1253 editor: LexicalEditor,
1254 event: KeyboardEvent,
1255 direction: Direction,
1256 tableNode: TableNode,
1257 tableObserver: TableObserver,
1260 (direction === 'up' || direction === 'down') &&
1261 isTypeaheadMenuInView(editor)
1266 const selection = $getSelection();
1268 if (!$isSelectionInTable(selection, tableNode)) {
1269 if ($isRangeSelection(selection)) {
1270 if (selection.isCollapsed() && direction === 'backward') {
1271 const anchorType = selection.anchor.type;
1272 const anchorOffset = selection.anchor.offset;
1274 anchorType !== 'element' &&
1275 !(anchorType === 'text' && anchorOffset === 0)
1279 const anchorNode = selection.anchor.getNode();
1283 const parentNode = $findMatchingParent(
1285 (n) => $isElementNode(n) && !n.isInline(),
1290 const siblingNode = parentNode.getPreviousSibling();
1291 if (!siblingNode || !$isTableNode(siblingNode)) {
1295 siblingNode.selectEnd();
1299 (direction === 'up' || direction === 'down')
1301 const focusNode = selection.focus.getNode();
1302 if ($isRootOrShadowRoot(focusNode)) {
1303 const selectedNode = selection.getNodes()[0];
1305 const tableCellNode = $findMatchingParent(
1309 if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1310 const firstDescendant = tableNode.getFirstDescendant();
1311 const lastDescendant = tableNode.getLastDescendant();
1312 if (!firstDescendant || !lastDescendant) {
1315 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1316 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1317 const firstCellCoords = tableNode.getCordsFromCellNode(
1319 tableObserver.table,
1321 const lastCellCoords = tableNode.getCordsFromCellNode(
1323 tableObserver.table,
1325 const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1328 tableObserver.table,
1330 const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1333 tableObserver.table,
1335 tableObserver.setAnchorCellForSelection(firstCellDOM);
1336 tableObserver.setFocusCellForSelection(lastCellDOM, true);
1342 const focusParentNode = $findMatchingParent(
1344 (n) => $isElementNode(n) && !n.isInline(),
1346 if (!focusParentNode) {
1350 direction === 'down'
1351 ? focusParentNode.getNextSibling()
1352 : focusParentNode.getPreviousSibling();
1354 $isTableNode(sibling) &&
1355 tableObserver.tableNodeKey === sibling.getKey()
1357 const firstDescendant = sibling.getFirstDescendant();
1358 const lastDescendant = sibling.getLastDescendant();
1359 if (!firstDescendant || !lastDescendant) {
1362 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1363 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1364 const newSelection = selection.clone();
1365 newSelection.focus.set(
1366 (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
1367 direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
1370 $setSelection(newSelection);
1379 if ($isRangeSelection(selection) && selection.isCollapsed()) {
1380 const {anchor, focus} = selection;
1381 const anchorCellNode = $findMatchingParent(
1385 const focusCellNode = $findMatchingParent(
1390 !$isTableCellNode(anchorCellNode) ||
1391 !anchorCellNode.is(focusCellNode)
1395 const anchorCellTable = $findTableNode(anchorCellNode);
1396 if (anchorCellTable !== tableNode && anchorCellTable != null) {
1397 const anchorCellTableElement = editor.getElementByKey(
1398 anchorCellTable.getKey(),
1400 if (anchorCellTableElement != null) {
1401 tableObserver.table = getTable(anchorCellTableElement);
1402 return $handleArrowKey(
1412 if (direction === 'backward' || direction === 'forward') {
1413 const anchorType = anchor.type;
1414 const anchorOffset = anchor.offset;
1415 const anchorNode = anchor.getNode();
1420 const selectedNodes = selection.getNodes();
1421 if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1426 isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1428 return $handleTableExit(event, anchorNode, tableNode, direction);
1434 const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1435 const anchorDOM = editor.getElementByKey(anchor.key);
1436 if (anchorDOM == null || anchorCellDom == null) {
1440 let edgeSelectionRect;
1441 if (anchor.type === 'element') {
1442 edgeSelectionRect = anchorDOM.getBoundingClientRect();
1444 const domSelection = window.getSelection();
1445 if (domSelection === null || domSelection.rangeCount === 0) {
1449 const range = domSelection.getRangeAt(0);
1450 edgeSelectionRect = range.getBoundingClientRect();
1455 ? anchorCellNode.getFirstChild()
1456 : anchorCellNode.getLastChild();
1457 if (edgeChild == null) {
1461 const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1463 if (edgeChildDOM == null) {
1467 const edgeRect = edgeChildDOM.getBoundingClientRect();
1470 ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1471 : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1476 const cords = tableNode.getCordsFromCellNode(
1478 tableObserver.table,
1481 if (event.shiftKey) {
1482 const cell = tableNode.getDOMCellFromCordsOrThrow(
1485 tableObserver.table,
1487 tableObserver.setAnchorCellForSelection(cell);
1488 tableObserver.setFocusCellForSelection(cell, true);
1490 return selectTableNodeInDirection(
1501 } else if ($isTableSelection(selection)) {
1502 const {anchor, focus} = selection;
1503 const anchorCellNode = $findMatchingParent(
1507 const focusCellNode = $findMatchingParent(
1512 const [tableNodeFromSelection] = selection.getNodes();
1513 const tableElement = editor.getElementByKey(
1514 tableNodeFromSelection.getKey(),
1517 !$isTableCellNode(anchorCellNode) ||
1518 !$isTableCellNode(focusCellNode) ||
1519 !$isTableNode(tableNodeFromSelection) ||
1520 tableElement == null
1524 tableObserver.updateTableTableSelection(selection);
1526 const grid = getTable(tableElement);
1527 const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1528 const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1533 tableObserver.setAnchorCellForSelection(anchorCell);
1537 if (event.shiftKey) {
1538 const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1539 return adjustFocusNodeInDirection(
1541 tableNodeFromSelection,
1547 focusCellNode.selectEnd();
1556 function stopEvent(event: Event) {
1557 event.preventDefault();
1558 event.stopImmediatePropagation();
1559 event.stopPropagation();
1562 function isTypeaheadMenuInView(editor: LexicalEditor) {
1563 // There is no inbuilt way to check if the component picker is in view
1564 // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
1565 const root = editor.getRootElement();
1570 root.hasAttribute('aria-controls') &&
1571 root.getAttribute('aria-controls') === 'typeahead-menu'
1575 function isExitingTableAnchor(
1578 anchorNode: LexicalNode,
1579 direction: 'backward' | 'forward',
1582 isExitingTableElementAnchor(type, anchorNode, direction) ||
1583 $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1587 function isExitingTableElementAnchor(
1589 anchorNode: LexicalNode,
1590 direction: 'backward' | 'forward',
1593 type === 'element' &&
1594 (direction === 'backward'
1595 ? anchorNode.getPreviousSibling() === null
1596 : anchorNode.getNextSibling() === null)
1600 function $isExitingTableTextAnchor(
1603 anchorNode: LexicalNode,
1604 direction: 'backward' | 'forward',
1606 const parentNode = $findMatchingParent(
1608 (n) => $isElementNode(n) && !n.isInline(),
1613 const hasValidOffset =
1614 direction === 'backward'
1616 : offset === anchorNode.getTextContentSize();
1620 (direction === 'backward'
1621 ? parentNode.getPreviousSibling() === null
1622 : parentNode.getNextSibling() === null)
1626 function $handleTableExit(
1627 event: KeyboardEvent,
1628 anchorNode: LexicalNode,
1629 tableNode: TableNode,
1630 direction: 'backward' | 'forward',
1632 const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1633 if (!$isTableCellNode(anchorCellNode)) {
1636 const [tableMap, cellValue] = $computeTableMap(
1641 if (!isExitingCell(tableMap, cellValue, direction)) {
1645 const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1646 if (!toNode || $isTableNode(toNode)) {
1651 if (direction === 'backward') {
1654 toNode.selectStart();
1659 function isExitingCell(
1660 tableMap: TableMapType,
1661 cellValue: TableMapValueType,
1662 direction: 'backward' | 'forward',
1664 const firstCell = tableMap[0][0];
1665 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1666 const {startColumn, startRow} = cellValue;
1667 return direction === 'backward'
1668 ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
1669 : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
1672 function $getExitingToNode(
1673 anchorNode: LexicalNode,
1674 direction: 'backward' | 'forward',
1675 tableNode: TableNode,
1677 const parentNode = $findMatchingParent(
1679 (n) => $isElementNode(n) && !n.isInline(),
1684 const anchorSibling =
1685 direction === 'backward'
1686 ? parentNode.getPreviousSibling()
1687 : parentNode.getNextSibling();
1688 return anchorSibling && $isTableNode(anchorSibling)
1690 : direction === 'backward'
1691 ? tableNode.getPreviousSibling()
1692 : tableNode.getNextSibling();
1695 function $insertParagraphAtTableEdge(
1696 edgePosition: 'first' | 'last',
1697 tableNode: TableNode,
1698 children?: LexicalNode[],
1700 const paragraphNode = $createParagraphNode();
1701 if (edgePosition === 'first') {
1702 tableNode.insertBefore(paragraphNode);
1704 tableNode.insertAfter(paragraphNode);
1706 paragraphNode.append(...(children || []));
1707 paragraphNode.selectEnd();
1710 function $getTableEdgeCursorPosition(
1711 editor: LexicalEditor,
1712 selection: RangeSelection,
1713 tableNode: TableNode,
1715 const tableNodeParent = tableNode.getParent();
1716 if (!tableNodeParent) {
1720 const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1721 if (!tableNodeParentDOM) {
1725 // TODO: Add support for nested tables
1726 const domSelection = window.getSelection();
1727 if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1731 const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1732 $isTableCellNode(n),
1733 ) as TableCellNode | null;
1734 if (!anchorCellNode) {
1738 const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1741 if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1745 const [tableMap, cellValue] = $computeTableMap(
1750 const firstCell = tableMap[0][0];
1751 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1752 const {startRow, startColumn} = cellValue;
1754 const isAtFirstCell =
1755 startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1756 const isAtLastCell =
1757 startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1759 if (isAtFirstCell) {
1761 } else if (isAtLastCell) {