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 === 'COLGROUP') {
921 currentNode = currentNode.nextSibling;
925 if (nodeMame === 'TD' || nodeMame === 'TH') {
926 const elem = currentNode as HTMLElement;
929 hasBackgroundColor: elem.style.backgroundColor !== '',
935 // @ts-expect-error: internal field
936 currentNode._cell = cell;
938 let row = domRows[y];
939 if (row === undefined) {
940 row = domRows[y] = [];
945 const child = currentNode.firstChild;
953 const sibling = currentNode.nextSibling;
955 if (sibling != null) {
957 currentNode = sibling;
961 const parent = currentNode.parentNode;
963 if (parent != null) {
964 const parentSibling = parent.nextSibling;
966 if (parentSibling == null) {
972 currentNode = parentSibling;
976 grid.columns = x + 1;
982 export function $updateDOMForSelection(
983 editor: LexicalEditor,
984 table: TableDOMTable,
985 selection: TableSelection | RangeSelection | null,
987 const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
988 $forEachTableCell(table, (cell, lexicalNode) => {
989 const elem = cell.elem;
991 if (selectedCellNodes.has(lexicalNode)) {
992 cell.highlighted = true;
993 $addHighlightToDOM(editor, cell);
995 cell.highlighted = false;
996 $removeHighlightFromDOM(editor, cell);
997 if (!elem.getAttribute('style')) {
998 elem.removeAttribute('style');
1004 export function $forEachTableCell(
1005 grid: TableDOMTable,
1008 lexicalNode: LexicalNode,
1015 const {domRows} = grid;
1017 for (let y = 0; y < domRows.length; y++) {
1018 const row = domRows[y];
1023 for (let x = 0; x < row.length; x++) {
1024 const cell = row[x];
1028 const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1030 if (lexicalNode !== null) {
1031 cb(cell, lexicalNode, {
1040 export function $addHighlightStyleToTable(
1041 editor: LexicalEditor,
1042 tableSelection: TableObserver,
1044 tableSelection.disableHighlightStyle();
1045 $forEachTableCell(tableSelection.table, (cell) => {
1046 cell.highlighted = true;
1047 $addHighlightToDOM(editor, cell);
1051 export function $removeHighlightStyleToTable(
1052 editor: LexicalEditor,
1053 tableObserver: TableObserver,
1055 tableObserver.enableHighlightStyle();
1056 $forEachTableCell(tableObserver.table, (cell) => {
1057 const elem = cell.elem;
1058 cell.highlighted = false;
1059 $removeHighlightFromDOM(editor, cell);
1061 if (!elem.getAttribute('style')) {
1062 elem.removeAttribute('style');
1067 type Direction = 'backward' | 'forward' | 'up' | 'down';
1069 const selectTableNodeInDirection = (
1070 tableObserver: TableObserver,
1071 tableNode: TableNode,
1074 direction: Direction,
1076 const isForward = direction === 'forward';
1078 switch (direction) {
1081 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1082 selectTableCellNode(
1083 tableNode.getCellNodeFromCordsOrThrow(
1084 x + (isForward ? 1 : -1),
1086 tableObserver.table,
1091 if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1092 selectTableCellNode(
1093 tableNode.getCellNodeFromCordsOrThrow(
1094 isForward ? 0 : tableObserver.table.columns - 1,
1095 y + (isForward ? 1 : -1),
1096 tableObserver.table,
1100 } else if (!isForward) {
1101 tableNode.selectPrevious();
1103 tableNode.selectNext();
1111 selectTableCellNode(
1112 tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1116 tableNode.selectPrevious();
1122 if (y !== tableObserver.table.rows - 1) {
1123 selectTableCellNode(
1124 tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1128 tableNode.selectNext();
1137 const adjustFocusNodeInDirection = (
1138 tableObserver: TableObserver,
1139 tableNode: TableNode,
1142 direction: Direction,
1144 const isForward = direction === 'forward';
1146 switch (direction) {
1149 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1150 tableObserver.setFocusCellForSelection(
1151 tableNode.getDOMCellFromCordsOrThrow(
1152 x + (isForward ? 1 : -1),
1154 tableObserver.table,
1162 tableObserver.setFocusCellForSelection(
1163 tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1171 if (y !== tableObserver.table.rows - 1) {
1172 tableObserver.setFocusCellForSelection(
1173 tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1185 function $isSelectionInTable(
1186 selection: null | BaseSelection,
1187 tableNode: TableNode,
1189 if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1190 const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1191 const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1193 return isAnchorInside && isFocusInside;
1199 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1201 tableCell.selectStart();
1203 tableCell.selectEnd();
1207 const BROWSER_BLUE_RGB = '172,206,247';
1208 function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1209 const element = cell.elem;
1210 const node = $getNearestNodeFromDOMNode(element);
1212 $isTableCellNode(node),
1213 'Expected to find LexicalNode from Table Cell DOMNode',
1215 const backgroundColor = node.getBackgroundColor();
1216 if (backgroundColor === null) {
1217 element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1219 element.style.setProperty(
1221 `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1224 element.style.setProperty('caret-color', 'transparent');
1227 function $removeHighlightFromDOM(
1228 editor: LexicalEditor,
1231 const element = cell.elem;
1232 const node = $getNearestNodeFromDOMNode(element);
1234 $isTableCellNode(node),
1235 'Expected to find LexicalNode from Table Cell DOMNode',
1237 const backgroundColor = node.getBackgroundColor();
1238 if (backgroundColor === null) {
1239 element.style.removeProperty('background-color');
1241 element.style.removeProperty('background-image');
1242 element.style.removeProperty('caret-color');
1245 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1246 const cellNode = $findMatchingParent(node, $isTableCellNode);
1247 return $isTableCellNode(cellNode) ? cellNode : null;
1250 export function $findTableNode(node: LexicalNode): null | TableNode {
1251 const tableNode = $findMatchingParent(node, $isTableNode);
1252 return $isTableNode(tableNode) ? tableNode : null;
1255 function $handleArrowKey(
1256 editor: LexicalEditor,
1257 event: KeyboardEvent,
1258 direction: Direction,
1259 tableNode: TableNode,
1260 tableObserver: TableObserver,
1263 (direction === 'up' || direction === 'down') &&
1264 isTypeaheadMenuInView(editor)
1269 const selection = $getSelection();
1271 if (!$isSelectionInTable(selection, tableNode)) {
1272 if ($isRangeSelection(selection)) {
1273 if (selection.isCollapsed() && direction === 'backward') {
1274 const anchorType = selection.anchor.type;
1275 const anchorOffset = selection.anchor.offset;
1277 anchorType !== 'element' &&
1278 !(anchorType === 'text' && anchorOffset === 0)
1282 const anchorNode = selection.anchor.getNode();
1286 const parentNode = $findMatchingParent(
1288 (n) => $isElementNode(n) && !n.isInline(),
1293 const siblingNode = parentNode.getPreviousSibling();
1294 if (!siblingNode || !$isTableNode(siblingNode)) {
1298 siblingNode.selectEnd();
1302 (direction === 'up' || direction === 'down')
1304 const focusNode = selection.focus.getNode();
1305 if ($isRootOrShadowRoot(focusNode)) {
1306 const selectedNode = selection.getNodes()[0];
1308 const tableCellNode = $findMatchingParent(
1312 if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1313 const firstDescendant = tableNode.getFirstDescendant();
1314 const lastDescendant = tableNode.getLastDescendant();
1315 if (!firstDescendant || !lastDescendant) {
1318 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1319 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1320 const firstCellCoords = tableNode.getCordsFromCellNode(
1322 tableObserver.table,
1324 const lastCellCoords = tableNode.getCordsFromCellNode(
1326 tableObserver.table,
1328 const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1331 tableObserver.table,
1333 const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1336 tableObserver.table,
1338 tableObserver.setAnchorCellForSelection(firstCellDOM);
1339 tableObserver.setFocusCellForSelection(lastCellDOM, true);
1345 const focusParentNode = $findMatchingParent(
1347 (n) => $isElementNode(n) && !n.isInline(),
1349 if (!focusParentNode) {
1353 direction === 'down'
1354 ? focusParentNode.getNextSibling()
1355 : focusParentNode.getPreviousSibling();
1357 $isTableNode(sibling) &&
1358 tableObserver.tableNodeKey === sibling.getKey()
1360 const firstDescendant = sibling.getFirstDescendant();
1361 const lastDescendant = sibling.getLastDescendant();
1362 if (!firstDescendant || !lastDescendant) {
1365 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1366 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1367 const newSelection = selection.clone();
1368 newSelection.focus.set(
1369 (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
1370 direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
1373 $setSelection(newSelection);
1382 if ($isRangeSelection(selection) && selection.isCollapsed()) {
1383 const {anchor, focus} = selection;
1384 const anchorCellNode = $findMatchingParent(
1388 const focusCellNode = $findMatchingParent(
1393 !$isTableCellNode(anchorCellNode) ||
1394 !anchorCellNode.is(focusCellNode)
1398 const anchorCellTable = $findTableNode(anchorCellNode);
1399 if (anchorCellTable !== tableNode && anchorCellTable != null) {
1400 const anchorCellTableElement = editor.getElementByKey(
1401 anchorCellTable.getKey(),
1403 if (anchorCellTableElement != null) {
1404 tableObserver.table = getTable(anchorCellTableElement);
1405 return $handleArrowKey(
1415 if (direction === 'backward' || direction === 'forward') {
1416 const anchorType = anchor.type;
1417 const anchorOffset = anchor.offset;
1418 const anchorNode = anchor.getNode();
1423 const selectedNodes = selection.getNodes();
1424 if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1429 isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1431 return $handleTableExit(event, anchorNode, tableNode, direction);
1437 const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1438 const anchorDOM = editor.getElementByKey(anchor.key);
1439 if (anchorDOM == null || anchorCellDom == null) {
1443 let edgeSelectionRect;
1444 if (anchor.type === 'element') {
1445 edgeSelectionRect = anchorDOM.getBoundingClientRect();
1447 const domSelection = window.getSelection();
1448 if (domSelection === null || domSelection.rangeCount === 0) {
1452 const range = domSelection.getRangeAt(0);
1453 edgeSelectionRect = range.getBoundingClientRect();
1458 ? anchorCellNode.getFirstChild()
1459 : anchorCellNode.getLastChild();
1460 if (edgeChild == null) {
1464 const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1466 if (edgeChildDOM == null) {
1470 const edgeRect = edgeChildDOM.getBoundingClientRect();
1473 ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1474 : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1479 const cords = tableNode.getCordsFromCellNode(
1481 tableObserver.table,
1484 if (event.shiftKey) {
1485 const cell = tableNode.getDOMCellFromCordsOrThrow(
1488 tableObserver.table,
1490 tableObserver.setAnchorCellForSelection(cell);
1491 tableObserver.setFocusCellForSelection(cell, true);
1493 return selectTableNodeInDirection(
1504 } else if ($isTableSelection(selection)) {
1505 const {anchor, focus} = selection;
1506 const anchorCellNode = $findMatchingParent(
1510 const focusCellNode = $findMatchingParent(
1515 const [tableNodeFromSelection] = selection.getNodes();
1516 const tableElement = editor.getElementByKey(
1517 tableNodeFromSelection.getKey(),
1520 !$isTableCellNode(anchorCellNode) ||
1521 !$isTableCellNode(focusCellNode) ||
1522 !$isTableNode(tableNodeFromSelection) ||
1523 tableElement == null
1527 tableObserver.updateTableTableSelection(selection);
1529 const grid = getTable(tableElement);
1530 const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1531 const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1536 tableObserver.setAnchorCellForSelection(anchorCell);
1540 if (event.shiftKey) {
1541 const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1542 return adjustFocusNodeInDirection(
1544 tableNodeFromSelection,
1550 focusCellNode.selectEnd();
1559 function stopEvent(event: Event) {
1560 event.preventDefault();
1561 event.stopImmediatePropagation();
1562 event.stopPropagation();
1565 function isTypeaheadMenuInView(editor: LexicalEditor) {
1566 // There is no inbuilt way to check if the component picker is in view
1567 // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
1568 const root = editor.getRootElement();
1573 root.hasAttribute('aria-controls') &&
1574 root.getAttribute('aria-controls') === 'typeahead-menu'
1578 function isExitingTableAnchor(
1581 anchorNode: LexicalNode,
1582 direction: 'backward' | 'forward',
1585 isExitingTableElementAnchor(type, anchorNode, direction) ||
1586 $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1590 function isExitingTableElementAnchor(
1592 anchorNode: LexicalNode,
1593 direction: 'backward' | 'forward',
1596 type === 'element' &&
1597 (direction === 'backward'
1598 ? anchorNode.getPreviousSibling() === null
1599 : anchorNode.getNextSibling() === null)
1603 function $isExitingTableTextAnchor(
1606 anchorNode: LexicalNode,
1607 direction: 'backward' | 'forward',
1609 const parentNode = $findMatchingParent(
1611 (n) => $isElementNode(n) && !n.isInline(),
1616 const hasValidOffset =
1617 direction === 'backward'
1619 : offset === anchorNode.getTextContentSize();
1623 (direction === 'backward'
1624 ? parentNode.getPreviousSibling() === null
1625 : parentNode.getNextSibling() === null)
1629 function $handleTableExit(
1630 event: KeyboardEvent,
1631 anchorNode: LexicalNode,
1632 tableNode: TableNode,
1633 direction: 'backward' | 'forward',
1635 const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1636 if (!$isTableCellNode(anchorCellNode)) {
1639 const [tableMap, cellValue] = $computeTableMap(
1644 if (!isExitingCell(tableMap, cellValue, direction)) {
1648 const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1649 if (!toNode || $isTableNode(toNode)) {
1654 if (direction === 'backward') {
1657 toNode.selectStart();
1662 function isExitingCell(
1663 tableMap: TableMapType,
1664 cellValue: TableMapValueType,
1665 direction: 'backward' | 'forward',
1667 const firstCell = tableMap[0][0];
1668 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1669 const {startColumn, startRow} = cellValue;
1670 return direction === 'backward'
1671 ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
1672 : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
1675 function $getExitingToNode(
1676 anchorNode: LexicalNode,
1677 direction: 'backward' | 'forward',
1678 tableNode: TableNode,
1680 const parentNode = $findMatchingParent(
1682 (n) => $isElementNode(n) && !n.isInline(),
1687 const anchorSibling =
1688 direction === 'backward'
1689 ? parentNode.getPreviousSibling()
1690 : parentNode.getNextSibling();
1691 return anchorSibling && $isTableNode(anchorSibling)
1693 : direction === 'backward'
1694 ? tableNode.getPreviousSibling()
1695 : tableNode.getNextSibling();
1698 function $insertParagraphAtTableEdge(
1699 edgePosition: 'first' | 'last',
1700 tableNode: TableNode,
1701 children?: LexicalNode[],
1703 const paragraphNode = $createParagraphNode();
1704 if (edgePosition === 'first') {
1705 tableNode.insertBefore(paragraphNode);
1707 tableNode.insertAfter(paragraphNode);
1709 paragraphNode.append(...(children || []));
1710 paragraphNode.selectEnd();
1713 function $getTableEdgeCursorPosition(
1714 editor: LexicalEditor,
1715 selection: RangeSelection,
1716 tableNode: TableNode,
1718 const tableNodeParent = tableNode.getParent();
1719 if (!tableNodeParent) {
1723 const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1724 if (!tableNodeParentDOM) {
1728 // TODO: Add support for nested tables
1729 const domSelection = window.getSelection();
1730 if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1734 const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1735 $isTableCellNode(n),
1736 ) as TableCellNode | null;
1737 if (!anchorCellNode) {
1741 const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1744 if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1748 const [tableMap, cellValue] = $computeTableMap(
1753 const firstCell = tableMap[0][0];
1754 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1755 const {startRow, startColumn} = cellValue;
1757 const isAtFirstCell =
1758 startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1759 const isAtLastCell =
1760 startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1762 if (isAtFirstCell) {
1764 } else if (isAtLastCell) {