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<ElementFormatType>(
443 FORMAT_ELEMENT_COMMAND,
445 const selection = $getSelection();
447 !$isTableSelection(selection) ||
448 !$isSelectionInTable(selection, tableNode)
453 const anchorNode = selection.anchor.getNode();
454 const focusNode = selection.focus.getNode();
455 if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) {
459 const [tableMap, anchorCell, focusCell] = $computeTableMap(
464 const maxRow = Math.max(anchorCell.startRow, focusCell.startRow);
465 const maxColumn = Math.max(
466 anchorCell.startColumn,
467 focusCell.startColumn,
469 const minRow = Math.min(anchorCell.startRow, focusCell.startRow);
470 const minColumn = Math.min(
471 anchorCell.startColumn,
472 focusCell.startColumn,
474 for (let i = minRow; i <= maxRow; i++) {
475 for (let j = minColumn; j <= maxColumn; j++) {
476 const cell = tableMap[i][j].cell;
477 cell.setFormat(formatType);
479 const cellChildren = cell.getChildren();
480 for (let k = 0; k < cellChildren.length; k++) {
481 const child = cellChildren[k];
482 if ($isElementNode(child) && !child.isInline()) {
483 child.setFormat(formatType);
490 COMMAND_PRIORITY_CRITICAL,
494 tableObserver.listenersToRemove.add(
495 editor.registerCommand(
496 CONTROLLED_TEXT_INSERTION_COMMAND,
498 const selection = $getSelection();
500 if (!$isSelectionInTable(selection, tableNode)) {
504 if ($isTableSelection(selection)) {
505 tableObserver.clearHighlight();
508 } else if ($isRangeSelection(selection)) {
509 const tableCellNode = $findMatchingParent(
510 selection.anchor.getNode(),
511 (n) => $isTableCellNode(n),
514 if (!$isTableCellNode(tableCellNode)) {
518 if (typeof payload === 'string') {
519 const edgePosition = $getTableEdgeCursorPosition(
525 $insertParagraphAtTableEdge(edgePosition, tableNode, [
526 $createTextNode(payload),
535 COMMAND_PRIORITY_CRITICAL,
540 tableObserver.listenersToRemove.add(
541 editor.registerCommand<KeyboardEvent>(
544 const selection = $getSelection();
546 !$isRangeSelection(selection) ||
547 !selection.isCollapsed() ||
548 !$isSelectionInTable(selection, tableNode)
553 const tableCellNode = $findCellNode(selection.anchor.getNode());
554 if (tableCellNode === null) {
560 const currentCords = tableNode.getCordsFromCellNode(
565 selectTableNodeInDirection(
570 !event.shiftKey ? 'forward' : 'backward',
575 COMMAND_PRIORITY_CRITICAL,
580 tableObserver.listenersToRemove.add(
581 editor.registerCommand(
584 return tableNode.isSelected();
586 COMMAND_PRIORITY_HIGH,
590 function getObserverCellFromCellNode(
591 tableCellNode: TableCellNode,
593 const currentCords = tableNode.getCordsFromCellNode(
597 return tableNode.getDOMCellFromCordsOrThrow(
604 tableObserver.listenersToRemove.add(
605 editor.registerCommand(
606 SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
607 (selectionPayload) => {
608 const {nodes, selection} = selectionPayload;
609 const anchorAndFocus = selection.getStartEndPoints();
610 const isTableSelection = $isTableSelection(selection);
611 const isRangeSelection = $isRangeSelection(selection);
612 const isSelectionInsideOfGrid =
614 $findMatchingParent(selection.anchor.getNode(), (n) =>
617 $findMatchingParent(selection.focus.getNode(), (n) =>
623 nodes.length !== 1 ||
624 !$isTableNode(nodes[0]) ||
625 !isSelectionInsideOfGrid ||
626 anchorAndFocus === null
630 const [anchor] = anchorAndFocus;
632 const newGrid = nodes[0];
633 const newGridRows = newGrid.getChildren();
634 const newColumnCount = newGrid
635 .getFirstChildOrThrow<TableNode>()
637 const newRowCount = newGrid.getChildrenSize();
638 const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
643 $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
646 $findMatchingParent(gridRowNode, (n) => $isTableNode(n));
649 !$isTableCellNode(gridCellNode) ||
650 !$isTableRowNode(gridRowNode) ||
651 !$isTableNode(gridNode)
656 const startY = gridRowNode.getIndexWithinParent();
657 const stopY = Math.min(
658 gridNode.getChildrenSize() - 1,
659 startY + newRowCount - 1,
661 const startX = gridCellNode.getIndexWithinParent();
662 const stopX = Math.min(
663 gridRowNode.getChildrenSize() - 1,
664 startX + newColumnCount - 1,
666 const fromX = Math.min(startX, stopX);
667 const fromY = Math.min(startY, stopY);
668 const toX = Math.max(startX, stopX);
669 const toY = Math.max(startY, stopY);
670 const gridRowNodes = gridNode.getChildren();
673 for (let r = fromY; r <= toY; r++) {
674 const currentGridRowNode = gridRowNodes[r];
676 if (!$isTableRowNode(currentGridRowNode)) {
680 const newGridRowNode = newGridRows[newRowIdx];
682 if (!$isTableRowNode(newGridRowNode)) {
686 const gridCellNodes = currentGridRowNode.getChildren();
687 const newGridCellNodes = newGridRowNode.getChildren();
688 let newColumnIdx = 0;
690 for (let c = fromX; c <= toX; c++) {
691 const currentGridCellNode = gridCellNodes[c];
693 if (!$isTableCellNode(currentGridCellNode)) {
697 const newGridCellNode = newGridCellNodes[newColumnIdx];
699 if (!$isTableCellNode(newGridCellNode)) {
703 const originalChildren = currentGridCellNode.getChildren();
704 newGridCellNode.getChildren().forEach((child) => {
705 if ($isTextNode(child)) {
706 const paragraphNode = $createParagraphNode();
707 paragraphNode.append(child);
708 currentGridCellNode.append(child);
710 currentGridCellNode.append(child);
713 originalChildren.forEach((n) => n.remove());
721 COMMAND_PRIORITY_CRITICAL,
725 tableObserver.listenersToRemove.add(
726 editor.registerCommand(
727 SELECTION_CHANGE_COMMAND,
729 const selection = $getSelection();
730 const prevSelection = $getPreviousSelection();
732 if ($isRangeSelection(selection)) {
733 const {anchor, focus} = selection;
734 const anchorNode = anchor.getNode();
735 const focusNode = focus.getNode();
736 // Using explicit comparison with table node to ensure it's not a nested table
737 // as in that case we'll leave selection resolving to that table
738 const anchorCellNode = $findCellNode(anchorNode);
739 const focusCellNode = $findCellNode(focusNode);
740 const isAnchorInside = !!(
741 anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
743 const isFocusInside = !!(
744 focusCellNode && tableNode.is($findTableNode(focusCellNode))
746 const isPartialyWithinTable = isAnchorInside !== isFocusInside;
747 const isWithinTable = isAnchorInside && isFocusInside;
748 const isBackward = selection.isBackward();
750 if (isPartialyWithinTable) {
751 const newSelection = selection.clone();
753 const [tableMap] = $computeTableMap(
758 const firstCell = tableMap[0][0].cell;
759 const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
760 newSelection.focus.set(
761 isBackward ? firstCell.getKey() : lastCell.getKey(),
763 ? firstCell.getChildrenSize()
764 : lastCell.getChildrenSize(),
768 $setSelection(newSelection);
769 $addHighlightStyleToTable(editor, tableObserver);
770 } else if (isWithinTable) {
771 // Handle case when selection spans across multiple cells but still
772 // has range selection, then we convert it into grid selection
773 if (!anchorCellNode.is(focusCellNode)) {
774 tableObserver.setAnchorCellForSelection(
775 getObserverCellFromCellNode(anchorCellNode),
777 tableObserver.setFocusCellForSelection(
778 getObserverCellFromCellNode(focusCellNode),
781 if (!tableObserver.isSelecting) {
783 const {onMouseUp, onMouseMove} = createMouseHandlers();
784 tableObserver.isSelecting = true;
785 editorWindow.addEventListener('mouseup', onMouseUp);
786 editorWindow.addEventListener('mousemove', onMouseMove);
793 $isTableSelection(selection) &&
794 selection.is(prevSelection) &&
795 selection.tableKey === tableNode.getKey()
797 // if selection goes outside of the table we need to change it to Range selection
798 const domSelection = getDOMSelection(editor._window);
801 domSelection.anchorNode &&
802 domSelection.focusNode
804 const focusNode = $getNearestNodeFromDOMNode(
805 domSelection.focusNode,
807 const isFocusOutside =
808 focusNode && !tableNode.is($findTableNode(focusNode));
810 const anchorNode = $getNearestNodeFromDOMNode(
811 domSelection.anchorNode,
813 const isAnchorInside =
814 anchorNode && tableNode.is($findTableNode(anchorNode));
819 domSelection.rangeCount > 0
821 const newSelection = $createRangeSelectionFromDom(
826 newSelection.anchor.set(
828 selection.isBackward() ? tableNode.getChildrenSize() : 0,
831 domSelection.removeAllRanges();
832 $setSelection(newSelection);
840 !selection.is(prevSelection) &&
841 ($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
842 tableObserver.tableSelection &&
843 !tableObserver.tableSelection.is(prevSelection)
846 $isTableSelection(selection) &&
847 selection.tableKey === tableObserver.tableNodeKey
849 tableObserver.updateTableTableSelection(selection);
851 !$isTableSelection(selection) &&
852 $isTableSelection(prevSelection) &&
853 prevSelection.tableKey === tableObserver.tableNodeKey
855 tableObserver.updateTableTableSelection(null);
861 tableObserver.hasHijackedSelectionStyles &&
862 !tableNode.isSelected()
864 $removeHighlightStyleToTable(editor, tableObserver);
866 !tableObserver.hasHijackedSelectionStyles &&
867 tableNode.isSelected()
869 $addHighlightStyleToTable(editor, tableObserver);
874 COMMAND_PRIORITY_CRITICAL,
878 tableObserver.listenersToRemove.add(
879 editor.registerCommand(
880 INSERT_PARAGRAPH_COMMAND,
882 const selection = $getSelection();
884 !$isRangeSelection(selection) ||
885 !selection.isCollapsed() ||
886 !$isSelectionInTable(selection, tableNode)
890 const edgePosition = $getTableEdgeCursorPosition(
896 $insertParagraphAtTableEdge(edgePosition, tableNode);
901 COMMAND_PRIORITY_CRITICAL,
905 return tableObserver;
908 export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
909 Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
911 export function attachTableObserverToTableElement(
912 tableElement: HTMLTableElementWithWithTableSelectionState,
913 tableObserver: TableObserver,
915 tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
918 export function getTableObserverFromTableElement(
919 tableElement: HTMLTableElementWithWithTableSelectionState,
920 ): TableObserver | null {
921 return tableElement[LEXICAL_ELEMENT_KEY];
924 export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
925 let currentNode: ParentNode | Node | null = node;
927 while (currentNode != null) {
928 const nodeName = currentNode.nodeName;
930 if (nodeName === 'TD' || nodeName === 'TH') {
931 // @ts-expect-error: internal field
932 const cell = currentNode._cell;
934 if (cell === undefined) {
941 currentNode = currentNode.parentNode;
947 export function doesTargetContainText(node: Node): boolean {
948 const currentNode: ParentNode | Node | null = node;
950 if (currentNode !== null) {
951 const nodeName = currentNode.nodeName;
953 if (nodeName === 'SPAN') {
960 export function getTable(tableElement: HTMLElement): TableDOMTable {
961 const domRows: TableDOMRows = [];
967 let currentNode = tableElement.firstChild;
972 while (currentNode != null) {
973 const nodeMame = currentNode.nodeName;
975 if (nodeMame === 'TD' || nodeMame === 'TH') {
976 const elem = currentNode as HTMLElement;
979 hasBackgroundColor: elem.style.backgroundColor !== '',
985 // @ts-expect-error: internal field
986 currentNode._cell = cell;
988 let row = domRows[y];
989 if (row === undefined) {
990 row = domRows[y] = [];
995 const child = currentNode.firstChild;
1003 const sibling = currentNode.nextSibling;
1005 if (sibling != null) {
1007 currentNode = sibling;
1011 const parent = currentNode.parentNode;
1013 if (parent != null) {
1014 const parentSibling = parent.nextSibling;
1016 if (parentSibling == null) {
1022 currentNode = parentSibling;
1026 grid.columns = x + 1;
1032 export function $updateDOMForSelection(
1033 editor: LexicalEditor,
1034 table: TableDOMTable,
1035 selection: TableSelection | RangeSelection | null,
1037 const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
1038 $forEachTableCell(table, (cell, lexicalNode) => {
1039 const elem = cell.elem;
1041 if (selectedCellNodes.has(lexicalNode)) {
1042 cell.highlighted = true;
1043 $addHighlightToDOM(editor, cell);
1045 cell.highlighted = false;
1046 $removeHighlightFromDOM(editor, cell);
1047 if (!elem.getAttribute('style')) {
1048 elem.removeAttribute('style');
1054 export function $forEachTableCell(
1055 grid: TableDOMTable,
1058 lexicalNode: LexicalNode,
1065 const {domRows} = grid;
1067 for (let y = 0; y < domRows.length; y++) {
1068 const row = domRows[y];
1073 for (let x = 0; x < row.length; x++) {
1074 const cell = row[x];
1078 const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
1080 if (lexicalNode !== null) {
1081 cb(cell, lexicalNode, {
1090 export function $addHighlightStyleToTable(
1091 editor: LexicalEditor,
1092 tableSelection: TableObserver,
1094 tableSelection.disableHighlightStyle();
1095 $forEachTableCell(tableSelection.table, (cell) => {
1096 cell.highlighted = true;
1097 $addHighlightToDOM(editor, cell);
1101 export function $removeHighlightStyleToTable(
1102 editor: LexicalEditor,
1103 tableObserver: TableObserver,
1105 tableObserver.enableHighlightStyle();
1106 $forEachTableCell(tableObserver.table, (cell) => {
1107 const elem = cell.elem;
1108 cell.highlighted = false;
1109 $removeHighlightFromDOM(editor, cell);
1111 if (!elem.getAttribute('style')) {
1112 elem.removeAttribute('style');
1117 type Direction = 'backward' | 'forward' | 'up' | 'down';
1119 const selectTableNodeInDirection = (
1120 tableObserver: TableObserver,
1121 tableNode: TableNode,
1124 direction: Direction,
1126 const isForward = direction === 'forward';
1128 switch (direction) {
1131 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1132 selectTableCellNode(
1133 tableNode.getCellNodeFromCordsOrThrow(
1134 x + (isForward ? 1 : -1),
1136 tableObserver.table,
1141 if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
1142 selectTableCellNode(
1143 tableNode.getCellNodeFromCordsOrThrow(
1144 isForward ? 0 : tableObserver.table.columns - 1,
1145 y + (isForward ? 1 : -1),
1146 tableObserver.table,
1150 } else if (!isForward) {
1151 tableNode.selectPrevious();
1153 tableNode.selectNext();
1161 selectTableCellNode(
1162 tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
1166 tableNode.selectPrevious();
1172 if (y !== tableObserver.table.rows - 1) {
1173 selectTableCellNode(
1174 tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
1178 tableNode.selectNext();
1187 const adjustFocusNodeInDirection = (
1188 tableObserver: TableObserver,
1189 tableNode: TableNode,
1192 direction: Direction,
1194 const isForward = direction === 'forward';
1196 switch (direction) {
1199 if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
1200 tableObserver.setFocusCellForSelection(
1201 tableNode.getDOMCellFromCordsOrThrow(
1202 x + (isForward ? 1 : -1),
1204 tableObserver.table,
1212 tableObserver.setFocusCellForSelection(
1213 tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
1221 if (y !== tableObserver.table.rows - 1) {
1222 tableObserver.setFocusCellForSelection(
1223 tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
1235 function $isSelectionInTable(
1236 selection: null | BaseSelection,
1237 tableNode: TableNode,
1239 if ($isRangeSelection(selection) || $isTableSelection(selection)) {
1240 const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
1241 const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
1243 return isAnchorInside && isFocusInside;
1249 function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
1251 tableCell.selectStart();
1253 tableCell.selectEnd();
1257 const BROWSER_BLUE_RGB = '172,206,247';
1258 function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
1259 const element = cell.elem;
1260 const node = $getNearestNodeFromDOMNode(element);
1262 $isTableCellNode(node),
1263 'Expected to find LexicalNode from Table Cell DOMNode',
1265 const backgroundColor = node.getBackgroundColor();
1266 if (backgroundColor === null) {
1267 element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
1269 element.style.setProperty(
1271 `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
1274 element.style.setProperty('caret-color', 'transparent');
1277 function $removeHighlightFromDOM(
1278 editor: LexicalEditor,
1281 const element = cell.elem;
1282 const node = $getNearestNodeFromDOMNode(element);
1284 $isTableCellNode(node),
1285 'Expected to find LexicalNode from Table Cell DOMNode',
1287 const backgroundColor = node.getBackgroundColor();
1288 if (backgroundColor === null) {
1289 element.style.removeProperty('background-color');
1291 element.style.removeProperty('background-image');
1292 element.style.removeProperty('caret-color');
1295 export function $findCellNode(node: LexicalNode): null | TableCellNode {
1296 const cellNode = $findMatchingParent(node, $isTableCellNode);
1297 return $isTableCellNode(cellNode) ? cellNode : null;
1300 export function $findTableNode(node: LexicalNode): null | TableNode {
1301 const tableNode = $findMatchingParent(node, $isTableNode);
1302 return $isTableNode(tableNode) ? tableNode : null;
1305 function $handleArrowKey(
1306 editor: LexicalEditor,
1307 event: KeyboardEvent,
1308 direction: Direction,
1309 tableNode: TableNode,
1310 tableObserver: TableObserver,
1313 (direction === 'up' || direction === 'down') &&
1314 isTypeaheadMenuInView(editor)
1319 const selection = $getSelection();
1321 if (!$isSelectionInTable(selection, tableNode)) {
1322 if ($isRangeSelection(selection)) {
1323 if (selection.isCollapsed() && direction === 'backward') {
1324 const anchorType = selection.anchor.type;
1325 const anchorOffset = selection.anchor.offset;
1327 anchorType !== 'element' &&
1328 !(anchorType === 'text' && anchorOffset === 0)
1332 const anchorNode = selection.anchor.getNode();
1336 const parentNode = $findMatchingParent(
1338 (n) => $isElementNode(n) && !n.isInline(),
1343 const siblingNode = parentNode.getPreviousSibling();
1344 if (!siblingNode || !$isTableNode(siblingNode)) {
1348 siblingNode.selectEnd();
1352 (direction === 'up' || direction === 'down')
1354 const focusNode = selection.focus.getNode();
1355 if ($isRootOrShadowRoot(focusNode)) {
1356 const selectedNode = selection.getNodes()[0];
1358 const tableCellNode = $findMatchingParent(
1362 if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
1363 const firstDescendant = tableNode.getFirstDescendant();
1364 const lastDescendant = tableNode.getLastDescendant();
1365 if (!firstDescendant || !lastDescendant) {
1368 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1369 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1370 const firstCellCoords = tableNode.getCordsFromCellNode(
1372 tableObserver.table,
1374 const lastCellCoords = tableNode.getCordsFromCellNode(
1376 tableObserver.table,
1378 const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1381 tableObserver.table,
1383 const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
1386 tableObserver.table,
1388 tableObserver.setAnchorCellForSelection(firstCellDOM);
1389 tableObserver.setFocusCellForSelection(lastCellDOM, true);
1395 const focusParentNode = $findMatchingParent(
1397 (n) => $isElementNode(n) && !n.isInline(),
1399 if (!focusParentNode) {
1403 direction === 'down'
1404 ? focusParentNode.getNextSibling()
1405 : focusParentNode.getPreviousSibling();
1407 $isTableNode(sibling) &&
1408 tableObserver.tableNodeKey === sibling.getKey()
1410 const firstDescendant = sibling.getFirstDescendant();
1411 const lastDescendant = sibling.getLastDescendant();
1412 if (!firstDescendant || !lastDescendant) {
1415 const [firstCellNode] = $getNodeTriplet(firstDescendant);
1416 const [lastCellNode] = $getNodeTriplet(lastDescendant);
1417 const newSelection = selection.clone();
1418 newSelection.focus.set(
1419 (direction === 'up' ? firstCellNode : lastCellNode).getKey(),
1420 direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
1423 $setSelection(newSelection);
1432 if ($isRangeSelection(selection) && selection.isCollapsed()) {
1433 const {anchor, focus} = selection;
1434 const anchorCellNode = $findMatchingParent(
1438 const focusCellNode = $findMatchingParent(
1443 !$isTableCellNode(anchorCellNode) ||
1444 !anchorCellNode.is(focusCellNode)
1448 const anchorCellTable = $findTableNode(anchorCellNode);
1449 if (anchorCellTable !== tableNode && anchorCellTable != null) {
1450 const anchorCellTableElement = editor.getElementByKey(
1451 anchorCellTable.getKey(),
1453 if (anchorCellTableElement != null) {
1454 tableObserver.table = getTable(anchorCellTableElement);
1455 return $handleArrowKey(
1465 if (direction === 'backward' || direction === 'forward') {
1466 const anchorType = anchor.type;
1467 const anchorOffset = anchor.offset;
1468 const anchorNode = anchor.getNode();
1473 const selectedNodes = selection.getNodes();
1474 if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
1479 isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
1481 return $handleTableExit(event, anchorNode, tableNode, direction);
1487 const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
1488 const anchorDOM = editor.getElementByKey(anchor.key);
1489 if (anchorDOM == null || anchorCellDom == null) {
1493 let edgeSelectionRect;
1494 if (anchor.type === 'element') {
1495 edgeSelectionRect = anchorDOM.getBoundingClientRect();
1497 const domSelection = window.getSelection();
1498 if (domSelection === null || domSelection.rangeCount === 0) {
1502 const range = domSelection.getRangeAt(0);
1503 edgeSelectionRect = range.getBoundingClientRect();
1508 ? anchorCellNode.getFirstChild()
1509 : anchorCellNode.getLastChild();
1510 if (edgeChild == null) {
1514 const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
1516 if (edgeChildDOM == null) {
1520 const edgeRect = edgeChildDOM.getBoundingClientRect();
1523 ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
1524 : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
1529 const cords = tableNode.getCordsFromCellNode(
1531 tableObserver.table,
1534 if (event.shiftKey) {
1535 const cell = tableNode.getDOMCellFromCordsOrThrow(
1538 tableObserver.table,
1540 tableObserver.setAnchorCellForSelection(cell);
1541 tableObserver.setFocusCellForSelection(cell, true);
1543 return selectTableNodeInDirection(
1554 } else if ($isTableSelection(selection)) {
1555 const {anchor, focus} = selection;
1556 const anchorCellNode = $findMatchingParent(
1560 const focusCellNode = $findMatchingParent(
1565 const [tableNodeFromSelection] = selection.getNodes();
1566 const tableElement = editor.getElementByKey(
1567 tableNodeFromSelection.getKey(),
1570 !$isTableCellNode(anchorCellNode) ||
1571 !$isTableCellNode(focusCellNode) ||
1572 !$isTableNode(tableNodeFromSelection) ||
1573 tableElement == null
1577 tableObserver.updateTableTableSelection(selection);
1579 const grid = getTable(tableElement);
1580 const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
1581 const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
1586 tableObserver.setAnchorCellForSelection(anchorCell);
1590 if (event.shiftKey) {
1591 const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
1592 return adjustFocusNodeInDirection(
1594 tableNodeFromSelection,
1600 focusCellNode.selectEnd();
1609 function stopEvent(event: Event) {
1610 event.preventDefault();
1611 event.stopImmediatePropagation();
1612 event.stopPropagation();
1615 function isTypeaheadMenuInView(editor: LexicalEditor) {
1616 // There is no inbuilt way to check if the component picker is in view
1617 // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
1618 const root = editor.getRootElement();
1623 root.hasAttribute('aria-controls') &&
1624 root.getAttribute('aria-controls') === 'typeahead-menu'
1628 function isExitingTableAnchor(
1631 anchorNode: LexicalNode,
1632 direction: 'backward' | 'forward',
1635 isExitingTableElementAnchor(type, anchorNode, direction) ||
1636 $isExitingTableTextAnchor(type, offset, anchorNode, direction)
1640 function isExitingTableElementAnchor(
1642 anchorNode: LexicalNode,
1643 direction: 'backward' | 'forward',
1646 type === 'element' &&
1647 (direction === 'backward'
1648 ? anchorNode.getPreviousSibling() === null
1649 : anchorNode.getNextSibling() === null)
1653 function $isExitingTableTextAnchor(
1656 anchorNode: LexicalNode,
1657 direction: 'backward' | 'forward',
1659 const parentNode = $findMatchingParent(
1661 (n) => $isElementNode(n) && !n.isInline(),
1666 const hasValidOffset =
1667 direction === 'backward'
1669 : offset === anchorNode.getTextContentSize();
1673 (direction === 'backward'
1674 ? parentNode.getPreviousSibling() === null
1675 : parentNode.getNextSibling() === null)
1679 function $handleTableExit(
1680 event: KeyboardEvent,
1681 anchorNode: LexicalNode,
1682 tableNode: TableNode,
1683 direction: 'backward' | 'forward',
1685 const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
1686 if (!$isTableCellNode(anchorCellNode)) {
1689 const [tableMap, cellValue] = $computeTableMap(
1694 if (!isExitingCell(tableMap, cellValue, direction)) {
1698 const toNode = $getExitingToNode(anchorNode, direction, tableNode);
1699 if (!toNode || $isTableNode(toNode)) {
1704 if (direction === 'backward') {
1707 toNode.selectStart();
1712 function isExitingCell(
1713 tableMap: TableMapType,
1714 cellValue: TableMapValueType,
1715 direction: 'backward' | 'forward',
1717 const firstCell = tableMap[0][0];
1718 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1719 const {startColumn, startRow} = cellValue;
1720 return direction === 'backward'
1721 ? startColumn === firstCell.startColumn && startRow === firstCell.startRow
1722 : startColumn === lastCell.startColumn && startRow === lastCell.startRow;
1725 function $getExitingToNode(
1726 anchorNode: LexicalNode,
1727 direction: 'backward' | 'forward',
1728 tableNode: TableNode,
1730 const parentNode = $findMatchingParent(
1732 (n) => $isElementNode(n) && !n.isInline(),
1737 const anchorSibling =
1738 direction === 'backward'
1739 ? parentNode.getPreviousSibling()
1740 : parentNode.getNextSibling();
1741 return anchorSibling && $isTableNode(anchorSibling)
1743 : direction === 'backward'
1744 ? tableNode.getPreviousSibling()
1745 : tableNode.getNextSibling();
1748 function $insertParagraphAtTableEdge(
1749 edgePosition: 'first' | 'last',
1750 tableNode: TableNode,
1751 children?: LexicalNode[],
1753 const paragraphNode = $createParagraphNode();
1754 if (edgePosition === 'first') {
1755 tableNode.insertBefore(paragraphNode);
1757 tableNode.insertAfter(paragraphNode);
1759 paragraphNode.append(...(children || []));
1760 paragraphNode.selectEnd();
1763 function $getTableEdgeCursorPosition(
1764 editor: LexicalEditor,
1765 selection: RangeSelection,
1766 tableNode: TableNode,
1768 const tableNodeParent = tableNode.getParent();
1769 if (!tableNodeParent) {
1773 const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
1774 if (!tableNodeParentDOM) {
1778 // TODO: Add support for nested tables
1779 const domSelection = window.getSelection();
1780 if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
1784 const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
1785 $isTableCellNode(n),
1786 ) as TableCellNode | null;
1787 if (!anchorCellNode) {
1791 const parentTable = $findMatchingParent(anchorCellNode, (n) =>
1794 if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
1798 const [tableMap, cellValue] = $computeTableMap(
1803 const firstCell = tableMap[0][0];
1804 const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
1805 const {startRow, startColumn} = cellValue;
1807 const isAtFirstCell =
1808 startRow === firstCell.startRow && startColumn === firstCell.startColumn;
1809 const isAtLastCell =
1810 startRow === lastCell.startRow && startColumn === lastCell.startColumn;
1812 if (isAtFirstCell) {
1814 } else if (isAtLastCell) {