resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {TableCellNode} from './LexicalTableCellNode';
import type {TableNode} from './LexicalTableNode';
import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver';
import type {
TableMapType,
TableMapValueType,
TableSelection,
} from './LexicalTableSelection';
import type {
BaseSelection,
LexicalCommand,
LexicalEditor,
LexicalNode,
RangeSelection,
TextFormatType,
} from 'lexical';
import {
$getClipboardDataFromSelection,
copyToClipboard,
} from '@lexical/clipboard';
import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
import {
$createParagraphNode,
$createRangeSelectionFromDom,
$createTextNode,
$getNearestNodeFromDOMNode,
$getPreviousSelection,
$getSelection,
$isDecoratorNode,
$isElementNode,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
$setSelection,
COMMAND_PRIORITY_CRITICAL,
COMMAND_PRIORITY_HIGH,
CONTROLLED_TEXT_INSERTION_COMMAND,
CUT_COMMAND,
DELETE_CHARACTER_COMMAND,
DELETE_LINE_COMMAND,
DELETE_WORD_COMMAND,
FOCUS_COMMAND,
FORMAT_TEXT_COMMAND,
INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
SELECTION_CHANGE_COMMAND,
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
} from 'lexical';
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
import invariant from 'lexical/shared/invariant';
import {$isTableCellNode} from './LexicalTableCellNode';
import {$isTableNode} from './LexicalTableNode';
import {TableDOMTable, TableObserver} from './LexicalTableObserver';
import {$isTableRowNode} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection';
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
export const getDOMSelection = (
targetWindow: Window | null,
): Selection | null =>
CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
const isMouseDownOnEvent = (event: MouseEvent) => {
return (event.buttons & 1) === 1;
};
export function applyTableHandlers(
tableNode: TableNode,
tableElement: HTMLTableElementWithWithTableSelectionState,
editor: LexicalEditor,
hasTabHandler: boolean,
): TableObserver {
const rootElement = editor.getRootElement();
if (rootElement === null) {
throw new Error('No root element.');
}
const tableObserver = new TableObserver(editor, tableNode.getKey());
const editorWindow = editor._window || window;
attachTableObserverToTableElement(tableElement, tableObserver);
const createMouseHandlers = () => {
const onMouseUp = () => {
tableObserver.isSelecting = false;
editorWindow.removeEventListener('mouseup', onMouseUp);
editorWindow.removeEventListener('mousemove', onMouseMove);
};
const onMouseMove = (moveEvent: MouseEvent) => {
// delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first
setTimeout(() => {
if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) {
tableObserver.isSelecting = false;
editorWindow.removeEventListener('mouseup', onMouseUp);
editorWindow.removeEventListener('mousemove', onMouseMove);
return;
}
const focusCell = getDOMCellFromTarget(moveEvent.target as Node);
if (
focusCell !== null &&
(tableObserver.anchorX !== focusCell.x ||
tableObserver.anchorY !== focusCell.y)
) {
moveEvent.preventDefault();
tableObserver.setFocusCellForSelection(focusCell);
}
}, 0);
};
return {onMouseMove: onMouseMove, onMouseUp: onMouseUp};
};
tableElement.addEventListener('mousedown', (event: MouseEvent) => {
setTimeout(() => {
if (event.button !== 0) {
return;
}
if (!editorWindow) {
return;
}
const anchorCell = getDOMCellFromTarget(event.target as Node);
if (anchorCell !== null) {
stopEvent(event);
tableObserver.setAnchorCellForSelection(anchorCell);
}
const {onMouseUp, onMouseMove} = createMouseHandlers();
tableObserver.isSelecting = true;
editorWindow.addEventListener('mouseup', onMouseUp);
editorWindow.addEventListener('mousemove', onMouseMove);
}, 0);
});
// Clear selection when clicking outside of dom.
const mouseDownCallback = (event: MouseEvent) => {
if (event.button !== 0) {
return;
}
editor.update(() => {
const selection = $getSelection();
const target = event.target as Node;
if (
$isTableSelection(selection) &&
selection.tableKey === tableObserver.tableNodeKey &&
rootElement.contains(target)
) {
tableObserver.clearHighlight();
}
});
};
editorWindow.addEventListener('mousedown', mouseDownCallback);
tableObserver.listenersToRemove.add(() =>
editorWindow.removeEventListener('mousedown', mouseDownCallback),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_DOWN_COMMAND,
(event) =>
$handleArrowKey(editor, event, 'down', tableNode, tableObserver),
COMMAND_PRIORITY_HIGH,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_UP_COMMAND,
(event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver),
COMMAND_PRIORITY_HIGH,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_LEFT_COMMAND,
(event) =>
$handleArrowKey(editor, event, 'backward', tableNode, tableObserver),
COMMAND_PRIORITY_HIGH,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_RIGHT_COMMAND,
(event) =>
$handleArrowKey(editor, event, 'forward', tableNode, tableObserver),
COMMAND_PRIORITY_HIGH,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_ESCAPE_COMMAND,
(event) => {
const selection = $getSelection();
if ($isTableSelection(selection)) {
const focusCellNode = $findMatchingParent(
selection.focus.getNode(),
$isTableCellNode,
);
if ($isTableCellNode(focusCellNode)) {
stopEvent(event);
focusCellNode.selectEnd();
return true;
}
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
const deleteTextHandler = (command: LexicalCommand<boolean>) => () => {
const selection = $getSelection();
if (!$isSelectionInTable(selection, tableNode)) {
return false;
}
if ($isTableSelection(selection)) {
tableObserver.clearText();
return true;
} else if ($isRangeSelection(selection)) {
const tableCellNode = $findMatchingParent(
selection.anchor.getNode(),
(n) => $isTableCellNode(n),
);
if (!$isTableCellNode(tableCellNode)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
const isAnchorInside = tableNode.isParentOf(anchorNode);
const isFocusInside = tableNode.isParentOf(focusNode);
const selectionContainsPartialTable =
(isAnchorInside && !isFocusInside) ||
(isFocusInside && !isAnchorInside);
if (selectionContainsPartialTable) {
tableObserver.clearText();
return true;
}
const nearestElementNode = $findMatchingParent(
selection.anchor.getNode(),
(n) => $isElementNode(n),
);
const topLevelCellElementNode =
nearestElementNode &&
$findMatchingParent(
nearestElementNode,
(n) => $isElementNode(n) && $isTableCellNode(n.getParent()),
);
if (
!$isElementNode(topLevelCellElementNode) ||
!$isElementNode(nearestElementNode)
) {
return false;
}
if (
command === DELETE_LINE_COMMAND &&
topLevelCellElementNode.getPreviousSibling() === null
) {
// TODO: Fix Delete Line in Table Cells.
return true;
}
}
return false;
};
[DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach(
(command) => {
tableObserver.listenersToRemove.add(
editor.registerCommand(
command,
deleteTextHandler(command),
COMMAND_PRIORITY_CRITICAL,
),
);
},
);
const $deleteCellHandler = (
event: KeyboardEvent | ClipboardEvent | null,
): boolean => {
const selection = $getSelection();
if (!$isSelectionInTable(selection, tableNode)) {
const nodes = selection ? selection.getNodes() : null;
if (nodes) {
const table = nodes.find(
(node) =>
$isTableNode(node) && node.getKey() === tableObserver.tableNodeKey,
);
if ($isTableNode(table)) {
const parentNode = table.getParent();
if (!parentNode) {
return false;
}
table.remove();
}
}
return false;
}
if ($isTableSelection(selection)) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
tableObserver.clearText();
return true;
} else if ($isRangeSelection(selection)) {
const tableCellNode = $findMatchingParent(
selection.anchor.getNode(),
(n) => $isTableCellNode(n),
);
if (!$isTableCellNode(tableCellNode)) {
return false;
}
}
return false;
};
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_BACKSPACE_COMMAND,
$deleteCellHandler,
COMMAND_PRIORITY_CRITICAL,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_DELETE_COMMAND,
$deleteCellHandler,
COMMAND_PRIORITY_CRITICAL,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent | ClipboardEvent | null>(
CUT_COMMAND,
(event) => {
const selection = $getSelection();
if (selection) {
if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
return false;
}
// Copying to the clipboard is async so we must capture the data
// before we delete it
void copyToClipboard(
editor,
objectKlassEquals(event, ClipboardEvent)
? (event as ClipboardEvent)
: null,
$getClipboardDataFromSelection(selection),
);
const intercepted = $deleteCellHandler(event);
if ($isRangeSelection(selection)) {
selection.removeText();
}
return intercepted;
}
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand<TextFormatType>(
FORMAT_TEXT_COMMAND,
(payload) => {
const selection = $getSelection();
if (!$isSelectionInTable(selection, tableNode)) {
return false;
}
if ($isTableSelection(selection)) {
tableObserver.formatCells(payload);
return true;
} else if ($isRangeSelection(selection)) {
const tableCellNode = $findMatchingParent(
selection.anchor.getNode(),
(n) => $isTableCellNode(n),
);
if (!$isTableCellNode(tableCellNode)) {
return false;
}
}
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand(
CONTROLLED_TEXT_INSERTION_COMMAND,
(payload) => {
const selection = $getSelection();
if (!$isSelectionInTable(selection, tableNode)) {
return false;
}
if ($isTableSelection(selection)) {
tableObserver.clearHighlight();
return false;
} else if ($isRangeSelection(selection)) {
const tableCellNode = $findMatchingParent(
selection.anchor.getNode(),
(n) => $isTableCellNode(n),
);
if (!$isTableCellNode(tableCellNode)) {
return false;
}
if (typeof payload === 'string') {
const edgePosition = $getTableEdgeCursorPosition(
editor,
selection,
tableNode,
);
if (edgePosition) {
$insertParagraphAtTableEdge(edgePosition, tableNode, [
$createTextNode(payload),
]);
return true;
}
}
}
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
if (hasTabHandler) {
tableObserver.listenersToRemove.add(
editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND,
(event) => {
const selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
!$isSelectionInTable(selection, tableNode)
) {
return false;
}
const tableCellNode = $findCellNode(selection.anchor.getNode());
if (tableCellNode === null) {
return false;
}
stopEvent(event);
const currentCords = tableNode.getCordsFromCellNode(
tableCellNode,
tableObserver.table,
);
selectTableNodeInDirection(
tableObserver,
tableNode,
currentCords.x,
currentCords.y,
!event.shiftKey ? 'forward' : 'backward',
);
return true;
},
COMMAND_PRIORITY_CRITICAL,
),
);
}
tableObserver.listenersToRemove.add(
editor.registerCommand(
FOCUS_COMMAND,
(payload) => {
return tableNode.isSelected();
},
COMMAND_PRIORITY_HIGH,
),
);
function getObserverCellFromCellNode(
tableCellNode: TableCellNode,
): TableDOMCell {
const currentCords = tableNode.getCordsFromCellNode(
tableCellNode,
tableObserver.table,
);
return tableNode.getDOMCellFromCordsOrThrow(
currentCords.x,
currentCords.y,
tableObserver.table,
);
}
tableObserver.listenersToRemove.add(
editor.registerCommand(
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
(selectionPayload) => {
const {nodes, selection} = selectionPayload;
const anchorAndFocus = selection.getStartEndPoints();
const isTableSelection = $isTableSelection(selection);
const isRangeSelection = $isRangeSelection(selection);
const isSelectionInsideOfGrid =
(isRangeSelection &&
$findMatchingParent(selection.anchor.getNode(), (n) =>
$isTableCellNode(n),
) !== null &&
$findMatchingParent(selection.focus.getNode(), (n) =>
$isTableCellNode(n),
) !== null) ||
isTableSelection;
if (
nodes.length !== 1 ||
!$isTableNode(nodes[0]) ||
!isSelectionInsideOfGrid ||
anchorAndFocus === null
) {
return false;
}
const [anchor] = anchorAndFocus;
const newGrid = nodes[0];
const newGridRows = newGrid.getChildren();
const newColumnCount = newGrid
.getFirstChildOrThrow<TableNode>()
.getChildrenSize();
const newRowCount = newGrid.getChildrenSize();
const gridCellNode = $findMatchingParent(anchor.getNode(), (n) =>
$isTableCellNode(n),
);
const gridRowNode =
gridCellNode &&
$findMatchingParent(gridCellNode, (n) => $isTableRowNode(n));
const gridNode =
gridRowNode &&
$findMatchingParent(gridRowNode, (n) => $isTableNode(n));
if (
!$isTableCellNode(gridCellNode) ||
!$isTableRowNode(gridRowNode) ||
!$isTableNode(gridNode)
) {
return false;
}
const startY = gridRowNode.getIndexWithinParent();
const stopY = Math.min(
gridNode.getChildrenSize() - 1,
startY + newRowCount - 1,
);
const startX = gridCellNode.getIndexWithinParent();
const stopX = Math.min(
gridRowNode.getChildrenSize() - 1,
startX + newColumnCount - 1,
);
const fromX = Math.min(startX, stopX);
const fromY = Math.min(startY, stopY);
const toX = Math.max(startX, stopX);
const toY = Math.max(startY, stopY);
const gridRowNodes = gridNode.getChildren();
let newRowIdx = 0;
for (let r = fromY; r <= toY; r++) {
const currentGridRowNode = gridRowNodes[r];
if (!$isTableRowNode(currentGridRowNode)) {
return false;
}
const newGridRowNode = newGridRows[newRowIdx];
if (!$isTableRowNode(newGridRowNode)) {
return false;
}
const gridCellNodes = currentGridRowNode.getChildren();
const newGridCellNodes = newGridRowNode.getChildren();
let newColumnIdx = 0;
for (let c = fromX; c <= toX; c++) {
const currentGridCellNode = gridCellNodes[c];
if (!$isTableCellNode(currentGridCellNode)) {
return false;
}
const newGridCellNode = newGridCellNodes[newColumnIdx];
if (!$isTableCellNode(newGridCellNode)) {
return false;
}
const originalChildren = currentGridCellNode.getChildren();
newGridCellNode.getChildren().forEach((child) => {
if ($isTextNode(child)) {
const paragraphNode = $createParagraphNode();
paragraphNode.append(child);
currentGridCellNode.append(child);
} else {
currentGridCellNode.append(child);
}
});
originalChildren.forEach((n) => n.remove());
newColumnIdx++;
}
newRowIdx++;
}
return true;
},
COMMAND_PRIORITY_CRITICAL,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const selection = $getSelection();
const prevSelection = $getPreviousSelection();
if ($isRangeSelection(selection)) {
const {anchor, focus} = selection;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
// Using explicit comparison with table node to ensure it's not a nested table
// as in that case we'll leave selection resolving to that table
const anchorCellNode = $findCellNode(anchorNode);
const focusCellNode = $findCellNode(focusNode);
const isAnchorInside = !!(
anchorCellNode && tableNode.is($findTableNode(anchorCellNode))
);
const isFocusInside = !!(
focusCellNode && tableNode.is($findTableNode(focusCellNode))
);
const isPartialyWithinTable = isAnchorInside !== isFocusInside;
const isWithinTable = isAnchorInside && isFocusInside;
const isBackward = selection.isBackward();
if (isPartialyWithinTable) {
const newSelection = selection.clone();
if (isFocusInside) {
const [tableMap] = $computeTableMap(
tableNode,
focusCellNode,
focusCellNode,
);
const firstCell = tableMap[0][0].cell;
const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
newSelection.focus.set(
isBackward ? firstCell.getKey() : lastCell.getKey(),
isBackward
? firstCell.getChildrenSize()
: lastCell.getChildrenSize(),
'element',
);
}
$setSelection(newSelection);
$addHighlightStyleToTable(editor, tableObserver);
} else if (isWithinTable) {
// Handle case when selection spans across multiple cells but still
// has range selection, then we convert it into grid selection
if (!anchorCellNode.is(focusCellNode)) {
tableObserver.setAnchorCellForSelection(
getObserverCellFromCellNode(anchorCellNode),
);
tableObserver.setFocusCellForSelection(
getObserverCellFromCellNode(focusCellNode),
true,
);
if (!tableObserver.isSelecting) {
setTimeout(() => {
const {onMouseUp, onMouseMove} = createMouseHandlers();
tableObserver.isSelecting = true;
editorWindow.addEventListener('mouseup', onMouseUp);
editorWindow.addEventListener('mousemove', onMouseMove);
}, 0);
}
}
}
} else if (
selection &&
$isTableSelection(selection) &&
selection.is(prevSelection) &&
selection.tableKey === tableNode.getKey()
) {
// if selection goes outside of the table we need to change it to Range selection
const domSelection = getDOMSelection(editor._window);
if (
domSelection &&
domSelection.anchorNode &&
domSelection.focusNode
) {
const focusNode = $getNearestNodeFromDOMNode(
domSelection.focusNode,
);
const isFocusOutside =
focusNode && !tableNode.is($findTableNode(focusNode));
const anchorNode = $getNearestNodeFromDOMNode(
domSelection.anchorNode,
);
const isAnchorInside =
anchorNode && tableNode.is($findTableNode(anchorNode));
if (
isFocusOutside &&
isAnchorInside &&
domSelection.rangeCount > 0
) {
const newSelection = $createRangeSelectionFromDom(
domSelection,
editor,
);
if (newSelection) {
newSelection.anchor.set(
tableNode.getKey(),
selection.isBackward() ? tableNode.getChildrenSize() : 0,
'element',
);
domSelection.removeAllRanges();
$setSelection(newSelection);
}
}
}
}
if (
selection &&
!selection.is(prevSelection) &&
($isTableSelection(selection) || $isTableSelection(prevSelection)) &&
tableObserver.tableSelection &&
!tableObserver.tableSelection.is(prevSelection)
) {
if (
$isTableSelection(selection) &&
selection.tableKey === tableObserver.tableNodeKey
) {
tableObserver.updateTableTableSelection(selection);
} else if (
!$isTableSelection(selection) &&
$isTableSelection(prevSelection) &&
prevSelection.tableKey === tableObserver.tableNodeKey
) {
tableObserver.updateTableTableSelection(null);
}
return false;
}
if (
tableObserver.hasHijackedSelectionStyles &&
!tableNode.isSelected()
) {
$removeHighlightStyleToTable(editor, tableObserver);
} else if (
!tableObserver.hasHijackedSelectionStyles &&
tableNode.isSelected()
) {
$addHighlightStyleToTable(editor, tableObserver);
}
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => {
const selection = $getSelection();
if (
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
!$isSelectionInTable(selection, tableNode)
) {
return false;
}
const edgePosition = $getTableEdgeCursorPosition(
editor,
selection,
tableNode,
);
if (edgePosition) {
$insertParagraphAtTableEdge(edgePosition, tableNode);
return true;
}
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
return tableObserver;
}
export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement &
Record<typeof LEXICAL_ELEMENT_KEY, TableObserver>;
export function attachTableObserverToTableElement(
tableElement: HTMLTableElementWithWithTableSelectionState,
tableObserver: TableObserver,
) {
tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
}
export function getTableObserverFromTableElement(
tableElement: HTMLTableElementWithWithTableSelectionState,
): TableObserver | null {
return tableElement[LEXICAL_ELEMENT_KEY];
}
export function getDOMCellFromTarget(node: Node): TableDOMCell | null {
let currentNode: ParentNode | Node | null = node;
while (currentNode != null) {
const nodeName = currentNode.nodeName;
if (nodeName === 'TD' || nodeName === 'TH') {
// @ts-expect-error: internal field
const cell = currentNode._cell;
if (cell === undefined) {
return null;
}
return cell;
}
currentNode = currentNode.parentNode;
}
return null;
}
export function doesTargetContainText(node: Node): boolean {
const currentNode: ParentNode | Node | null = node;
if (currentNode !== null) {
const nodeName = currentNode.nodeName;
if (nodeName === 'SPAN') {
return true;
}
}
return false;
}
export function getTable(tableElement: HTMLElement): TableDOMTable {
const domRows: TableDOMRows = [];
const grid = {
columns: 0,
domRows,
rows: 0,
};
let currentNode = tableElement.firstChild;
let x = 0;
let y = 0;
domRows.length = 0;
while (currentNode != null) {
const nodeMame = currentNode.nodeName;
if (nodeMame === 'TD' || nodeMame === 'TH') {
const elem = currentNode as HTMLElement;
const cell = {
elem,
hasBackgroundColor: elem.style.backgroundColor !== '',
highlighted: false,
x,
y,
};
// @ts-expect-error: internal field
currentNode._cell = cell;
let row = domRows[y];
if (row === undefined) {
row = domRows[y] = [];
}
row[x] = cell;
} else {
const child = currentNode.firstChild;
if (child != null) {
currentNode = child;
continue;
}
}
const sibling = currentNode.nextSibling;
if (sibling != null) {
x++;
currentNode = sibling;
continue;
}
const parent = currentNode.parentNode;
if (parent != null) {
const parentSibling = parent.nextSibling;
if (parentSibling == null) {
break;
}
y++;
x = 0;
currentNode = parentSibling;
}
}
grid.columns = x + 1;
grid.rows = y + 1;
return grid;
}
export function $updateDOMForSelection(
editor: LexicalEditor,
table: TableDOMTable,
selection: TableSelection | RangeSelection | null,
) {
const selectedCellNodes = new Set(selection ? selection.getNodes() : []);
$forEachTableCell(table, (cell, lexicalNode) => {
const elem = cell.elem;
if (selectedCellNodes.has(lexicalNode)) {
cell.highlighted = true;
$addHighlightToDOM(editor, cell);
} else {
cell.highlighted = false;
$removeHighlightFromDOM(editor, cell);
if (!elem.getAttribute('style')) {
elem.removeAttribute('style');
}
}
});
}
export function $forEachTableCell(
grid: TableDOMTable,
cb: (
cell: TableDOMCell,
lexicalNode: LexicalNode,
cords: {
x: number;
y: number;
},
) => void,
) {
const {domRows} = grid;
for (let y = 0; y < domRows.length; y++) {
const row = domRows[y];
if (!row) {
continue;
}
for (let x = 0; x < row.length; x++) {
const cell = row[x];
if (!cell) {
continue;
}
const lexicalNode = $getNearestNodeFromDOMNode(cell.elem);
if (lexicalNode !== null) {
cb(cell, lexicalNode, {
x,
y,
});
}
}
}
}
export function $addHighlightStyleToTable(
editor: LexicalEditor,
tableSelection: TableObserver,
) {
tableSelection.disableHighlightStyle();
$forEachTableCell(tableSelection.table, (cell) => {
cell.highlighted = true;
$addHighlightToDOM(editor, cell);
});
}
export function $removeHighlightStyleToTable(
editor: LexicalEditor,
tableObserver: TableObserver,
) {
tableObserver.enableHighlightStyle();
$forEachTableCell(tableObserver.table, (cell) => {
const elem = cell.elem;
cell.highlighted = false;
$removeHighlightFromDOM(editor, cell);
if (!elem.getAttribute('style')) {
elem.removeAttribute('style');
}
});
}
type Direction = 'backward' | 'forward' | 'up' | 'down';
const selectTableNodeInDirection = (
tableObserver: TableObserver,
tableNode: TableNode,
x: number,
y: number,
direction: Direction,
): boolean => {
const isForward = direction === 'forward';
switch (direction) {
case 'backward':
case 'forward':
if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
selectTableCellNode(
tableNode.getCellNodeFromCordsOrThrow(
x + (isForward ? 1 : -1),
y,
tableObserver.table,
),
isForward,
);
} else {
if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) {
selectTableCellNode(
tableNode.getCellNodeFromCordsOrThrow(
isForward ? 0 : tableObserver.table.columns - 1,
y + (isForward ? 1 : -1),
tableObserver.table,
),
isForward,
);
} else if (!isForward) {
tableNode.selectPrevious();
} else {
tableNode.selectNext();
}
}
return true;
case 'up':
if (y !== 0) {
selectTableCellNode(
tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
false,
);
} else {
tableNode.selectPrevious();
}
return true;
case 'down':
if (y !== tableObserver.table.rows - 1) {
selectTableCellNode(
tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
true,
);
} else {
tableNode.selectNext();
}
return true;
default:
return false;
}
};
const adjustFocusNodeInDirection = (
tableObserver: TableObserver,
tableNode: TableNode,
x: number,
y: number,
direction: Direction,
): boolean => {
const isForward = direction === 'forward';
switch (direction) {
case 'backward':
case 'forward':
if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) {
tableObserver.setFocusCellForSelection(
tableNode.getDOMCellFromCordsOrThrow(
x + (isForward ? 1 : -1),
y,
tableObserver.table,
),
);
}
return true;
case 'up':
if (y !== 0) {
tableObserver.setFocusCellForSelection(
tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table),
);
return true;
} else {
return false;
}
case 'down':
if (y !== tableObserver.table.rows - 1) {
tableObserver.setFocusCellForSelection(
tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table),
);
return true;
} else {
return false;
}
default:
return false;
}
};
function $isSelectionInTable(
selection: null | BaseSelection,
tableNode: TableNode,
): boolean {
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
return isAnchorInside && isFocusInside;
}
return false;
}
function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
if (fromStart) {
tableCell.selectStart();
} else {
tableCell.selectEnd();
}
}
const BROWSER_BLUE_RGB = '172,206,247';
function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void {
const element = cell.elem;
const node = $getNearestNodeFromDOMNode(element);
invariant(
$isTableCellNode(node),
'Expected to find LexicalNode from Table Cell DOMNode',
);
const backgroundColor = node.getBackgroundColor();
if (backgroundColor === null) {
element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`);
} else {
element.style.setProperty(
'background-image',
`linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`,
);
}
element.style.setProperty('caret-color', 'transparent');
}
function $removeHighlightFromDOM(
editor: LexicalEditor,
cell: TableDOMCell,
): void {
const element = cell.elem;
const node = $getNearestNodeFromDOMNode(element);
invariant(
$isTableCellNode(node),
'Expected to find LexicalNode from Table Cell DOMNode',
);
const backgroundColor = node.getBackgroundColor();
if (backgroundColor === null) {
element.style.removeProperty('background-color');
}
element.style.removeProperty('background-image');
element.style.removeProperty('caret-color');
}
export function $findCellNode(node: LexicalNode): null | TableCellNode {
const cellNode = $findMatchingParent(node, $isTableCellNode);
return $isTableCellNode(cellNode) ? cellNode : null;
}
export function $findTableNode(node: LexicalNode): null | TableNode {
const tableNode = $findMatchingParent(node, $isTableNode);
return $isTableNode(tableNode) ? tableNode : null;
}
function $handleArrowKey(
editor: LexicalEditor,
event: KeyboardEvent,
direction: Direction,
tableNode: TableNode,
tableObserver: TableObserver,
): boolean {
if (
(direction === 'up' || direction === 'down') &&
isTypeaheadMenuInView(editor)
) {
return false;
}
const selection = $getSelection();
if (!$isSelectionInTable(selection, tableNode)) {
if ($isRangeSelection(selection)) {
if (selection.isCollapsed() && direction === 'backward') {
const anchorType = selection.anchor.type;
const anchorOffset = selection.anchor.offset;
if (
anchorType !== 'element' &&
!(anchorType === 'text' && anchorOffset === 0)
) {
return false;
}
const anchorNode = selection.anchor.getNode();
if (!anchorNode) {
return false;
}
const parentNode = $findMatchingParent(
anchorNode,
(n) => $isElementNode(n) && !n.isInline(),
);
if (!parentNode) {
return false;
}
const siblingNode = parentNode.getPreviousSibling();
if (!siblingNode || !$isTableNode(siblingNode)) {
return false;
}
stopEvent(event);
siblingNode.selectEnd();
return true;
} else if (
event.shiftKey &&
(direction === 'up' || direction === 'down')
) {
const focusNode = selection.focus.getNode();
if ($isRootOrShadowRoot(focusNode)) {
const selectedNode = selection.getNodes()[0];
if (selectedNode) {
const tableCellNode = $findMatchingParent(
selectedNode,
$isTableCellNode,
);
if (tableCellNode && tableNode.isParentOf(tableCellNode)) {
const firstDescendant = tableNode.getFirstDescendant();
const lastDescendant = tableNode.getLastDescendant();
if (!firstDescendant || !lastDescendant) {
return false;
}
const [firstCellNode] = $getNodeTriplet(firstDescendant);
const [lastCellNode] = $getNodeTriplet(lastDescendant);
const firstCellCoords = tableNode.getCordsFromCellNode(
firstCellNode,
tableObserver.table,
);
const lastCellCoords = tableNode.getCordsFromCellNode(
lastCellNode,
tableObserver.table,
);
const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(
firstCellCoords.x,
firstCellCoords.y,
tableObserver.table,
);
const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(
lastCellCoords.x,
lastCellCoords.y,
tableObserver.table,
);
tableObserver.setAnchorCellForSelection(firstCellDOM);
tableObserver.setFocusCellForSelection(lastCellDOM, true);
return true;
}
}
return false;
} else {
const focusParentNode = $findMatchingParent(
focusNode,
(n) => $isElementNode(n) && !n.isInline(),
);
if (!focusParentNode) {
return false;
}
const sibling =
direction === 'down'
? focusParentNode.getNextSibling()
: focusParentNode.getPreviousSibling();
if (
$isTableNode(sibling) &&
tableObserver.tableNodeKey === sibling.getKey()
) {
const firstDescendant = sibling.getFirstDescendant();
const lastDescendant = sibling.getLastDescendant();
if (!firstDescendant || !lastDescendant) {
return false;
}
const [firstCellNode] = $getNodeTriplet(firstDescendant);
const [lastCellNode] = $getNodeTriplet(lastDescendant);
const newSelection = selection.clone();
newSelection.focus.set(
(direction === 'up' ? firstCellNode : lastCellNode).getKey(),
direction === 'up' ? 0 : lastCellNode.getChildrenSize(),
'element',
);
$setSelection(newSelection);
return true;
}
}
}
}
return false;
}
if ($isRangeSelection(selection) && selection.isCollapsed()) {
const {anchor, focus} = selection;
const anchorCellNode = $findMatchingParent(
anchor.getNode(),
$isTableCellNode,
);
const focusCellNode = $findMatchingParent(
focus.getNode(),
$isTableCellNode,
);
if (
!$isTableCellNode(anchorCellNode) ||
!anchorCellNode.is(focusCellNode)
) {
return false;
}
const anchorCellTable = $findTableNode(anchorCellNode);
if (anchorCellTable !== tableNode && anchorCellTable != null) {
const anchorCellTableElement = editor.getElementByKey(
anchorCellTable.getKey(),
);
if (anchorCellTableElement != null) {
tableObserver.table = getTable(anchorCellTableElement);
return $handleArrowKey(
editor,
event,
direction,
anchorCellTable,
tableObserver,
);
}
}
if (direction === 'backward' || direction === 'forward') {
const anchorType = anchor.type;
const anchorOffset = anchor.offset;
const anchorNode = anchor.getNode();
if (!anchorNode) {
return false;
}
const selectedNodes = selection.getNodes();
if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) {
return false;
}
if (
isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction)
) {
return $handleTableExit(event, anchorNode, tableNode, direction);
}
return false;
}
const anchorCellDom = editor.getElementByKey(anchorCellNode.__key);
const anchorDOM = editor.getElementByKey(anchor.key);
if (anchorDOM == null || anchorCellDom == null) {
return false;
}
let edgeSelectionRect;
if (anchor.type === 'element') {
edgeSelectionRect = anchorDOM.getBoundingClientRect();
} else {
const domSelection = window.getSelection();
if (domSelection === null || domSelection.rangeCount === 0) {
return false;
}
const range = domSelection.getRangeAt(0);
edgeSelectionRect = range.getBoundingClientRect();
}
const edgeChild =
direction === 'up'
? anchorCellNode.getFirstChild()
: anchorCellNode.getLastChild();
if (edgeChild == null) {
return false;
}
const edgeChildDOM = editor.getElementByKey(edgeChild.__key);
if (edgeChildDOM == null) {
return false;
}
const edgeRect = edgeChildDOM.getBoundingClientRect();
const isExiting =
direction === 'up'
? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height
: edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom;
if (isExiting) {
stopEvent(event);
const cords = tableNode.getCordsFromCellNode(
anchorCellNode,
tableObserver.table,
);
if (event.shiftKey) {
const cell = tableNode.getDOMCellFromCordsOrThrow(
cords.x,
cords.y,
tableObserver.table,
);
tableObserver.setAnchorCellForSelection(cell);
tableObserver.setFocusCellForSelection(cell, true);
} else {
return selectTableNodeInDirection(
tableObserver,
tableNode,
cords.x,
cords.y,
direction,
);
}
return true;
}
} else if ($isTableSelection(selection)) {
const {anchor, focus} = selection;
const anchorCellNode = $findMatchingParent(
anchor.getNode(),
$isTableCellNode,
);
const focusCellNode = $findMatchingParent(
focus.getNode(),
$isTableCellNode,
);
const [tableNodeFromSelection] = selection.getNodes();
const tableElement = editor.getElementByKey(
tableNodeFromSelection.getKey(),
);
if (
!$isTableCellNode(anchorCellNode) ||
!$isTableCellNode(focusCellNode) ||
!$isTableNode(tableNodeFromSelection) ||
tableElement == null
) {
return false;
}
tableObserver.updateTableTableSelection(selection);
const grid = getTable(tableElement);
const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid);
const anchorCell = tableNode.getDOMCellFromCordsOrThrow(
cordsAnchor.x,
cordsAnchor.y,
grid,
);
tableObserver.setAnchorCellForSelection(anchorCell);
stopEvent(event);
if (event.shiftKey) {
const cords = tableNode.getCordsFromCellNode(focusCellNode, grid);
return adjustFocusNodeInDirection(
tableObserver,
tableNodeFromSelection,
cords.x,
cords.y,
direction,
);
} else {
focusCellNode.selectEnd();
}
return true;
}
return false;
}
function stopEvent(event: Event) {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}
function isTypeaheadMenuInView(editor: LexicalEditor) {
// There is no inbuilt way to check if the component picker is in view
// but we can check if the root DOM element has the aria-controls attribute "typeahead-menu".
const root = editor.getRootElement();
if (!root) {
return false;
}
return (
root.hasAttribute('aria-controls') &&
root.getAttribute('aria-controls') === 'typeahead-menu'
);
}
function isExitingTableAnchor(
type: string,
offset: number,
anchorNode: LexicalNode,
direction: 'backward' | 'forward',
) {
return (
isExitingTableElementAnchor(type, anchorNode, direction) ||
$isExitingTableTextAnchor(type, offset, anchorNode, direction)
);
}
function isExitingTableElementAnchor(
type: string,
anchorNode: LexicalNode,
direction: 'backward' | 'forward',
) {
return (
type === 'element' &&
(direction === 'backward'
? anchorNode.getPreviousSibling() === null
: anchorNode.getNextSibling() === null)
);
}
function $isExitingTableTextAnchor(
type: string,
offset: number,
anchorNode: LexicalNode,
direction: 'backward' | 'forward',
) {
const parentNode = $findMatchingParent(
anchorNode,
(n) => $isElementNode(n) && !n.isInline(),
);
if (!parentNode) {
return false;
}
const hasValidOffset =
direction === 'backward'
? offset === 0
: offset === anchorNode.getTextContentSize();
return (
type === 'text' &&
hasValidOffset &&
(direction === 'backward'
? parentNode.getPreviousSibling() === null
: parentNode.getNextSibling() === null)
);
}
function $handleTableExit(
event: KeyboardEvent,
anchorNode: LexicalNode,
tableNode: TableNode,
direction: 'backward' | 'forward',
) {
const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode);
if (!$isTableCellNode(anchorCellNode)) {
return false;
}
const [tableMap, cellValue] = $computeTableMap(
tableNode,
anchorCellNode,
anchorCellNode,
);
if (!isExitingCell(tableMap, cellValue, direction)) {
return false;
}
const toNode = $getExitingToNode(anchorNode, direction, tableNode);
if (!toNode || $isTableNode(toNode)) {
return false;
}
stopEvent(event);
if (direction === 'backward') {
toNode.selectEnd();
} else {
toNode.selectStart();
}
return true;
}
function isExitingCell(
tableMap: TableMapType,
cellValue: TableMapValueType,
direction: 'backward' | 'forward',
) {
const firstCell = tableMap[0][0];
const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
const {startColumn, startRow} = cellValue;
return direction === 'backward'
? startColumn === firstCell.startColumn && startRow === firstCell.startRow
: startColumn === lastCell.startColumn && startRow === lastCell.startRow;
}
function $getExitingToNode(
anchorNode: LexicalNode,
direction: 'backward' | 'forward',
tableNode: TableNode,
) {
const parentNode = $findMatchingParent(
anchorNode,
(n) => $isElementNode(n) && !n.isInline(),
);
if (!parentNode) {
return undefined;
}
const anchorSibling =
direction === 'backward'
? parentNode.getPreviousSibling()
: parentNode.getNextSibling();
return anchorSibling && $isTableNode(anchorSibling)
? anchorSibling
: direction === 'backward'
? tableNode.getPreviousSibling()
: tableNode.getNextSibling();
}
function $insertParagraphAtTableEdge(
edgePosition: 'first' | 'last',
tableNode: TableNode,
children?: LexicalNode[],
) {
const paragraphNode = $createParagraphNode();
if (edgePosition === 'first') {
tableNode.insertBefore(paragraphNode);
} else {
tableNode.insertAfter(paragraphNode);
}
paragraphNode.append(...(children || []));
paragraphNode.selectEnd();
}
function $getTableEdgeCursorPosition(
editor: LexicalEditor,
selection: RangeSelection,
tableNode: TableNode,
) {
const tableNodeParent = tableNode.getParent();
if (!tableNodeParent) {
return undefined;
}
const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey());
if (!tableNodeParentDOM) {
return undefined;
}
// TODO: Add support for nested tables
const domSelection = window.getSelection();
if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) {
return undefined;
}
const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) =>
$isTableCellNode(n),
) as TableCellNode | null;
if (!anchorCellNode) {
return undefined;
}
const parentTable = $findMatchingParent(anchorCellNode, (n) =>
$isTableNode(n),
);
if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) {
return undefined;
}
const [tableMap, cellValue] = $computeTableMap(
tableNode,
anchorCellNode,
anchorCellNode,
);
const firstCell = tableMap[0][0];
const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1];
const {startRow, startColumn} = cellValue;
const isAtFirstCell =
startRow === firstCell.startRow && startColumn === firstCell.startColumn;
const isAtLastCell =
startRow === lastCell.startRow && startColumn === lastCell.startColumn;
if (isAtFirstCell) {
return 'first';
} else if (isAtLastCell) {
return 'last';
} else {
return undefined;
}
}