@lexical/table
Version:
This package provides the Table feature for Lexical.
1,656 lines (1,517 loc) • 81 kB
text/typescript
/**
* 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 {
$getClipboardDataFromSelection,
copyToClipboard,
} from '@lexical/clipboard';
import invariant from '@lexical/internal/invariant';
import {objectKlassEquals} from '@lexical/utils';
import {
$caretFromPoint,
$createParagraphNode,
$createRangeSelectionFromDom,
$createTextNode,
$extendCaretToRange,
$findMatchingParent,
$getAdjacentChildCaret,
$getChildCaret,
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getNodeByKeyOrThrow,
$getPreviousSelection,
$getSelection,
$getSiblingCaret,
$isChildCaret,
$isElementNode,
$isExtendableTextPointCaret,
$isRangeSelection,
$isRootOrShadowRoot,
$isSiblingCaret,
$isTextNode,
$normalizeCaret,
$setPointFromCaret,
$setSelection,
addClassNamesToElement,
type BaseSelection,
type CaretDirection,
type ChildCaret,
COMMAND_PRIORITY_HIGH,
CONTROLLED_TEXT_INSERTION_COMMAND,
COPY_COMMAND,
CUT_COMMAND,
DELETE_CHARACTER_COMMAND,
DELETE_LINE_COMMAND,
DELETE_WORD_COMMAND,
type EditorState,
type ElementNode,
FOCUS_COMMAND,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
getActiveElement,
getComposedEventTarget,
getDOMSelection,
getDOMSelectionPoints,
getDOMSelectionRange,
INSERT_PARAGRAPH_COMMAND,
IS_FIREFOX,
isDOMDocumentNode,
isDOMNode,
isDOMShadowRoot,
isHTMLElement,
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,
type LexicalCommand,
type LexicalEditor,
type LexicalNode,
type NodeKey,
PASTE_COMMAND,
type PointCaret,
type RangeSelection,
registerEventListener,
removeClassNamesFromElement,
SELECTION_CHANGE_COMMAND,
type SiblingCaret,
} from 'lexical';
import {$isTableCellNode, type TableCellNode} from './LexicalTableCellNode';
import {
$getElementForTableNode,
$isScrollableTablesActive,
$isTableNode,
type TableNode,
} from './LexicalTableNode';
import {
type TableDOMCell,
type TableDOMRows,
type TableDOMTable,
TableObserver,
type TableObservers,
} from './LexicalTableObserver';
import {$isTableRowNode} from './LexicalTableRowNode';
import {
$isTableSelection,
type TableMapType,
type TableMapValueType,
type TableSelection,
} from './LexicalTableSelection';
import {
$computeTableCellRectBoundary,
$computeTableCellRectSpans,
$computeTableMap,
$getNodeTriplet,
type TableCellRectBoundary,
} from './LexicalTableUtils';
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
function $getTableNodeByKeyOrThrow(key: NodeKey): TableNode {
const tableNode = $getNodeByKeyOrThrow(key);
invariant($isTableNode(tableNode), 'Expected TableNode for key %s', key);
return tableNode;
}
const isPointerDownOnEvent = (event: PointerEvent) => {
return (event.buttons & 1) === 1;
};
// Distance (px) from a scroll container edge at which drag auto-scroll kicks
// in, and the maximum scroll delta applied per animation frame.
const AUTO_SCROLL_EDGE_ZONE = 40;
const AUTO_SCROLL_MAX_STEP = 18;
// Given a pointer position and the start/end edges of a scroll container on one
// axis, return the signed per-frame scroll delta: negative near the start edge,
// positive near the end edge, 0 while outside both edge zones. The delta ramps
// up the deeper the pointer is into the zone (and is capped once it reaches or
// passes the edge).
function autoScrollStep(pos: number, start: number, end: number): number {
const speed = (depth: number) =>
Math.max(
1,
Math.ceil(
(Math.min(AUTO_SCROLL_EDGE_ZONE, depth) / AUTO_SCROLL_EDGE_ZONE) *
AUTO_SCROLL_MAX_STEP,
),
);
if (pos <= start + AUTO_SCROLL_EDGE_ZONE) {
return -speed(start + AUTO_SCROLL_EDGE_ZONE - pos);
}
if (pos >= end - AUTO_SCROLL_EDGE_ZONE) {
return speed(pos - (end - AUTO_SCROLL_EDGE_ZONE));
}
return 0;
}
export function isHTMLTableElement(el: unknown): el is HTMLTableElement {
return isHTMLElement(el) && el.nodeName === 'TABLE';
}
export function getTableElement<T extends HTMLElement | null>(
tableNode: TableNode,
dom: T,
): HTMLTableElementWithWithTableSelectionState | (T & null) {
if (!dom) {
return dom as T & null;
}
const element: null | HTMLTableElementWithWithTableSelectionState =
isHTMLTableElement(dom) ? dom : dom.querySelector('table');
invariant(
isHTMLTableElement(element),
'getTableElement: Expecting table in DOM node for %s of type %s with key %s, not %s',
tableNode.constructor.name,
tableNode.getType(),
tableNode.getKey(),
dom.nodeName,
);
return element;
}
export function getEditorWindow(editor: LexicalEditor): Window | null {
return editor._window;
}
export function $findParentTableCellNodeInTable(
tableNode: LexicalNode,
node: LexicalNode | null,
): TableCellNode | null {
for (
let currentNode = node, lastTableCellNode: TableCellNode | null = null;
currentNode !== null;
currentNode = currentNode.getParent()
) {
if (tableNode.is(currentNode)) {
return lastTableCellNode;
} else if ($isTableCellNode(currentNode)) {
lastTableCellNode = currentNode;
}
}
return null;
}
const ARROW_KEY_COMMANDS_WITH_DIRECTION = [
[KEY_ARROW_DOWN_COMMAND, 'down'],
[KEY_ARROW_UP_COMMAND, 'up'],
[KEY_ARROW_LEFT_COMMAND, 'backward'],
[KEY_ARROW_RIGHT_COMMAND, 'forward'],
] as const;
const DELETE_TEXT_COMMANDS = [
DELETE_WORD_COMMAND,
DELETE_LINE_COMMAND,
DELETE_CHARACTER_COMMAND,
] as const;
const DELETE_KEY_COMMANDS = [
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
] as const;
export function registerTableWindowHandlers(
editor: LexicalEditor,
tableObservers: TableObservers,
) {
// Use registerRootListener so the pointerdown handler is (re)attached
// whenever the root element is set. This is required for the Extension API,
// where register() runs before the ContentEditable mounts and getRootElement()
// is still null.
return editor.registerRootListener(rootElement => {
if (rootElement === null) {
return;
}
const editorWindow = editor._window;
if (editorWindow === null) {
return;
}
const pointerDownCallback = (event: PointerEvent) => {
// Listener is on editorWindow; the composed target is needed so the
// rootElement.contains check below sees the shadow-internal target.
const target = getComposedEventTarget(event);
if (
event.button !== 0 ||
!isDOMNode(target) ||
!rootElement.contains(target)
) {
return;
}
const selectionInfo = getTableObserverFromCellNode(target);
editor.update(() => {
// Clear highlights from all tables (even one we're actively clicking on)
const selection = $getSelection();
if ($isTableSelection(selection)) {
for (const [observer] of tableObservers.observers.values()) {
observer.$clearHighlight(false);
}
$setSelection(null);
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}
if (!selectionInfo) {
return;
}
const {tableObserver, tableElement, cellElement} = selectionInfo;
$handleTableClick(
editor,
event,
cellElement,
tableElement,
tableObserver,
tableObservers,
);
});
};
return registerEventListener(
editorWindow,
'pointerdown',
pointerDownCallback,
);
});
}
function $handleTableClick(
editor: LexicalEditor,
event: PointerEvent,
selectedDOMCell: TableDOMCell,
tableElement: HTMLTableElementWithWithTableSelectionState,
tableObserver: TableObserver,
tableObservers: TableObservers,
) {
const editorWindow = editor._window;
if (!editorWindow) {
return;
}
const createPointerHandlers = (startingCell: TableDOMCell | null) => {
if (tableObserver.isSelecting) {
return;
}
tableObserver.isSelecting = true;
// Set anchor immediately if starting cell provided (handles direct drag without click)
if (startingCell !== null && tableObserver.anchorCell === null) {
editor.update(() => {
tableObserver.$setAnchorCellForSelection(startingCell);
});
}
let lastClientX = event.clientX;
let lastClientY = event.clientY;
let autoScrollRafId: number | null = null;
const stopSelecting = () => {
tableObserver.isSelecting = false;
if (autoScrollRafId !== null) {
editorWindow.cancelAnimationFrame(autoScrollRafId);
autoScrollRafId = null;
}
editorWindow.removeEventListener('pointerup', onPointerUp);
editorWindow.removeEventListener('pointermove', onPointerMove);
};
// Resolve the table cell under the given viewport coordinates via the
// table's own root so elementsFromPoint isn't retargeted; narrow with the
// type guards rather than casting so the detached-table case (Node) falls
// through to no hit-test.
const resolveFocusCellFromPoint = (
clientX: number,
clientY: number,
): TableDOMCell | null => {
const tableRoot = tableElement.getRootNode();
if (!isDOMDocumentNode(tableRoot) && !isDOMShadowRoot(tableRoot)) {
return null;
}
for (const el of tableRoot.elementsFromPoint(clientX, clientY)) {
const cell = getDOMCellInTableFromTarget(tableElement, el);
if (cell) {
return cell;
}
}
return null;
};
const applyFocusCell = (focusCell: TableDOMCell, override: boolean) => {
// Fallback: set anchor if still missing (handles race conditions)
if (tableObserver.anchorCell === null) {
editor.update(() => {
tableObserver.$setAnchorCellForSelection(focusCell);
});
}
if (
tableObserver.focusCell === null ||
focusCell.elem !== tableObserver.focusCell.elem
) {
tableObservers.setNextFocus({
focusCell,
override,
tableKey: tableObserver.tableNodeKey,
});
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}
};
// Walk up from the table to the nearest ancestor that can actually scroll
// on the requested axis (the scrollable-tables wrapper for 'x'). Returns
// null when none is found, in which case the caller may fall back to the
// window.
const findScrollContainer = (axis: 'x' | 'y'): HTMLElement | null => {
for (
let el: HTMLElement | null = tableElement.parentElement;
el;
el = el.parentElement
) {
const canScroll =
axis === 'x'
? el.scrollWidth > el.clientWidth
: el.scrollHeight > el.clientHeight;
if (canScroll) {
const style = editorWindow.getComputedStyle(el);
const overflow = axis === 'x' ? style.overflowX : style.overflowY;
if (overflow === 'auto' || overflow === 'scroll') {
return el;
}
}
}
return null;
};
// Scroll `container` (or the window when null) on `axis` if the pointer is
// within the edge zone. Returns whether it actually scrolled.
const scrollAxis = (
container: HTMLElement | null,
pos: number,
axis: 'x' | 'y',
): boolean => {
let start: number;
let end: number;
if (container === null) {
start = 0;
end = axis === 'x' ? editorWindow.innerWidth : editorWindow.innerHeight;
} else {
const rect = container.getBoundingClientRect();
start = axis === 'x' ? rect.left : rect.top;
end = axis === 'x' ? rect.right : rect.bottom;
}
const step = autoScrollStep(pos, start, end);
if (step === 0) {
return false;
}
if (container === null) {
const before =
axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY;
editorWindow.scrollBy(axis === 'x' ? step : 0, axis === 'x' ? 0 : step);
return (
(axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY) !==
before
);
}
if (axis === 'x') {
const before = container.scrollLeft;
container.scrollLeft += step;
return container.scrollLeft !== before;
}
const before = container.scrollTop;
container.scrollTop += step;
return container.scrollTop !== before;
};
// Clamp the last pointer position into the visible bounds of the scroll
// container(s) so a pointer dragged past an edge still hit-tests onto the
// newly-revealed cell instead of empty space beyond the table.
const clampHitPoint = (
hContainer: HTMLElement | null,
vContainer: HTMLElement | null,
): [number, number] => {
let x = lastClientX;
let y = lastClientY;
if (hContainer === null) {
x = Math.min(Math.max(x, 1), editorWindow.innerWidth - 1);
} else {
const rect = hContainer.getBoundingClientRect();
x = Math.min(Math.max(x, rect.left + 1), rect.right - 1);
}
if (vContainer === null) {
y = Math.min(Math.max(y, 1), editorWindow.innerHeight - 1);
} else {
const rect = vContainer.getBoundingClientRect();
y = Math.min(Math.max(y, rect.top + 1), rect.bottom - 1);
}
return [x, y];
};
const isNearScrollEdge = (): boolean => {
const hContainer = findScrollContainer('x');
if (hContainer !== null) {
const rect = hContainer.getBoundingClientRect();
if (autoScrollStep(lastClientX, rect.left, rect.right) !== 0) {
return true;
}
}
const vContainer = findScrollContainer('y');
const vStart =
vContainer === null ? 0 : vContainer.getBoundingClientRect().top;
const vEnd =
vContainer === null
? editorWindow.innerHeight
: vContainer.getBoundingClientRect().bottom;
return autoScrollStep(lastClientY, vStart, vEnd) !== 0;
};
const tickAutoScroll = () => {
autoScrollRafId = null;
if (!tableObserver.isSelecting) {
return;
}
const hContainer = findScrollContainer('x');
const vContainer = findScrollContainer('y');
// Only the table's own wrapper scrolls horizontally; pages don't
// auto-scroll sideways. Vertically we fall back to the window.
const scrolledX =
hContainer !== null && scrollAxis(hContainer, lastClientX, 'x');
const scrolledY = scrollAxis(vContainer, lastClientY, 'y');
if (scrolledX || scrolledY) {
const [hitX, hitY] = clampHitPoint(hContainer, vContainer);
const focusCell = resolveFocusCellFromPoint(hitX, hitY);
if (focusCell) {
applyFocusCell(focusCell, false);
}
autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll);
}
};
const maybeStartAutoScroll = () => {
// Touch taps don't initiate table selection, so they shouldn't scroll.
if (
autoScrollRafId !== null ||
tableObserver.pointerType === 'touch' ||
!isNearScrollEdge()
) {
return;
}
autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll);
};
const onPointerUp = () => {
stopSelecting();
};
const onPointerMove = (moveEvent: PointerEvent) => {
if (!isPointerDownOnEvent(moveEvent) && tableObserver.isSelecting) {
stopSelecting();
return;
}
const moveTarget = getComposedEventTarget(moveEvent);
if (!isDOMNode(moveTarget)) {
return;
}
lastClientX = moveEvent.clientX;
lastClientY = moveEvent.clientY;
let focusCell: null | TableDOMCell = null;
// In firefox the moveEvent.target may be captured so we must always
// consult the coordinates #7245
const override = !(IS_FIREFOX || tableElement.contains(moveTarget));
if (override) {
focusCell = getDOMCellInTableFromTarget(tableElement, moveTarget);
} else {
focusCell = resolveFocusCellFromPoint(
moveEvent.clientX,
moveEvent.clientY,
);
}
if (focusCell) {
applyFocusCell(focusCell, override);
}
// Keep the selection reachable when dragging toward/past an edge of a
// scrollable table (#7153).
maybeStartAutoScroll();
};
editorWindow.addEventListener(
'pointerup',
onPointerUp,
tableObserver.listenerOptions,
);
editorWindow.addEventListener(
'pointermove',
onPointerMove,
tableObserver.listenerOptions,
);
};
tableObserver.pointerType = event.pointerType;
const tableNode = $getTableNodeByKeyOrThrow(tableObserver.tableNodeKey);
const prevSelection = $getPreviousSelection();
// We can't trust Firefox to do the right thing with the selection and
// we don't have a proper state machine to do this "correctly" but
// if we go ahead and make the table selection now it will work
if (
IS_FIREFOX &&
event.shiftKey &&
$isSelectionInTable(prevSelection, tableNode) &&
($isRangeSelection(prevSelection) || $isTableSelection(prevSelection))
) {
const prevAnchorNode = prevSelection.anchor.getNode();
const prevAnchorCell = $findParentTableCellNodeInTable(
tableNode,
prevSelection.anchor.getNode(),
);
if (prevAnchorCell) {
tableObserver.$setAnchorCellForSelection(
$getObserverCellFromCellNodeOrThrow(tableObserver, prevAnchorCell),
);
tableObserver.$setFocusCellForSelection(selectedDOMCell);
stopEvent(event);
} else {
const newSelection = tableNode.isBefore(prevAnchorNode)
? tableNode.selectStart()
: tableNode.selectEnd();
newSelection.anchor.set(
prevSelection.anchor.key,
prevSelection.anchor.offset,
prevSelection.anchor.type,
);
}
} else {
// Only set anchor cell for selection if this is not a simple touch tap
// Touch taps should not initiate table selection mode
if (event.pointerType !== 'touch') {
tableObserver.$setAnchorCellForSelection(selectedDOMCell);
}
}
// Pass the target cell to createPointerHandlers so it can be used as anchor
// if user drags directly without clicking first
createPointerHandlers(selectedDOMCell);
}
export function applyTableHandlers(
tableNode: TableNode,
element: HTMLElement,
editor: LexicalEditor,
hasTabHandler: boolean,
tableObservers: TableObservers,
): TableObserver {
const rootElement = editor.getRootElement();
const editorWindow = getEditorWindow(editor);
invariant(
rootElement !== null && editorWindow !== null,
'applyTableHandlers: editor has no root element set',
);
const tableObserver = new TableObserver(editor, tableNode.getKey());
const tableElement = getTableElement(tableNode, element);
attachTableObserverToTableElement(tableElement, tableObserver);
tableObserver.listenersToRemove.add(() =>
detachTableObserverFromTableElement(tableElement, tableObserver),
);
const onTripleClick = (event: MouseEvent) => {
const target = getComposedEventTarget(event);
if (event.detail >= 3 && isDOMNode(target)) {
const targetCell = getDOMCellFromTarget(target);
if (targetCell !== null) {
event.preventDefault();
}
}
};
tableObserver.listenersToRemove.add(
registerEventListener(
tableElement,
'mousedown',
onTripleClick,
tableObserver.listenerOptions,
),
);
for (const [command, direction] of ARROW_KEY_COMMANDS_WITH_DIRECTION) {
tableObserver.listenersToRemove.add(
editor.registerCommand(
command,
event =>
$handleArrowKey(
editor,
event,
direction,
tableNode,
tableObserver,
tableObservers,
),
COMMAND_PRIORITY_HIGH,
),
);
}
tableObserver.listenersToRemove.add(
editor.registerCommand(
KEY_ESCAPE_COMMAND,
event => {
const selection = $getSelection();
if ($isTableSelection(selection)) {
const focusCellNode = $findParentTableCellNodeInTable(
tableNode,
selection.focus.getNode(),
);
if (focusCellNode !== null) {
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 = $findParentTableCellNodeInTable(
tableNode,
selection.anchor.getNode(),
);
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;
};
for (const command of DELETE_TEXT_COMMANDS) {
tableObserver.listenersToRemove.add(
editor.registerCommand(
command,
deleteTextHandler(command),
COMMAND_PRIORITY_HIGH,
),
);
}
const $deleteCellHandler = (
event: KeyboardEvent | ClipboardEvent | null,
): boolean => {
const selection = $getSelection();
if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
return false;
}
// If the selection is inside the table but should remove the whole table
// we expand the selection so that both the anchor and focus are outside
// the table and the editor's command listener will handle the delete
const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode());
const isFocusInside = tableNode.isParentOf(selection.focus.getNode());
if (isAnchorInside !== isFocusInside) {
const tablePoint = isAnchorInside ? 'anchor' : 'focus';
const outerPoint = isAnchorInside ? 'focus' : 'anchor';
// Preserve the outer point
const {key, offset, type} = selection[outerPoint];
// Expand the selection around the table
const newSelection =
tableNode[
selection[tablePoint].isBefore(selection[outerPoint])
? 'selectPrevious'
: 'selectNext'
]();
// Restore the outer point of the selection
newSelection[outerPoint].set(key, offset, type);
// Let the base implementation handle the rest
return false;
}
if (!$isSelectionInTable(selection, tableNode)) {
return false;
}
if ($isTableSelection(selection)) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
tableObserver.$clearText();
return true;
}
return false;
};
for (const command of DELETE_KEY_COMMANDS) {
tableObserver.listenersToRemove.add(
editor.registerCommand(
command,
$deleteCellHandler,
COMMAND_PRIORITY_HIGH,
),
);
}
tableObserver.listenersToRemove.add(
editor.registerCommand(
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 : null,
$getClipboardDataFromSelection(selection),
);
const intercepted = $deleteCellHandler(event);
if ($isRangeSelection(selection)) {
selection.removeText();
return true;
}
return intercepted;
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
// When TableSelection clears native selection ranges (removeAllRanges),
// browsers redirect paste events to <body> instead of the rootElement.
// Intercept at the document level and forward to PASTE_COMMAND.
const doc = rootElement.ownerDocument;
tableObserver.listenersToRemove.add(
registerEventListener(doc, 'paste', (event: ClipboardEvent) => {
if (event.defaultPrevented) {
return;
}
const shouldIntercept = editor.read('latest', () => {
const selection = $getSelection();
return (
rootElement.contains(doc.activeElement) &&
$isTableSelection(selection) &&
$isSelectionInTable(selection, tableNode)
);
});
if (shouldIntercept) {
event.preventDefault();
editor.dispatchCommand(PASTE_COMMAND, event);
}
}),
);
// In read-only mode (contentEditable=false), Firefox fires the native copy
// event on the document rather than on the root element, so the core
// PASS_THROUGH copy listener never sees it. We intercept at the document
// level and forward to COPY_COMMAND. Unlike the paste listener above, we
// skip events whose target is inside the rootElement — those are already
// handled by the core copy listener which runs regardless of isEditable.
tableObserver.listenersToRemove.add(
registerEventListener(doc, 'copy', (event: ClipboardEvent) => {
if (event.defaultPrevented) {
return;
}
const target = getComposedEventTarget(event);
if (
target === rootElement ||
(isDOMNode(target) && rootElement.contains(target))
) {
return;
}
const shouldIntercept = editor.read('latest', () => {
const selection = $getSelection();
return (
rootElement.contains(getActiveElement(rootElement)) &&
$isTableSelection(selection) &&
$isSelectionInTable(selection, tableNode)
);
});
if (shouldIntercept) {
event.preventDefault();
editor.dispatchCommand(COPY_COMMAND, event);
}
}),
);
tableObserver.listenersToRemove.add(
editor.registerCommand(
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_HIGH,
),
);
tableObserver.listenersToRemove.add(
editor.registerCommand(
FORMAT_ELEMENT_COMMAND,
formatType => {
const selection = $getSelection();
if (
!$isTableSelection(selection) ||
!$isSelectionInTable(selection, tableNode)
) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) {
return false;
}
// Align the table if the entire table is selected
if ($isFullTableSelection(selection, tableNode)) {
tableNode.setFormat(formatType);
return true;
}
const [tableMap, anchorCell, focusCell] = $computeTableMap(
tableNode,
anchorNode,
focusNode,
);
const maxRow = Math.max(
anchorCell.startRow + anchorCell.cell.__rowSpan - 1,
focusCell.startRow + focusCell.cell.__rowSpan - 1,
);
const maxColumn = Math.max(
anchorCell.startColumn + anchorCell.cell.__colSpan - 1,
focusCell.startColumn + focusCell.cell.__colSpan - 1,
);
const minRow = Math.min(anchorCell.startRow, focusCell.startRow);
const minColumn = Math.min(
anchorCell.startColumn,
focusCell.startColumn,
);
const visited = new Set<TableCellNode>();
for (let i = minRow; i <= maxRow; i++) {
for (let j = minColumn; j <= maxColumn; j++) {
const cell = tableMap[i][j].cell;
if (visited.has(cell)) {
continue;
}
visited.add(cell);
cell.setFormat(formatType);
const cellChildren = cell.getChildren();
for (let k = 0; k < cellChildren.length; k++) {
const child = cellChildren[k];
if ($isElementNode(child) && !child.isInline()) {
child.setFormat(formatType);
}
}
}
}
return true;
},
COMMAND_PRIORITY_HIGH,
),
);
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_HIGH,
),
);
if (hasTabHandler) {
tableObserver.listenersToRemove.add(
editor.registerCommand(
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 ||
!tableNode.is($findTableNode(tableCellNode))
) {
return false;
}
stopEvent(event);
$selectAdjacentCell(
tableCellNode,
event.shiftKey ? 'previous' : 'next',
);
return true;
},
COMMAND_PRIORITY_HIGH,
),
);
}
tableObserver.listenersToRemove.add(
editor.registerCommand(
FOCUS_COMMAND,
payload => {
return tableNode.isSelected();
},
COMMAND_PRIORITY_HIGH,
),
);
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_HIGH,
),
);
return tableObserver;
}
/** @internal */
export function $handleTableSelectionChangeCommand(
tableObservers: TableObservers,
editor: LexicalEditor,
) {
const selection = $getSelection();
const prevSelection = $getPreviousSelection();
const nextFocus = tableObservers.getAndClearNextFocus();
if (nextFocus !== null) {
const {tableKey, focusCell} = nextFocus;
const observerAndTable = tableObservers.observers.get(tableKey);
invariant(
!!observerAndTable,
'tableObserver not found for tableKey: %s',
tableKey,
);
const [tableObserver] = observerAndTable;
if (
$isTableSelection(selection) &&
selection.tableKey === tableObserver.tableNodeKey
) {
if (
focusCell.x === tableObserver.focusX &&
focusCell.y === tableObserver.focusY
) {
// The selection is already the correct table selection
return false;
} else {
tableObserver.$setFocusCellForSelection(focusCell);
return true;
}
} else if (
tableObserver.anchorCell !== null &&
tableObserver.anchorCellNodeKey !== null &&
focusCell.elem !== tableObserver.anchorCell.elem &&
tableObserver.tableSelection !== null
) {
// The selection has crossed cells
// If we have an anchor cell set and tableSelection initialized,
// we have all the necessary state to create the selection.
// The presence of nextFocus means we're dragging, so process it.
// Use ignoreStart=true to ensure isHighlightingCells is set correctly
// on the first drag attempt, especially when switching columns.
tableObserver.$setFocusCellForSelection(focusCell, true);
return true;
}
}
const shouldCheckSelectionForTable =
tableObservers.getAndClearShouldCheckSelectionForTable();
// If they pressed the down arrow with the selection outside of the
// table, and then the selection ends up in the table but not in the
// first cell, then move the selection to the first cell.
if (
!!shouldCheckSelectionForTable &&
$isRangeSelection(prevSelection) &&
$isRangeSelection(selection) &&
selection.isCollapsed()
) {
// The table may have been removed before this selection change
// was dispatched, in which case there is nothing to check
const tableNode = $getNodeByKey(shouldCheckSelectionForTable);
if ($isTableNode(tableNode)) {
const anchor = selection.anchor.getNode();
const firstRow = tableNode.getFirstChild();
const anchorCell = $findCellNode(anchor);
if (anchorCell !== null && $isTableRowNode(firstRow)) {
const firstCell = firstRow.getFirstChild();
if (
$isTableCellNode(firstCell) &&
tableNode.is(
$findMatchingParent(
anchorCell,
node => node.is(tableNode) || node.is(firstCell),
),
)
) {
// The selection moved to the table, but not in the first cell
firstCell.selectStart();
return true;
}
}
}
}
if ($isTableSelection(selection)) {
$fixTableSelectionForSelectedTable(editor, selection);
}
if ($isRangeSelection(selection)) {
$fixRangeSelectionForSelectedTable(selection, tableObservers);
}
// Generic selection logic that runs across every table observer when the selection changes.
// Note: the selection might have changed in the code above, which re-dispatches the selection change command
// and gets handled here on the second pass. This should be refactored.
for (const [
tableNode,
tableObserver,
] of tableObservers.$getTableNodesAndObservers()) {
$syncTableSelectionState(editor, tableNode, tableObserver);
}
return false;
}
/**
* Handles cases where range selections cross into, out of, or within tables.
*/
function $fixRangeSelectionForSelectedTable(
selection: RangeSelection,
tableObservers: TableObservers,
) {
const prevSelection = $getPreviousSelection();
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 anchorCellTable = anchorCellNode
? $findTableNode(anchorCellNode)
: null;
const focusCellTable = focusCellNode ? $findTableNode(focusCellNode) : null;
const isBackward = selection.isBackward();
const isSameTable =
anchorCellNode &&
focusCellNode &&
anchorCellTable &&
focusCellTable &&
anchorCellTable.is(focusCellTable);
// The focus should be moved (to cover the whole focus table) if it is moved outside of the anchor's table.
// For example, when dragging from outside a table into it.
const shouldMoveFocus =
focusCellTable &&
(!anchorCellTable || anchorCellTable.isParentOf(focusCellTable));
// The anchor should be moved (to cover the whole anchor table) if the focus is moved outside of the anchor table.
// For example, when dragging from inside a table out of it.
const shouldMoveAnchor =
anchorCellTable &&
(!focusCellTable || focusCellTable.isParentOf(anchorCellTable));
if (shouldMoveFocus) {
// Select the whole focus table.
const newSelection = selection.clone();
const [tableMap] = $computeTableMap(
focusCellTable,
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 ? 0 : lastCell.getChildrenSize(),
'element',
);
$setSelection(newSelection);
} else if (shouldMoveAnchor) {
// Select the whole anchor table.
const newSelection = selection.clone();
const [tableMap] = $computeTableMap(
anchorCellTable,
anchorCellNode!,
anchorCellNode!,
);
const firstCell = tableMap[0][0].cell;
const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell;
newSelection.anchor.set(
isBackward ? lastCell.getKey() : firstCell.getKey(),
isBackward ? lastCell.getChildrenSize() : 0,
'element',
);
$setSelection(newSelection);
} else if (isSameTable) {
// Handle case when selection spans across multiple cells but still
// has range selection, then we convert it into table selection
// For example, this fires when dragging up from first cell, outside of the table, or when clicking a cell
// then shift-clicking another cell.
const observerInfo = tableObservers.observers.get(anchorCellTable.getKey());
invariant(
!!observerInfo,
'tableObserver not found for tableKey: %s',
anchorCellTable.getKey(),
);
const [tableObserver] = observerInfo;
if (!anchorCellNode.is(focusCellNode)) {
tableObserver.$setAnchorCellForSelection(
$getObserverCellFromCellNodeOrThrow(tableObserver, anchorCellNode),
);
tableObserver.$setFocusCellForSelection(
$getObserverCellFromCellNodeOrThrow(tableObserver, focusCellNode),
true,
);
}
// Handle case when the pointer type is touch and the current and
// previous selection are collapsed, and the previous anchor and current
// focus cell nodes are different, then we convert it into table selection
// However, only do this if the table observer is actively selecting (user dragging)
// to prevent unwanted selections when simply tapping between cells on mobile
if (
tableObserver.pointerType === 'touch' &&
tableObserver.isSelecting &&
selection.isCollapsed() &&
$isRangeSelection(prevSelection) &&
prevSelection.isCollapsed()
) {
const prevAnchorCellNode = $findCellNode(prevSelection.anchor.getNode());
if (prevAnchorCellNode && !prevAnchorCellNode.is(focusCellNode)) {
tableObserver.$setAnchorCellForSelection(
$getObserverCellFromCellNodeOrThrow(
tableObserver,
prevAnchorCellNode,
),
);
tableObserver.$setFocusCellForSelection(
$getObserverCellFromCellNodeOrThrow(tableObserver, focusCellNode),
true,
);
tableObserver.pointerType = null;
}
}
}
}
/**
* Ensures that a TableSelection is automatically changed to a RangeSelection when the selection goes outside of the table.
*/
function $fixTableSelectionForSelectedTable(
editor: LexicalEditor,
selection: TableSelection,
) {
const editorWindow = getEditorWindow(editor);
const prevSelection = $getPreviousSelection();
if (!selection.is(prevSelection)) {
return;
}
const tableNode = $getTableNodeByKeyOrThrow(selection.tableKey);
// if selection goes outside of the table we need to change it to Range selection
const domSelection = getDOMSelection(editorWindow);
const points =
domSelection &&
getDOMSelectionPoints(domSelection, editor.getRootElement());
if (domSelection && points && points.anchorNode && points.focusNode) {
const focusNode = $getNearestNodeFromDOMNode(points.focusNode);
const isFocusOutside = focusNode && !tableNode.isParentOf(focusNode);
const anchorNode = $getNearestNodeFromDOMNode(points.anchorNode);
const isAnchorInside = anchorNode && tableNode.isParentOf(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);
}
}
}
}
// Handle keeping the table observer/DOM in sync with the selection.
function $syncTableSelectionState(
editor: LexicalEditor,
tableNode: TableNode,
tableObserver: TableObserver,
) {
const selection = $getSelection();
const prevSelection = $getPreviousSelection();
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);
}
}
if (tableObserver.hasHijackedSelectionStyles && !tableNode.isSelected()) {
$removeHighlightStyleToTable(editor, tableObserver);
} else if (
!tableObserver.hasHijackedSelectionStyles &&
tableNode.isSelected()
) {
$addHighlightStyleToTable(editor, tableObserver);
}
}
export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement & {
[LEXICAL_ELEMENT_KEY]?: TableObserver | undefined;
};
export function detachTableObserverFromTableElement(
tableElement: HTMLTableElementWithWithTableSelectionState,
tableObserver: TableObserver,
) {
if (getTableObserverFromTableElement(tableElement) === tableObserver) {
delete tableElement[LEXICAL_ELEMENT_KEY];
}
}
export function attachTableObserverToTableElement(
tableElement: HTMLTableElementWithWithTableSelectionState,
tableObserver: TableObserver,
) {
invariant(
getTableObserverFromTableElement(tableElement) === null,
'tableElement already has an attached TableObserver',
);
tableElement[LEXICAL_ELEMENT_KEY] = tableObserver;
}
export function getTableObserverFromTableElement(
tableElement: HTMLTableElementWithWithTableSelectionState,
): TableObserver | null {
return tableElement[LEXICAL_ELEMENT_KEY] || null;
}
function getTableObserverFromCellNode(node: null | Node): {
tableObserver: TableObserver;
tableElement: HTMLTableElementWithWithTableSelectionState;
cellElement: TableDOMCell;
} | null {
const cellNode = getDOMCellFromTarget(node);
if (cellNode === null) {
return null;
}
let currentNode: ParentNode | Node | null = cellNode.elem;
while (currentNode != null) {
const nodeName = currentNode.nodeName;
if (
nodeName === 'TABLE' &&
LEXICAL_ELEMENT_KEY in currentNode &&
!!currentNode[LEXICAL_ELEMENT_KEY]
) {
return {
cellElement: cellNode,
tableElement:
currentNode as HTMLTableElementWithWithTableSelectionState,
tableObserver: currentNode[LEXICAL_ELEMENT_KEY] as TableObserver,
};
}
currentNode = currentNode.parentNode;
}
return null;
}
export function getDOMCellFromTarget(node: null | 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 getDOMCellInTableFromTarget(
table: HTMLTableElementWithWithTableSelectionState,
node: null | Node,
): TableDOMCell | null {
if (!table.contains(node)) {
return null;
}
let cell: null | TableDOMCell = null;
for (
let currentNode: ParentNode | Node | null = node;
currentNode != null;
currentNode = currentNode.parentNode
) {
if (currentNode === table) {
return cell;
}
const nodeName = currentNode.nodeName;
if (nodeName === 'TD' || nodeName === 'TH') {
// @ts-expect-error: internal field
cell = currentNode._cell || null;
}
}
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(
tableNode: TableNode,
dom: HTMLElement,
): TableDOMTable {
const tableElement = getTableElement(tableNode, dom);
const domRows: TableDOMRows = [];
const grid = {
columns: 0,
domRows,
rows: 0,
};
let currentNode = tableElement.querySelector('tr') as ChildNode | null;
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) {
contin