UNPKG

@lexical/table

Version:

This package provides the Table feature for Lexical.

1,656 lines (1,517 loc) 81 kB
/** * 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