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';
75 const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
77 export const getDOMSelection = (
78 targetWindow: Window | null,
79 ): Selection | null =>
80 CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
82 const isMouseDownOnEvent = (event: MouseEvent) => {
83 return (event.buttons & 1) === 1;
86 export function applyTableHandlers(
88 tableElement: HTMLTableElementWithWithTableSelectionState,
89 editor: LexicalEditor,
90 hasTabHandler: boolean,
92 const rootElement = editor.getRootElement();
94 if (rootElement === null) {
95 throw new Error('No root element.');
98 const tableObserver = new TableObserver(editor, tableNode.getKey());
99 const editorWindow = editor._window || window;
101 attachTableObserverToTableElement(tableElement, tableObserver);
103 const createMouseHandlers = () => {
104 const onMouseUp = () => {
105 tableObserver.isSelecting = false;
106 editorWindow.removeEventListener('mouseup', onMouseUp);
107 editorWindow.removeEventListener('mousemove', onMouseMove);
110 const onMouseMove = (moveEvent: MouseEvent) => {
111 // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
113 if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
114 tableObserver.isSelecting = false;
115 editorWindow.removeEventListener('mouseup', onMouseUp);
116 editorWindow.removeEventListener('mousemove', onMouseMove);
119 const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
121 focusCell !== null &&
122 (tableObserver.anchorX !== focusCell.x ||
123 tableObserver.anchorY !== focusCell.y)
125 moveEvent.preventDefault();
126 tableObserver.setFocusCellForSelection(focusCell);
130 return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
133 tableElement.addEventListener('mousedown', (event: MouseEvent) => {
135 if (event.button !== 0) {
143 const anchorCell = getDOMCellFromTarget(event.target as Node);
144 if (anchorCell !== null) {
146 tableObserver.setAnchorCellForSelection(anchorCell);
149 const {onMouseUp, onMouseMove} = createMouseHandlers();
150 tableObserver.isSelecting = true;
151 editorWindow.addEventListener('mouseup', onMouseUp);
152 editorWindow.addEventListener('mousemove', onMouseMove);
156 // Clear selection when clicking outside of dom.
157 const mouseDownCallback = (event: MouseEvent) => {
158 if (event.button !== 0) {
162 editor.update(() => {
163 const selection = $getSelection();
164 const target = event.target as Node;
166 $isTableSelection(selection) &&
167 selection.tableKey === tableObserver.tableNodeKey &&
168 rootElement.contains(target)
170 tableObserver.clearHighlight();
175 editorWindow.addEventListener('mousedown', mouseDownCallback);
177 tableObserver.listenersToRemove.add(() =>
178 editorWindow.removeEventListener('mousedown', mouseDownCallback),
181 tableObserver.listenersToRemove.add(
182 editor.registerCommand<KeyboardEvent>(
183 KEY_ARROW_DOWN_COMMAND,
185 $handleArrowKey(editor, event, 'down', tableNode, tableObserver),
186 COMMAND_PRIORITY_HIGH,
190 tableObserver.listenersToRemove.add(
191 editor.registerCommand<KeyboardEvent>(
192 KEY_ARROW_UP_COMMAND,
193 (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver),
194 COMMAND_PRIORITY_HIGH,
198 tableObserver.listenersToRemove.add(
199 editor.registerCommand<KeyboardEvent>(
200 KEY_ARROW_LEFT_COMMAND,
202 $handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
203 COMMAND_PRIORITY_HIGH,
207 tableObserver.listenersToRemove.add(
208 editor.registerCommand<KeyboardEvent>(
209 KEY_ARROW_RIGHT_COMMAND,
211 $handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
212 COMMAND_PRIORITY_HIGH,
216 tableObserver.listenersToRemove.add(
217 editor.registerCommand<KeyboardEvent>(
220 const selection = $getSelection();
221 if ($isTableSelection(selection)) {
222 const focusCellNode = $findMatchingParent(
223 selection.focus.getNode(),
226 if ($isTableCellNode(focusCellNode)) {
228 focusCellNode.selectEnd();
235 COMMAND_PRIORITY_HIGH,
239 const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
240 const selection = $getSelection();
242 if (!$isSelectionInTable(selection, tableNode)) {
246 if ($isTableSelection(selection)) {
247 tableObserver.clearText();
250 } else if ($isRangeSelection(selection)) {
251 const tableCellNode = $findMatchingParent(
252 selection.anchor.getNode(),
253 (n) => $isTableCellNode(n),
256 if (!$isTableCellNode(tableCellNode)) {
260 const anchorNode = selection.anchor.getNode();
261 const focusNode = selection.focus.getNode();
262 const isAnchorInside = tableNode.isParentOf(anchorNode);
263 const isFocusInside = tableNode.isParentOf(focusNode);
265 const selectionContainsPartialTable =
266 (isAnchorInside && !isFocusInside) ||
267 (isFocusInside && !isAnchorInside);
269 if (selectionContainsPartialTable) {
270 tableObserver.clearText();
274 const nearestElementNode = $findMatchingParent(
275 selection.anchor.getNode(),
276 (n) => $isElementNode(n),
279 const topLevelCellElementNode =
280 nearestElementNode &&
283 (n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
287 !$isElementNode(topLevelCellElementNode) ||
288 !$isElementNode(nearestElementNode)
294 command === DELETE_LINE_COMMAND &&
295 topLevelCellElementNode.getPreviousSibling() === null
297 // TODO: Fix Delete Line in Table Cells.
305 [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
307 tableObserver.listenersToRemove.add(
308 editor.registerCommand(
310 deleteTextHandler(command),
311 COMMAND_PRIORITY_CRITICAL,
317 const $deleteCellHandler = (
318 event: KeyboardEvent | ClipboardEvent | null,
320 const selection = $getSelection();
322 if (!$isSelectionInTable(selection, tableNode)) {
323 const nodes = selection ? selection.getNodes() : null;
325 const table = nodes.find(
327 $isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
329 if ($isTableNode(table)) {
330 const parentNode = table.getParent();
340 if ($isTableSelection(selection)) {
342 event.preventDefault();
343 event.stopPropagation();
345 tableObserver.clearText();
348 } else if ($isRangeSelection(selection)) {
349 const tableCellNode = $findMatchingParent(
350 selection.anchor.getNode(),
351 (n) => $isTableCellNode(n),
354 if (!$isTableCellNode(tableCellNode)) {
362 tableObserver.listenersToRemove.add(
363 editor.registerCommand<KeyboardEvent>(
364 KEY_BACKSPACE_COMMAND,
366 COMMAND_PRIORITY_CRITICAL,
370 tableObserver.listenersToRemove.add(
371 editor.registerCommand<KeyboardEvent>(
374 COMMAND_PRIORITY_CRITICAL,
378 tableObserver.listenersToRemove.add(
379 editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
382 const selection = $getSelection();
384 if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
387 // Copying to the clipboard is async so we must capture the data
388 // before we delete it
389 void copyToClipboard(
391 objectKlassEquals(event, ClipboardEvent)
392 ? (event as ClipboardEvent)
394 $getClipboardDataFromSelection(selection),
396 const intercepted = $deleteCellHandler(event);
397 if ($isRangeSelection(selection)) {
398 selection.removeText();
404 COMMAND_PRIORITY_CRITICAL,
408 tableObserver.listenersToRemove.add(
409 editor.registerCommand<TextFormatType>(
412 const selection = $getSelection();
414 if (!$isSelectionInTable(selection, tableNode)) {
418 if ($isTableSelection(selection)) {
419 tableObserver.formatCells(payload);
422 } else if ($isRangeSelection(selection)) {
423 const tableCellNode = $findMatchingParent(
424 selection.anchor.getNode(),
425 (n) => $isTableCellNode(n),
428 if (!$isTableCellNode(tableCellNode)) {
435 COMMAND_PRIORITY_CRITICAL,
439 tableObserver.listenersToRemove.add(
440 editor.registerCommand(
441 CONTROLLED_TEXT_INSERTION_COMMAND,
443 const selection = $getSelection();
445 if (!$isSelectionInTable(selection, tableNode)) {
449 if ($isTableSelection(selection)) {
450 tableObserver.clearHighlight();
453 } else if ($isRangeSelection(selection)) {
454 const tableCellNode = $findMatchingParent(
455 selection.anchor.getNode(),
456 (n) => $isTableCellNode(n),
459 if (!$isTableCellNode(tableCellNode)) {
463 if (typeof payload === 'string') {
464 const edgePosition = $getTableEdgeCursorPosition(
470 $insertParagraphAtTableEdge(edgePosition, tableNode, [
471 $createTextNode(payload),
480 COMMAND_PRIORITY_CRITICAL,
485 tableObserver.listenersToRemove.add(
486 editor.registerCommand<KeyboardEvent>(
489 const selection = $getSelection();
491 !$isRangeSelection(selection) ||
492 !selection.isCollapsed() ||
493 !$isSelectionInTable(selection, tableNode)
498 const tableCellNode = $findCellNode(selection.anchor.getNode());
499 if (tableCellNode === null) {
505 const currentCords = tableNode.getCordsFromCellNode(
510 selectTableNodeInDirection(
515 !event.shiftKey ? 'forward' : 'backward',
520 COMMAND_PRIORITY_CRITICAL,
525 tableObserver.listenersToRemove.add(
526 editor.registerCommand(
529 return tableNode.isSelected();
531 COMMAND_PRIORITY_HIGH,
535 function getObserverCellFromCellNode(
536 tableCellNode: TableCellNode,
538 const currentCords = tableNode.getCordsFromCellNode(
542 return tableNode.getDOMCellFromCordsOrThrow(
549 tableObserver.listenersToRemove.add(
550 editor.registerCommand(
551 SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
552 (selectionPayload) => {
553 const {nodes, selection} = selectionPayload;
554 const anchorAndFocus = selection.getStartEndPoints();
555 const isTableSelection = $isTableSelection(selection);
556 const isRangeSelection = $isRangeSelection(selection);
557 const isSelectionInsideOfGrid =
559 $findMatchingParent(selection.anchor.getNode(), (n) =>
562 $findMatchingParent(selection.focus.getNode(), (n) =>
568 nodes.length !== 1 ||
569 !$isTableNode(nodes[0]) ||
570 !isSelectionInsideOfGrid ||
571 anchorAndFocus === null
575 const [anchor] = anchorAndFocus;
577 const newGrid = nodes[0];
578 const newGridRows = newGrid.getChildren();
579 const newColumnCount = newGrid
580 .getFirstChildOrThrow<TableNode>()
582 const newRowCount = newGrid.getChildrenSize();
583 const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
588 $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
591 $findMatchingParent(gridRowNode, (n) => $isTableNode(n));
594 !$isTableCellNode(gridCellNode) ||
595 !$isTableRowNode(gridRowNode) ||
596 !$isTableNode(gridNode)
601 const startY = gridRowNode.getIndexWithinParent();
602 const stopY = Math.min(
603 gridNode.getChildrenSize() - 1,
604 startY + newRowCount - 1,
606 const startX = gridCellNode.getIndexWithinParent();
607 const stopX = Math.min(
608 gridRowNode.getChildrenSize() - 1,
609 startX + newColumnCount - 1,
611 const fromX = Math.min(startX, stopX);
612 const fromY = Math.min(startY, stopY);
613 const toX = Math.max(startX, stopX);
614 const toY = Math.max(startY, stopY);
615 const gridRowNodes = gridNode.getChildren();
618 for (let r = fromY; r <= toY; r++) {
619 const currentGridRowNode = gridRowNodes[r];
621 if (!$isTableRowNode(currentGridRowNode)) {
625 const newGridRowNode = newGridRows[newRowIdx];
627 if (!$isTableRowNode(newGridRowNode)) {
631 const gridCellNodes = currentGridRowNode.getChildren();
632 const newGridCellNodes = newGridRowNode.getChildren();
633 let newColumnIdx = 0;
635 for (let c = fromX; c <= toX; c++) {
636 const currentGridCellNode = gridCellNodes[c];
638 if (!$isTableCellNode(currentGridCellNode)) {
642 const newGridCellNode = newGridCellNodes[newColumnIdx];
644 if (!$isTableCellNode(newGridCellNode)) {
648 const originalChildren = currentGridCellNode.getChildren();
649 newGridCellNode.getChildren().forEach((child) => {
650 if ($isTextNode(child)) {
651 const paragraphNode = $createParagraphNode();
652 paragraphNode.append(child);
653 currentGridCellNode.append(child);
655 currentGridCellNode.append(child);
658 originalChildren.forEach((n) => n.remove());
666 COMMAND_PRIORITY_CRITICAL,
670 tableObserver.listenersToRemove.add(
671 editor.registerCommand(
672 SELECTION_CHANGE_COMMAND,
674 const selection = $getSelection();
675 const prevSelection = $getPreviousSelection();
677 if ($isRangeSelection(selection)) {
678 const {anchor, focus} = selection;
679 const anchorNode = anchor.getNode();
680 const focusNode = focus.getNode();
681 // Using explicit comparison with table node to ensure it's not a nested table
682 // as in that case we'll leave selection resolving to that table
683 const anchorCellNode = $findCellNode(anchorNode);
684 const focusCellNode = $findCellNode(focusNode);
685 const isAnchorInside = !!(
686 anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
688 const isFocusInside = !!(
689 focusCellNode && tableNode.is($findTableNode(focusCellNode))
691 const isPartialyWithinTable = isAnchorInside !== isFocusInside;
692 const isWithinTable = isAnchorInside && isFocusInside;
693 const isBackward = selection.isBackward();
695 if (isPartialyWithinTable) {
696 const newSelection = selection.clone();
698 const [tableMap] = $computeTableMap(
703 const firstCell = tableMap[0][0].cell;
704 const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
705 newSelection.focus.set(
706 isBackward ? firstCell.getKey() : lastCell.getKey(),
708 ? firstCell.getChildrenSize()
709 : lastCell.getChildrenSize(),
713 $setSelection(newSelection);
714 $addHighlightStyleToTable(editor, tableObserver);
715 } else if (isWithinTable) {
716 // Handle case when selection spans across multiple cells but still
717 // has range selection, then we convert it into grid selection
718 if (!anchorCellNode.is(focusCellNode)) {
719 tableObserver.setAnchorCellForSelection(
720 getObserverCellFromCellNode(anchorCellNode),
722 tableObserver.setFocusCellForSelection(
723 getObserverCellFromCellNode(focusCellNode),
726 if (!tableObserver.isSelecting) {
728 const {onMouseUp, onMouseMove} = createMouseHandlers();
729 tableObserver.isSelecting = true;
730 editorWindow.addEventListener('mouseup', onMouseUp);
731 editorWindow.addEventListener('mousemove', onMouseMove);
738 $isTableSelection(selection) &&
739 selection.is(prevSelection) &&
740 selection.tableKey === tableNode.getKey()
742 // if selection goes outside of the table we need to change it to Range selection
743 const domSelection = getDOMSelection(editor._window);
746 domSelection.anchorNode &&
747 domSelection.focusNode
749 const focusNode = $getNearestNodeFromDOMNode(
750 domSelection.focusNode,
752 const isFocusOutside =
753 focusNode && !tableNode.is($findTableNode(focusNode));
755 const anchorNode = $getNearestNodeFromDOMNode(
756 domSelection.anchorNode,
758 const isAnchorInside =
759 anchorNode && tableNode.is($findTableNode(anchorNode));
764 domSelection.rangeCount > 0
766 const newSelection = $createRangeSelectionFromDom(
771 newSelection.anchor.set(
773 selection.isBackward() ? tableNode.getChildrenSize() : 0,
776 domSelection.removeAllRanges();
777 $setSelection(newSelection);
785 !selection.is(prevSelection) &&
786 ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
787 tableObserver.tableSelection &&
788 !tableObserver.tableSelection.is(prevSelection)
791 $isTableSelection(selection) &&
792 selection.tableKey === tableObserver.tableNodeKey
794 tableObserver.updateTableTableSelection(selection);
796 !$isTableSelection(selection) &&
797 $isTableSelection(prevSelection) &&
798 prevSelection.tableKey === tableObserver.tableNodeKey
800 tableObserver.updateTableTableSelection(null);
806 tableObserver.hasHijackedSelectionStyles &&
807 !tableNode.isSelected()
809 $removeHighlightStyleToTable(editor, tableObserver);
811 !tableObserver.hasHijackedSelectionStyles &&
812 tableNode.isSelected()
814 $addHighlightStyleToTable(editor, tableObserver);
819 COMMAND_PRIORITY_CRITICAL,
823 tableObserver.listenersToRemove.add(
824 editor.registerCommand(
825 INSERT_PARAGRAPH_COMMAND,
827 const selection = $getSelection();
829 !$isRangeSelection(selection) ||
830 !selection.isCollapsed() ||
831 !$isSelectionInTable(selection, tableNode)
835 const edgePosition = $getTableEdgeCursorPosition(
841 $insertParagraphAtTableEdge(edgePosition, tableNode);
846 COMMAND_PRIORITY_CRITICAL,
850 return tableObserver;
853 export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
854 Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
856 export function attachTableObserverToTableElement(
857 tableElement: HTMLTableElementWithWithTableSelectionState,
858 tableObserver: TableObserver,
860 tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
863 export function getTableObserverFromTableElement(
864 tableElement: HTMLTableElementWithWithTableSelectionState,
865 ): TableObserver | null {
866 return tableElement[LEXICAL_ELEMENT_KEY];
869 export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
870 let currentNode: ParentNode | Node | null = node;
872 while (currentNode != null) {
873 const nodeName = currentNode.nodeName;
875 if (nodeName === 'TD' || nodeName === 'TH') {
876 // @ts-expect-error: internal field
877 const cell = currentNode._cell;
879 if (cell === undefined) {
886 currentNode = currentNode.parentNode;
892 export function doesTargetContainText(node: Node): boolean {
893 const currentNode: ParentNode | Node | null = node;
895 if (currentNode !== null) {
896 const nodeName = currentNode.nodeName;
898 if (nodeName === 'SPAN') {
905 export function getTable(tableElement: HTMLElement): TableDOMTable {
906 const domRows: TableDOMRows = [];
912 let currentNode = tableElement.firstChild;
917 while (currentNode != null) {
918 const nodeMame = currentNode.nodeName;
920 if (nodeMame === 'TD' || nodeMame === 'TH') {
921 const elem = currentNode as HTMLElement;
924 hasBackgroundColor: elem.style.backgroundColor !== '',
930 // @ts-expect-error: internal field
931 currentNode._cell = cell;
933 let row = domRows[y];
934 if (row === undefined) {
935 row = domRows[y] = [];
940 const child = currentNode.firstChild;
948 const sibling = currentNode.nextSibling;
950 if (sibling != null) {
952 currentNode = sibling;
956 const parent = currentNode.parentNode;
958 if (parent != null) {
959 const parentSibling = parent.nextSibling;
961 if (parentSibling == null) {
967 currentNode = parentSibling;
971 grid.columns = x + 1;
977 export function $updateDOMForSelection(
978 editor: LexicalEditor,
979 table: TableDOMTable,
980 selection: TableSelection | RangeSelection | null,
982 const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
983 $forEachTableCell(table, (cell, lexicalNode) => {
984 const elem = cell.elem;
986 if (selectedCellNodes.has(lexicalNode)) {
987 cell.highlighted = true;
988 $addHighlightToDOM(editor, cell);
990 cell.highlighted = false;
991 $removeHighlightFromDOM(editor, cell);
992 if (!elem.getAttribute('style')) {
993 elem.removeAttribute('style');
999 export function $forEachTableCell(
1000 grid: TableDOMTable,
1003 lexicalNode: LexicalNode,
1010 const {domRows} = grid;
1012 for (let y = 0; y < domRows.length; y++) {
1013 const row = domRows[y];
1018 for (let x = 0; x < row.length; x++) {
1019 const cell = row[x];
1023 const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1025 if (lexicalNode !== null) {
1026 cb(cell, lexicalNode, {
1035 export function $addHighlightStyleToTable(
1036 editor: LexicalEditor,
1037 tableSelection: TableObserver,
1039 tableSelection.disableHighlightStyle();
1040 $forEachTableCell(tableSelection.table, (cell) => {
1041 cell.highlighted = true;
1042 $addHighlightToDOM(editor, cell);
1046 export function $removeHighlightStyleToTable(
1047 editor: LexicalEditor,
1048 tableObserver: TableObserver,
1050 tableObserver.enableHighlightStyle();
1051 $forEachTableCell(tableObserver.table, (cell) => {
1052 const elem = cell.elem;
1053 cell.highlighted = false;
1054 $removeHighlightFromDOM(editor, cell);
1056 if (!elem.getAttribute('style')) {
1057 elem.removeAttribute('style');
1062 type Direction = 'backward' | 'forward' | 'up' | 'down';
1064 const selectTableNodeInDirection = (
1065 tableObserver: TableObserver,
1066 tableNode: TableNode,
1069 direction: Direction,
1071 const isForward = direction === 'forward';
1073 switch (direction) {
1076 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1077 selectTableCellNode(
1078 tableNode.getCellNodeFromCordsOrThrow(
1079 x + (isForward ? 1 : -1),
1081 tableObserver.table,
1086 if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1087 selectTableCellNode(
1088 tableNode.getCellNodeFromCordsOrThrow(
1089 isForward ? 0 : tableObserver.table.columns - 1,
1090 y + (isForward ? 1 : -1),
1091 tableObserver.table,
1095 } else if (!isForward) {
1096 tableNode.selectPrevious();
1098 tableNode.selectNext();
1106 selectTableCellNode(
1107 tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1111 tableNode.selectPrevious();
1117 if (y !== tableObserver.table.rows - 1) {
1118 selectTableCellNode(
1119 tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1123 tableNode.selectNext();
1132 const adjustFocusNodeInDirection = (
1133 tableObserver: TableObserver,
1134 tableNode: TableNode,
1137 direction: Direction,
1139 const isForward = direction === 'forward';
1141 switch (direction) {
1144 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1145 tableObserver.setFocusCellForSelection(
1146 tableNode.getDOMCellFromCordsOrThrow(
1147 x + (isForward ? 1 : -1),
1149 tableObserver.table,
1157 tableObserver.setFocusCellForSelection(
1158 tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1166 if (y !== tableObserver.table.rows - 1) {
1167 tableObserver.setFocusCellForSelection(
1168 tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1180 function $isSelectionInTable(
1181 selection: null | BaseSelection,
1182 tableNode: TableNode,
1184 if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1185 const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1186 const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1188 return isAnchorInside && isFocusInside;
1194 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1196 tableCell.selectStart();
1198 tableCell.selectEnd();
1202 const BROWSER_BLUE_RGB = '172,206,247';
1203 function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1204 const element = cell.elem;
1205 const node = $getNearestNodeFromDOMNode(element);
1207 $isTableCellNode(node),
1208 'Expected to find LexicalNode from Table Cell DOMNode',
1210 const backgroundColor = node.getBackgroundColor();
1211 if (backgroundColor === null) {
1212 element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1214 element.style.setProperty(
1216 `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1219 element.style.setProperty('caret-color', 'transparent');
1222 function $removeHighlightFromDOM(
1223 editor: LexicalEditor,
1226 const element = cell.elem;
1227 const node = $getNearestNodeFromDOMNode(element);
1229 $isTableCellNode(node),
1230 'Expected to find LexicalNode from Table Cell DOMNode',
1232 const backgroundColor = node.getBackgroundColor();
1233 if (backgroundColor === null) {
1234 element.style.removeProperty('background-color');
1236 element.style.removeProperty('background-image');
1237 element.style.removeProperty('caret-color');
1240 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1241 const cellNode = $findMatchingParent(node, $isTableCellNode);
1242 return $isTableCellNode(cellNode) ? cellNode : null;
1245 export function $findTableNode(node: LexicalNode): null | TableNode {
1246 const tableNode = $findMatchingParent(node, $isTableNode);
1247 return $isTableNode(tableNode) ? tableNode : null;
1250 function $handleArrowKey(
1251 editor: LexicalEditor,
1252 event: KeyboardEvent,
1253 direction: Direction,
1254 tableNode: TableNode,
1255 tableObserver: TableObserver,
1258 (direction === 'up' || direction === 'down') &&
1259 isTypeaheadMenuInView(editor)
1264 const selection = $getSelection();
1266 if (!$isSelectionInTable(selection, tableNode)) {
1267 if ($isRangeSelection(selection)) {
1268 if (selection.isCollapsed() && direction === 'backward') {
1269 const anchorType = selection.anchor.type;
1270 const anchorOffset = selection.anchor.offset;
1272 anchorType !== 'element' &&
1273 !(anchorType === 'text' && anchorOffset === 0)
1277 const anchorNode = selection.anchor.getNode();
1281 const parentNode = $findMatchingParent(
1283 (n) => $isElementNode(n) && !n.isInline(),
1288 const siblingNode = parentNode.getPreviousSibling();
1289 if (!siblingNode || !$isTableNode(siblingNode)) {
1293 siblingNode.selectEnd();
1297 (direction === 'up' || direction === 'down')
1299 const focusNode = selection.focus.getNode();
1300 if ($isRootOrShadowRoot(focusNode)) {
1301 const selectedNode = selection.getNodes()[0];
1303 const tableCellNode = $findMatchingParent(
1307 if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1308 const firstDescendant = tableNode.getFirstDescendant();
1309 const lastDescendant = tableNode.getLastDescendant();
1310 if (!firstDescendant || !lastDescendant) {
1313 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1314 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1315 const firstCellCoords = tableNode.getCordsFromCellNode(
1317 tableObserver.table,
1319 const lastCellCoords = tableNode.getCordsFromCellNode(
1321 tableObserver.table,
1323 const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1326 tableObserver.table,
1328 const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1331 tableObserver.table,
1333 tableObserver.setAnchorCellForSelection(firstCellDOM);
1334 tableObserver.setFocusCellForSelection(lastCellDOM, true);
1340 const focusParentNode = $findMatchingParent(
1342 (n) => $isElementNode(n) && !n.isInline(),
1344 if (!focusParentNode) {
1348 direction === 'down'
1349 ? focusParentNode.getNextSibling()
1350 : focusParentNode.getPreviousSibling();
1352 $isTableNode(sibling) &&
1353 tableObserver.tableNodeKey === sibling.getKey()
1355 const firstDescendant = sibling.getFirstDescendant();
1356 const lastDescendant = sibling.getLastDescendant();
1357 if (!firstDescendant || !lastDescendant) {
1360 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1361 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1362 const newSelection = selection.clone();
1363 newSelection.focus.set(
1364 (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
1365 direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
1368 $setSelection(newSelection);
1377 if ($isRangeSelection(selection) && selection.isCollapsed()) {
1378 const {anchor, focus} = selection;
1379 const anchorCellNode = $findMatchingParent(
1383 const focusCellNode = $findMatchingParent(
1388 !$isTableCellNode(anchorCellNode) ||
1389 !anchorCellNode.is(focusCellNode)
1393 const anchorCellTable = $findTableNode(anchorCellNode);
1394 if (anchorCellTable !== tableNode && anchorCellTable != null) {
1395 const anchorCellTableElement = editor.getElementByKey(
1396 anchorCellTable.getKey(),
1398 if (anchorCellTableElement != null) {
1399 tableObserver.table = getTable(anchorCellTableElement);
1400 return $handleArrowKey(
1410 if (direction === 'backward' || direction === 'forward') {
1411 const anchorType = anchor.type;
1412 const anchorOffset = anchor.offset;
1413 const anchorNode = anchor.getNode();
1418 const selectedNodes = selection.getNodes();
1419 if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1424 isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1426 return $handleTableExit(event, anchorNode, tableNode, direction);
1432 const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1433 const anchorDOM = editor.getElementByKey(anchor.key);
1434 if (anchorDOM == null || anchorCellDom == null) {
1438 let edgeSelectionRect;
1439 if (anchor.type === 'element') {
1440 edgeSelectionRect = anchorDOM.getBoundingClientRect();
1442 const domSelection = window.getSelection();
1443 if (domSelection === null || domSelection.rangeCount === 0) {
1447 const range = domSelection.getRangeAt(0);
1448 edgeSelectionRect = range.getBoundingClientRect();
1453 ? anchorCellNode.getFirstChild()
1454 : anchorCellNode.getLastChild();
1455 if (edgeChild == null) {
1459 const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1461 if (edgeChildDOM == null) {
1465 const edgeRect = edgeChildDOM.getBoundingClientRect();
1468 ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1469 : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1474 const cords = tableNode.getCordsFromCellNode(
1476 tableObserver.table,
1479 if (event.shiftKey) {
1480 const cell = tableNode.getDOMCellFromCordsOrThrow(
1483 tableObserver.table,
1485 tableObserver.setAnchorCellForSelection(cell);
1486 tableObserver.setFocusCellForSelection(cell, true);
1488 return selectTableNodeInDirection(
1499 } else if ($isTableSelection(selection)) {
1500 const {anchor, focus} = selection;
1501 const anchorCellNode = $findMatchingParent(
1505 const focusCellNode = $findMatchingParent(
1510 const [tableNodeFromSelection] = selection.getNodes();
1511 const tableElement = editor.getElementByKey(
1512 tableNodeFromSelection.getKey(),
1515 !$isTableCellNode(anchorCellNode) ||
1516 !$isTableCellNode(focusCellNode) ||
1517 !$isTableNode(tableNodeFromSelection) ||
1518 tableElement == null
1522 tableObserver.updateTableTableSelection(selection);
1524 const grid = getTable(tableElement);
1525 const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1526 const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1531 tableObserver.setAnchorCellForSelection(anchorCell);
1535 if (event.shiftKey) {
1536 const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1537 return adjustFocusNodeInDirection(
1539 tableNodeFromSelection,
1545 focusCellNode.selectEnd();
1554 function stopEvent(event: Event) {
1555 event.preventDefault();
1556 event.stopImmediatePropagation();
1557 event.stopPropagation();
1560 function isTypeaheadMenuInView(editor: LexicalEditor) {
1561 // There is no inbuilt way to check if the component picker is in view
1562 // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
1563 const root = editor.getRootElement();
1568 root.hasAttribute('aria-controls') &&
1569 root.getAttribute('aria-controls') === 'typeahead-menu'
1573 function isExitingTableAnchor(
1576 anchorNode: LexicalNode,
1577 direction: 'backward' | 'forward',
1580 isExitingTableElementAnchor(type, anchorNode, direction) ||
1581 $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1585 function isExitingTableElementAnchor(
1587 anchorNode: LexicalNode,
1588 direction: 'backward' | 'forward',
1591 type === 'element' &&
1592 (direction === 'backward'
1593 ? anchorNode.getPreviousSibling() === null
1594 : anchorNode.getNextSibling() === null)
1598 function $isExitingTableTextAnchor(
1601 anchorNode: LexicalNode,
1602 direction: 'backward' | 'forward',
1604 const parentNode = $findMatchingParent(
1606 (n) => $isElementNode(n) && !n.isInline(),
1611 const hasValidOffset =
1612 direction === 'backward'
1614 : offset === anchorNode.getTextContentSize();
1618 (direction === 'backward'
1619 ? parentNode.getPreviousSibling() === null
1620 : parentNode.getNextSibling() === null)
1624 function $handleTableExit(
1625 event: KeyboardEvent,
1626 anchorNode: LexicalNode,
1627 tableNode: TableNode,
1628 direction: 'backward' | 'forward',
1630 const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1631 if (!$isTableCellNode(anchorCellNode)) {
1634 const [tableMap, cellValue] = $computeTableMap(
1639 if (!isExitingCell(tableMap, cellValue, direction)) {
1643 const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1644 if (!toNode || $isTableNode(toNode)) {
1649 if (direction === 'backward') {
1652 toNode.selectStart();
1657 function isExitingCell(
1658 tableMap: TableMapType,
1659 cellValue: TableMapValueType,
1660 direction: 'backward' | 'forward',
1662 const firstCell = tableMap[0][0];
1663 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1664 const {startColumn, startRow} = cellValue;
1665 return direction === 'backward'
1666 ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
1667 : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
1670 function $getExitingToNode(
1671 anchorNode: LexicalNode,
1672 direction: 'backward' | 'forward',
1673 tableNode: TableNode,
1675 const parentNode = $findMatchingParent(
1677 (n) => $isElementNode(n) && !n.isInline(),
1682 const anchorSibling =
1683 direction === 'backward'
1684 ? parentNode.getPreviousSibling()
1685 : parentNode.getNextSibling();
1686 return anchorSibling && $isTableNode(anchorSibling)
1688 : direction === 'backward'
1689 ? tableNode.getPreviousSibling()
1690 : tableNode.getNextSibling();
1693 function $insertParagraphAtTableEdge(
1694 edgePosition: 'first' | 'last',
1695 tableNode: TableNode,
1696 children?: LexicalNode[],
1698 const paragraphNode = $createParagraphNode();
1699 if (edgePosition === 'first') {
1700 tableNode.insertBefore(paragraphNode);
1702 tableNode.insertAfter(paragraphNode);
1704 paragraphNode.append(...(children || []));
1705 paragraphNode.selectEnd();
1708 function $getTableEdgeCursorPosition(
1709 editor: LexicalEditor,
1710 selection: RangeSelection,
1711 tableNode: TableNode,
1713 const tableNodeParent = tableNode.getParent();
1714 if (!tableNodeParent) {
1718 const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1719 if (!tableNodeParentDOM) {
1723 // TODO: Add support for nested tables
1724 const domSelection = window.getSelection();
1725 if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1729 const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1730 $isTableCellNode(n),
1731 ) as TableCellNode | null;
1732 if (!anchorCellNode) {
1736 const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1739 if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1743 const [tableMap, cellValue] = $computeTableMap(
1748 const firstCell = tableMap[0][0];
1749 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1750 const {startRow, startColumn} = cellValue;
1752 const isAtFirstCell =
1753 startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1754 const isAtLastCell =
1755 startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1757 if (isAtFirstCell) {
1759 } else if (isAtLastCell) {