/** * 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 {LexicalEditor, NodeKey, TextFormatType} from 'lexical'; import { addClassNamesToElement, removeClassNamesFromElement, } from '@lexical/utils'; import { $createParagraphNode, $createRangeSelection, $createTextNode, $getNearestNodeFromDOMNode, $getNodeByKey, $getRoot, $getSelection, $isElementNode, $setSelection, SELECTION_CHANGE_COMMAND, } from 'lexical'; import invariant from 'lexical/shared/invariant'; import {$isTableCellNode} from './LexicalTableCellNode'; import {$isTableNode} from './LexicalTableNode'; import { $createTableSelection, $isTableSelection, type TableSelection, } from './LexicalTableSelection'; import { $findTableNode, $updateDOMForSelection, getDOMSelection, getTable, } from './LexicalTableSelectionHelpers'; export type TableDOMCell = { elem: HTMLElement; highlighted: boolean; hasBackgroundColor: boolean; x: number; y: number; }; export type TableDOMRows = Array | undefined>; export type TableDOMTable = { domRows: TableDOMRows; columns: number; rows: number; }; export class TableObserver { focusX: number; focusY: number; listenersToRemove: Set<() => void>; table: TableDOMTable; isHighlightingCells: boolean; anchorX: number; anchorY: number; tableNodeKey: NodeKey; anchorCell: TableDOMCell | null; focusCell: TableDOMCell | null; anchorCellNodeKey: NodeKey | null; focusCellNodeKey: NodeKey | null; editor: LexicalEditor; tableSelection: TableSelection | null; hasHijackedSelectionStyles: boolean; isSelecting: boolean; constructor(editor: LexicalEditor, tableNodeKey: string) { this.isHighlightingCells = false; this.anchorX = -1; this.anchorY = -1; this.focusX = -1; this.focusY = -1; this.listenersToRemove = new Set(); this.tableNodeKey = tableNodeKey; this.editor = editor; this.table = { columns: 0, domRows: [], rows: 0, }; this.tableSelection = null; this.anchorCellNodeKey = null; this.focusCellNodeKey = null; this.anchorCell = null; this.focusCell = null; this.hasHijackedSelectionStyles = false; this.trackTable(); this.isSelecting = false; } getTable(): TableDOMTable { return this.table; } removeListeners() { Array.from(this.listenersToRemove).forEach((removeListener) => removeListener(), ); } trackTable() { const observer = new MutationObserver((records) => { this.editor.update(() => { let gridNeedsRedraw = false; for (let i = 0; i < records.length; i++) { const record = records[i]; const target = record.target; const nodeName = target.nodeName; if ( nodeName === 'TABLE' || nodeName === 'TBODY' || nodeName === 'THEAD' || nodeName === 'TR' ) { gridNeedsRedraw = true; break; } } if (!gridNeedsRedraw) { return; } const tableElement = this.editor.getElementByKey(this.tableNodeKey); if (!tableElement) { throw new Error('Expected to find TableElement in DOM'); } this.table = getTable(tableElement); }); }); this.editor.update(() => { const tableElement = this.editor.getElementByKey(this.tableNodeKey); if (!tableElement) { throw new Error('Expected to find TableElement in DOM'); } this.table = getTable(tableElement); observer.observe(tableElement, { attributes: true, childList: true, subtree: true, }); }); } clearHighlight() { const editor = this.editor; this.isHighlightingCells = false; this.anchorX = -1; this.anchorY = -1; this.focusX = -1; this.focusY = -1; this.tableSelection = null; this.anchorCellNodeKey = null; this.focusCellNodeKey = null; this.anchorCell = null; this.focusCell = null; this.hasHijackedSelectionStyles = false; this.enableHighlightStyle(); editor.update(() => { const tableNode = $getNodeByKey(this.tableNodeKey); if (!$isTableNode(tableNode)) { throw new Error('Expected TableNode.'); } const tableElement = editor.getElementByKey(this.tableNodeKey); if (!tableElement) { throw new Error('Expected to find TableElement in DOM'); } const grid = getTable(tableElement); $updateDOMForSelection(editor, grid, null); $setSelection(null); editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); }); } enableHighlightStyle() { const editor = this.editor; editor.update(() => { const tableElement = editor.getElementByKey(this.tableNodeKey); if (!tableElement) { throw new Error('Expected to find TableElement in DOM'); } removeClassNamesFromElement( tableElement, editor._config.theme.tableSelection, ); tableElement.classList.remove('disable-selection'); this.hasHijackedSelectionStyles = false; }); } disableHighlightStyle() { const editor = this.editor; editor.update(() => { const tableElement = editor.getElementByKey(this.tableNodeKey); if (!tableElement) { throw new Error('Expected to find TableElement in DOM'); } addClassNamesToElement(tableElement, editor._config.theme.tableSelection); this.hasHijackedSelectionStyles = true; }); } updateTableTableSelection(selection: TableSelection | null): void { if (selection !== null && selection.tableKey === this.tableNodeKey) { const editor = this.editor; this.tableSelection = selection; this.isHighlightingCells = true; this.disableHighlightStyle(); $updateDOMForSelection(editor, this.table, this.tableSelection); } else if (selection == null) { this.clearHighlight(); } else { this.tableNodeKey = selection.tableKey; this.updateTableTableSelection(selection); } } setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) { const editor = this.editor; editor.update(() => { const tableNode = $getNodeByKey(this.tableNodeKey); if (!$isTableNode(tableNode)) { throw new Error('Expected TableNode.'); } const tableElement = editor.getElementByKey(this.tableNodeKey); if (!tableElement) { throw new Error('Expected to find TableElement in DOM'); } const cellX = cell.x; const cellY = cell.y; this.focusCell = cell; if (this.anchorCell !== null) { const domSelection = getDOMSelection(editor._window); // Collapse the selection if (domSelection) { domSelection.setBaseAndExtent( this.anchorCell.elem, 0, this.focusCell.elem, 0, ); } } if ( !this.isHighlightingCells && (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart) ) { this.isHighlightingCells = true; this.disableHighlightStyle(); } else if (cellX === this.focusX && cellY === this.focusY) { return; } this.focusX = cellX; this.focusY = cellY; if (this.isHighlightingCells) { const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem); if ( this.tableSelection != null && this.anchorCellNodeKey != null && $isTableCellNode(focusTableCellNode) && tableNode.is($findTableNode(focusTableCellNode)) ) { const focusNodeKey = focusTableCellNode.getKey(); this.tableSelection = this.tableSelection.clone() || $createTableSelection(); this.focusCellNodeKey = focusNodeKey; this.tableSelection.set( this.tableNodeKey, this.anchorCellNodeKey, this.focusCellNodeKey, ); $setSelection(this.tableSelection); editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); $updateDOMForSelection(editor, this.table, this.tableSelection); } } }); } setAnchorCellForSelection(cell: TableDOMCell) { this.isHighlightingCells = false; this.anchorCell = cell; this.anchorX = cell.x; this.anchorY = cell.y; this.editor.update(() => { const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem); if ($isTableCellNode(anchorTableCellNode)) { const anchorNodeKey = anchorTableCellNode.getKey(); this.tableSelection = this.tableSelection != null ? this.tableSelection.clone() : $createTableSelection(); this.anchorCellNodeKey = anchorNodeKey; } }); } formatCells(type: TextFormatType) { this.editor.update(() => { const selection = $getSelection(); if (!$isTableSelection(selection)) { invariant(false, 'Expected grid selection'); } const formatSelection = $createRangeSelection(); const anchor = formatSelection.anchor; const focus = formatSelection.focus; selection.getNodes().forEach((cellNode) => { if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) { anchor.set(cellNode.getKey(), 0, 'element'); focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); formatSelection.formatText(type); } }); $setSelection(selection); this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); }); } clearText() { const editor = this.editor; editor.update(() => { const tableNode = $getNodeByKey(this.tableNodeKey); if (!$isTableNode(tableNode)) { throw new Error('Expected TableNode.'); } const selection = $getSelection(); if (!$isTableSelection(selection)) { invariant(false, 'Expected grid selection'); } const selectedNodes = selection.getNodes().filter($isTableCellNode); if (selectedNodes.length === this.table.columns * this.table.rows) { tableNode.selectPrevious(); // Delete entire table tableNode.remove(); const rootNode = $getRoot(); rootNode.selectStart(); return; } selectedNodes.forEach((cellNode) => { if ($isElementNode(cellNode)) { const paragraphNode = $createParagraphNode(); const textNode = $createTextNode(); paragraphNode.append(textNode); cellNode.append(paragraphNode); cellNode.getChildren().forEach((child) => { if (child !== paragraphNode) { child.remove(); } }); } }); $updateDOMForSelection(editor, this.table, null); $setSelection(null); editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); }); } }