/** * 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. * */ 'use strict'; var utils = require('@lexical/utils'); var lexical = require('lexical'); var clipboard = require('@lexical/clipboard'); /** * 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. * */ const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/; // .PlaygroundEditorTheme__tableCell width value from // packages/lexical-playground/src/themes/PlaygroundEditorTheme.css const COLUMN_WIDTH = 75; /** * 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. * */ const TableCellHeaderStates = { BOTH: 3, COLUMN: 2, NO_STATUS: 0, ROW: 1 }; /** @noInheritDoc */ class TableCellNode extends lexical.ElementNode { /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ /** @internal */ static getType() { return 'tablecell'; } static clone(node) { return new TableCellNode(node.__headerState, node.__colSpan, node.__width, node.__key); } afterCloneFrom(node) { super.afterCloneFrom(node); this.__rowSpan = node.__rowSpan; this.__backgroundColor = node.__backgroundColor; this.__verticalAlign = node.__verticalAlign; } static importDOM() { return { td: node => ({ conversion: $convertTableCellNodeElement, priority: 0 }), th: node => ({ conversion: $convertTableCellNodeElement, priority: 0 }) }; } static importJSON(serializedNode) { return $createTableCellNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode) { return super.updateFromJSON(serializedNode).setHeaderStyles(serializedNode.headerState).setColSpan(serializedNode.colSpan || 1).setRowSpan(serializedNode.rowSpan || 1).setWidth(serializedNode.width || undefined).setBackgroundColor(serializedNode.backgroundColor || null).setVerticalAlign(serializedNode.verticalAlign || undefined); } constructor(headerState = TableCellHeaderStates.NO_STATUS, colSpan = 1, width, key) { super(key); this.__colSpan = colSpan; this.__rowSpan = 1; this.__headerState = headerState; this.__width = width; this.__backgroundColor = null; this.__verticalAlign = undefined; } createDOM(config) { const element = document.createElement(this.getTag()); if (this.__width) { element.style.width = `${this.__width}px`; } if (this.__colSpan > 1) { element.colSpan = this.__colSpan; } if (this.__rowSpan > 1) { element.rowSpan = this.__rowSpan; } if (this.__backgroundColor !== null) { element.style.backgroundColor = this.__backgroundColor; } if (isValidVerticalAlign(this.__verticalAlign)) { element.style.verticalAlign = this.__verticalAlign; } utils.addClassNamesToElement(element, config.theme.tableCell, this.hasHeader() && config.theme.tableCellHeader); return element; } exportDOM(editor) { const output = super.exportDOM(editor); if (lexical.isHTMLElement(output.element)) { const element = output.element; element.setAttribute('data-temporary-table-cell-lexical-key', this.getKey()); element.style.border = '1px solid black'; if (this.__colSpan > 1) { element.colSpan = this.__colSpan; } if (this.__rowSpan > 1) { element.rowSpan = this.__rowSpan; } element.style.width = `${this.getWidth() || COLUMN_WIDTH}px`; element.style.verticalAlign = this.getVerticalAlign() || 'top'; element.style.textAlign = 'start'; if (this.__backgroundColor === null && this.hasHeader()) { element.style.backgroundColor = '#f2f3f5'; } } return output; } exportJSON() { return { ...super.exportJSON(), ...(isValidVerticalAlign(this.__verticalAlign) && { verticalAlign: this.__verticalAlign }), backgroundColor: this.getBackgroundColor(), colSpan: this.__colSpan, headerState: this.__headerState, rowSpan: this.__rowSpan, width: this.getWidth() }; } getColSpan() { return this.getLatest().__colSpan; } setColSpan(colSpan) { const self = this.getWritable(); self.__colSpan = colSpan; return self; } getRowSpan() { return this.getLatest().__rowSpan; } setRowSpan(rowSpan) { const self = this.getWritable(); self.__rowSpan = rowSpan; return self; } getTag() { return this.hasHeader() ? 'th' : 'td'; } setHeaderStyles(headerState, mask = TableCellHeaderStates.BOTH) { const self = this.getWritable(); self.__headerState = headerState & mask | self.__headerState & ~mask; return self; } getHeaderStyles() { return this.getLatest().__headerState; } setWidth(width) { const self = this.getWritable(); self.__width = width; return self; } getWidth() { return this.getLatest().__width; } getBackgroundColor() { return this.getLatest().__backgroundColor; } setBackgroundColor(newBackgroundColor) { const self = this.getWritable(); self.__backgroundColor = newBackgroundColor; return self; } getVerticalAlign() { return this.getLatest().__verticalAlign; } setVerticalAlign(newVerticalAlign) { const self = this.getWritable(); self.__verticalAlign = newVerticalAlign || undefined; return self; } toggleHeaderStyle(headerStateToToggle) { const self = this.getWritable(); if ((self.__headerState & headerStateToToggle) === headerStateToToggle) { self.__headerState -= headerStateToToggle; } else { self.__headerState += headerStateToToggle; } return self; } hasHeaderState(headerState) { return (this.getHeaderStyles() & headerState) === headerState; } hasHeader() { return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS; } updateDOM(prevNode) { return prevNode.__headerState !== this.__headerState || prevNode.__width !== this.__width || prevNode.__colSpan !== this.__colSpan || prevNode.__rowSpan !== this.__rowSpan || prevNode.__backgroundColor !== this.__backgroundColor || prevNode.__verticalAlign !== this.__verticalAlign; } isShadowRoot() { return true; } collapseAtStart() { return true; } canBeEmpty() { return false; } canIndent() { return false; } } function isValidVerticalAlign(verticalAlign) { return verticalAlign === 'middle' || verticalAlign === 'bottom'; } function $convertTableCellNodeElement(domNode) { const domNode_ = domNode; const nodeName = domNode.nodeName.toLowerCase(); let width = undefined; if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) { width = parseFloat(domNode_.style.width); } const tableCellNode = $createTableCellNode(nodeName === 'th' ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS, domNode_.colSpan, width); tableCellNode.__rowSpan = domNode_.rowSpan; const backgroundColor = domNode_.style.backgroundColor; if (backgroundColor !== '') { tableCellNode.__backgroundColor = backgroundColor; } const verticalAlign = domNode_.style.verticalAlign; if (isValidVerticalAlign(verticalAlign)) { tableCellNode.__verticalAlign = verticalAlign; } const style = domNode_.style; const textDecoration = (style && style.textDecoration || '').split(' '); const hasBoldFontWeight = style.fontWeight === '700' || style.fontWeight === 'bold'; const hasLinethroughTextDecoration = textDecoration.includes('line-through'); const hasItalicFontStyle = style.fontStyle === 'italic'; const hasUnderlineTextDecoration = textDecoration.includes('underline'); return { after: childLexicalNodes => { const result = []; let paragraphNode = null; const removeSingleLineBreakNode = () => { if (paragraphNode) { const firstChild = paragraphNode.getFirstChild(); if (lexical.$isLineBreakNode(firstChild) && paragraphNode.getChildrenSize() === 1) { firstChild.remove(); } } }; for (const child of childLexicalNodes) { if (lexical.$isInlineElementOrDecoratorNode(child) || lexical.$isTextNode(child) || lexical.$isLineBreakNode(child)) { if (lexical.$isTextNode(child)) { if (hasBoldFontWeight) { child.toggleFormat('bold'); } if (hasLinethroughTextDecoration) { child.toggleFormat('strikethrough'); } if (hasItalicFontStyle) { child.toggleFormat('italic'); } if (hasUnderlineTextDecoration) { child.toggleFormat('underline'); } } if (paragraphNode) { paragraphNode.append(child); } else { paragraphNode = lexical.$createParagraphNode().append(child); result.push(paragraphNode); } } else { result.push(child); removeSingleLineBreakNode(); paragraphNode = null; } } removeSingleLineBreakNode(); if (result.length === 0) { result.push(lexical.$createParagraphNode()); } return result; }, node: tableCellNode }; } function $createTableCellNode(headerState = TableCellHeaderStates.NO_STATUS, colSpan = 1, width) { return lexical.$applyNodeReplacement(new TableCellNode(headerState, colSpan, width)); } function $isTableCellNode(node) { return node instanceof TableCellNode; } /** * 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. * */ const INSERT_TABLE_COMMAND = lexical.createCommand('INSERT_TABLE_COMMAND'); /** * 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * 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. * */ /** @noInheritDoc */ class TableRowNode extends lexical.ElementNode { /** @internal */ static getType() { return 'tablerow'; } static clone(node) { return new TableRowNode(node.__height, node.__key); } static importDOM() { return { tr: node => ({ conversion: $convertTableRowElement, priority: 0 }) }; } static importJSON(serializedNode) { return $createTableRowNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode) { return super.updateFromJSON(serializedNode).setHeight(serializedNode.height); } constructor(height, key) { super(key); this.__height = height; } exportJSON() { const height = this.getHeight(); return { ...super.exportJSON(), ...(height === undefined ? undefined : { height }) }; } createDOM(config) { const element = document.createElement('tr'); if (this.__height) { element.style.height = `${this.__height}px`; } utils.addClassNamesToElement(element, config.theme.tableRow); return element; } extractWithChild(child, selection, destination) { return destination === 'html'; } isShadowRoot() { return true; } setHeight(height) { const self = this.getWritable(); self.__height = height; return self; } getHeight() { return this.getLatest().__height; } updateDOM(prevNode) { return prevNode.__height !== this.__height; } canBeEmpty() { return false; } canIndent() { return false; } } function $convertTableRowElement(domNode) { const domNode_ = domNode; let height = undefined; if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) { height = parseFloat(domNode_.style.height); } return { after: children => utils.$descendantsMatching(children, $isTableCellNode), node: $createTableRowNode(height) }; } function $createTableRowNode(height) { return lexical.$applyNodeReplacement(new TableRowNode(height)); } function $isTableRowNode(node) { return node instanceof TableRowNode; } /** * 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. * */ const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined'; /** * 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. * */ const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null; const IS_FIREFOX = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent); CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false; function $createTableNodeWithDimensions(rowCount, columnCount, includeHeaders = true) { const tableNode = $createTableNode(); for (let iRow = 0; iRow < rowCount; iRow++) { const tableRowNode = $createTableRowNode(); for (let iColumn = 0; iColumn < columnCount; iColumn++) { let headerState = TableCellHeaderStates.NO_STATUS; if (typeof includeHeaders === 'object') { if (iRow === 0 && includeHeaders.rows) { headerState |= TableCellHeaderStates.ROW; } if (iColumn === 0 && includeHeaders.columns) { headerState |= TableCellHeaderStates.COLUMN; } } else if (includeHeaders) { if (iRow === 0) { headerState |= TableCellHeaderStates.ROW; } if (iColumn === 0) { headerState |= TableCellHeaderStates.COLUMN; } } const tableCellNode = $createTableCellNode(headerState); const paragraphNode = lexical.$createParagraphNode(); paragraphNode.append(lexical.$createTextNode()); tableCellNode.append(paragraphNode); tableRowNode.append(tableCellNode); } tableNode.append(tableRowNode); } return tableNode; } function $getTableCellNodeFromLexicalNode(startingNode) { const node = utils.$findMatchingParent(startingNode, n => $isTableCellNode(n)); if ($isTableCellNode(node)) { return node; } return null; } function $getTableRowNodeFromTableCellNodeOrThrow(startingNode) { const node = utils.$findMatchingParent(startingNode, n => $isTableRowNode(n)); if ($isTableRowNode(node)) { return node; } throw new Error('Expected table cell to be inside of table row.'); } function $getTableNodeFromLexicalNodeOrThrow(startingNode) { const node = utils.$findMatchingParent(startingNode, n => $isTableNode(n)); if ($isTableNode(node)) { return node; } throw new Error('Expected table cell to be inside of table.'); } function $getTableRowIndexFromTableCellNode(tableCellNode) { const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode); const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableRowNode); return tableNode.getChildren().findIndex(n => n.is(tableRowNode)); } function $getTableColumnIndexFromTableCellNode(tableCellNode) { const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode); return tableRowNode.getChildren().findIndex(n => n.is(tableCellNode)); } function $getTableCellSiblingsFromTableCellNode(tableCellNode, table) { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); const { x, y } = tableNode.getCordsFromCellNode(tableCellNode, table); return { above: tableNode.getCellNodeFromCords(x, y - 1, table), below: tableNode.getCellNodeFromCords(x, y + 1, table), left: tableNode.getCellNodeFromCords(x - 1, y, table), right: tableNode.getCellNodeFromCords(x + 1, y, table) }; } function $removeTableRowAtIndex(tableNode, indexToDelete) { const tableRows = tableNode.getChildren(); if (indexToDelete >= tableRows.length || indexToDelete < 0) { throw new Error('Expected table cell to be inside of table row.'); } const targetRowNode = tableRows[indexToDelete]; targetRowNode.remove(); return tableNode; } /** * @deprecated This function does not support merged cells. Use {@link $insertTableRowAtSelection} or {@link $insertTableRowAtNode} instead. */ function $insertTableRow(tableNode, targetIndex, shouldInsertAfter = true, rowCount, table) { const tableRows = tableNode.getChildren(); if (targetIndex >= tableRows.length || targetIndex < 0) { throw new Error('Table row target index out of range'); } const targetRowNode = tableRows[targetIndex]; if ($isTableRowNode(targetRowNode)) { for (let r = 0; r < rowCount; r++) { const tableRowCells = targetRowNode.getChildren(); const tableColumnCount = tableRowCells.length; const newTableRowNode = $createTableRowNode(); for (let c = 0; c < tableColumnCount; c++) { const tableCellFromTargetRow = tableRowCells[c]; if (!$isTableCellNode(tableCellFromTargetRow)) { formatDevErrorMessage(`Expected table cell`); } const { above, below } = $getTableCellSiblingsFromTableCellNode(tableCellFromTargetRow, table); let headerState = TableCellHeaderStates.NO_STATUS; const width = above && above.getWidth() || below && below.getWidth() || undefined; if (above && above.hasHeaderState(TableCellHeaderStates.COLUMN) || below && below.hasHeaderState(TableCellHeaderStates.COLUMN)) { headerState |= TableCellHeaderStates.COLUMN; } const tableCellNode = $createTableCellNode(headerState, 1, width); tableCellNode.append(lexical.$createParagraphNode()); newTableRowNode.append(tableCellNode); } if (shouldInsertAfter) { targetRowNode.insertAfter(newTableRowNode); } else { targetRowNode.insertBefore(newTableRowNode); } } } else { throw new Error('Row before insertion index does not exist.'); } return tableNode; } const getHeaderState = (currentState, possibleState) => { if (currentState === TableCellHeaderStates.BOTH || currentState === possibleState) { return possibleState; } return TableCellHeaderStates.NO_STATUS; }; /** * Inserts a table row before or after the current focus cell node, * taking into account any spans. If successful, returns the * inserted table row node. */ function $insertTableRowAtSelection(insertAfter = true) { const selection = lexical.$getSelection(); if (!(lexical.$isRangeSelection(selection) || $isTableSelection(selection))) { formatDevErrorMessage(`Expected a RangeSelection or TableSelection`); } const anchor = selection.anchor.getNode(); const focus = selection.focus.getNode(); const [anchorCell] = $getNodeTriplet(anchor); const [focusCell,, grid] = $getNodeTriplet(focus); const [, focusCellMap, anchorCellMap] = $computeTableMap(grid, focusCell, anchorCell); const { startRow: anchorStartRow } = anchorCellMap; const { startRow: focusStartRow } = focusCellMap; if (insertAfter) { return $insertTableRowAtNode(anchorStartRow + anchorCell.__rowSpan > focusStartRow + focusCell.__rowSpan ? anchorCell : focusCell, true); } else { return $insertTableRowAtNode(focusStartRow < anchorStartRow ? focusCell : anchorCell, false); } } /** * @deprecated renamed to {@link $insertTableRowAtSelection} */ const $insertTableRow__EXPERIMENTAL = $insertTableRowAtSelection; /** * Inserts a table row before or after the given cell node, * taking into account any spans. If successful, returns the * inserted table row node. */ function $insertTableRowAtNode(cellNode, insertAfter = true) { const [,, grid] = $getNodeTriplet(cellNode); const [gridMap, cellMap] = $computeTableMap(grid, cellNode, cellNode); const columnCount = gridMap[0].length; const { startRow: cellStartRow } = cellMap; let insertedRow = null; if (insertAfter) { const insertAfterEndRow = cellStartRow + cellNode.__rowSpan - 1; const insertAfterEndRowMap = gridMap[insertAfterEndRow]; const newRow = $createTableRowNode(); for (let i = 0; i < columnCount; i++) { const { cell, startRow } = insertAfterEndRowMap[i]; if (startRow + cell.__rowSpan - 1 <= insertAfterEndRow) { const currentCell = insertAfterEndRowMap[i].cell; const currentCellHeaderState = currentCell.__headerState; const headerState = getHeaderState(currentCellHeaderState, TableCellHeaderStates.COLUMN); newRow.append($createTableCellNode(headerState).append(lexical.$createParagraphNode())); } else { cell.setRowSpan(cell.__rowSpan + 1); } } const insertAfterEndRowNode = grid.getChildAtIndex(insertAfterEndRow); if (!$isTableRowNode(insertAfterEndRowNode)) { formatDevErrorMessage(`insertAfterEndRow is not a TableRowNode`); } insertAfterEndRowNode.insertAfter(newRow); insertedRow = newRow; } else { const insertBeforeStartRow = cellStartRow; const insertBeforeStartRowMap = gridMap[insertBeforeStartRow]; const newRow = $createTableRowNode(); for (let i = 0; i < columnCount; i++) { const { cell, startRow } = insertBeforeStartRowMap[i]; if (startRow === insertBeforeStartRow) { const currentCell = insertBeforeStartRowMap[i].cell; const currentCellHeaderState = currentCell.__headerState; const headerState = getHeaderState(currentCellHeaderState, TableCellHeaderStates.COLUMN); newRow.append($createTableCellNode(headerState).append(lexical.$createParagraphNode())); } else { cell.setRowSpan(cell.__rowSpan + 1); } } const insertBeforeStartRowNode = grid.getChildAtIndex(insertBeforeStartRow); if (!$isTableRowNode(insertBeforeStartRowNode)) { formatDevErrorMessage(`insertBeforeStartRow is not a TableRowNode`); } insertBeforeStartRowNode.insertBefore(newRow); insertedRow = newRow; } return insertedRow; } /** * @deprecated This function does not support merged cells. Use {@link $insertTableColumnAtSelection} or {@link $insertTableColumnAtNode} instead. */ function $insertTableColumn(tableNode, targetIndex, shouldInsertAfter = true, columnCount, table) { const tableRows = tableNode.getChildren(); const tableCellsToBeInserted = []; for (let r = 0; r < tableRows.length; r++) { const currentTableRowNode = tableRows[r]; if ($isTableRowNode(currentTableRowNode)) { for (let c = 0; c < columnCount; c++) { const tableRowChildren = currentTableRowNode.getChildren(); if (targetIndex >= tableRowChildren.length || targetIndex < 0) { throw new Error('Table column target index out of range'); } const targetCell = tableRowChildren[targetIndex]; if (!$isTableCellNode(targetCell)) { formatDevErrorMessage(`Expected table cell`); } const { left, right } = $getTableCellSiblingsFromTableCellNode(targetCell, table); let headerState = TableCellHeaderStates.NO_STATUS; if (left && left.hasHeaderState(TableCellHeaderStates.ROW) || right && right.hasHeaderState(TableCellHeaderStates.ROW)) { headerState |= TableCellHeaderStates.ROW; } const newTableCell = $createTableCellNode(headerState); newTableCell.append(lexical.$createParagraphNode()); tableCellsToBeInserted.push({ newTableCell, targetCell }); } } } tableCellsToBeInserted.forEach(({ newTableCell, targetCell }) => { if (shouldInsertAfter) { targetCell.insertAfter(newTableCell); } else { targetCell.insertBefore(newTableCell); } }); return tableNode; } /** * Inserts a column before or after the current focus cell node, * taking into account any spans. If successful, returns the * first inserted cell node. */ function $insertTableColumnAtSelection(insertAfter = true) { const selection = lexical.$getSelection(); if (!(lexical.$isRangeSelection(selection) || $isTableSelection(selection))) { formatDevErrorMessage(`Expected a RangeSelection or TableSelection`); } const anchor = selection.anchor.getNode(); const focus = selection.focus.getNode(); const [anchorCell] = $getNodeTriplet(anchor); const [focusCell,, grid] = $getNodeTriplet(focus); const [, focusCellMap, anchorCellMap] = $computeTableMap(grid, focusCell, anchorCell); const { startColumn: anchorStartColumn } = anchorCellMap; const { startColumn: focusStartColumn } = focusCellMap; if (insertAfter) { return $insertTableColumnAtNode(anchorStartColumn + anchorCell.__colSpan > focusStartColumn + focusCell.__colSpan ? anchorCell : focusCell, true); } else { return $insertTableColumnAtNode(focusStartColumn < anchorStartColumn ? focusCell : anchorCell, false); } } /** * @deprecated renamed to {@link $insertTableColumnAtSelection} */ const $insertTableColumn__EXPERIMENTAL = $insertTableColumnAtSelection; /** * Inserts a column before or after the given cell node, * taking into account any spans. If successful, returns the * first inserted cell node. */ function $insertTableColumnAtNode(cellNode, insertAfter = true, shouldSetSelection = true) { const [,, grid] = $getNodeTriplet(cellNode); const [gridMap, cellMap] = $computeTableMap(grid, cellNode, cellNode); const rowCount = gridMap.length; const { startColumn } = cellMap; const insertAfterColumn = insertAfter ? startColumn + cellNode.__colSpan - 1 : startColumn - 1; const gridFirstChild = grid.getFirstChild(); if (!$isTableRowNode(gridFirstChild)) { formatDevErrorMessage(`Expected firstTable child to be a row`); } let firstInsertedCell = null; function $createTableCellNodeForInsertTableColumn(headerState = TableCellHeaderStates.NO_STATUS) { const cell = $createTableCellNode(headerState).append(lexical.$createParagraphNode()); if (firstInsertedCell === null) { firstInsertedCell = cell; } return cell; } let loopRow = gridFirstChild; rowLoop: for (let i = 0; i < rowCount; i++) { if (i !== 0) { const currentRow = loopRow.getNextSibling(); if (!$isTableRowNode(currentRow)) { formatDevErrorMessage(`Expected row nextSibling to be a row`); } loopRow = currentRow; } const rowMap = gridMap[i]; const currentCellHeaderState = rowMap[insertAfterColumn < 0 ? 0 : insertAfterColumn].cell.__headerState; const headerState = getHeaderState(currentCellHeaderState, TableCellHeaderStates.ROW); if (insertAfterColumn < 0) { $insertFirst(loopRow, $createTableCellNodeForInsertTableColumn(headerState)); continue; } const { cell: currentCell, startColumn: currentStartColumn, startRow: currentStartRow } = rowMap[insertAfterColumn]; if (currentStartColumn + currentCell.__colSpan - 1 <= insertAfterColumn) { let insertAfterCell = currentCell; let insertAfterCellRowStart = currentStartRow; let prevCellIndex = insertAfterColumn; while (insertAfterCellRowStart !== i && insertAfterCell.__rowSpan > 1) { prevCellIndex -= currentCell.__colSpan; if (prevCellIndex >= 0) { const { cell: cell_, startRow: startRow_ } = rowMap[prevCellIndex]; insertAfterCell = cell_; insertAfterCellRowStart = startRow_; } else { loopRow.append($createTableCellNodeForInsertTableColumn(headerState)); continue rowLoop; } } insertAfterCell.insertAfter($createTableCellNodeForInsertTableColumn(headerState)); } else { currentCell.setColSpan(currentCell.__colSpan + 1); } } if (firstInsertedCell !== null && shouldSetSelection) { $moveSelectionToCell(firstInsertedCell); } const colWidths = grid.getColWidths(); if (colWidths) { const newColWidths = [...colWidths]; const columnIndex = insertAfterColumn < 0 ? 0 : insertAfterColumn; const newWidth = newColWidths[columnIndex]; newColWidths.splice(columnIndex, 0, newWidth); grid.setColWidths(newColWidths); } return firstInsertedCell; } /** * @deprecated This function does not support merged cells. Use {@link $deleteTableColumnAtSelection} instead. */ function $deleteTableColumn(tableNode, targetIndex) { const tableRows = tableNode.getChildren(); for (let i = 0; i < tableRows.length; i++) { const currentTableRowNode = tableRows[i]; if ($isTableRowNode(currentTableRowNode)) { const tableRowChildren = currentTableRowNode.getChildren(); if (targetIndex >= tableRowChildren.length || targetIndex < 0) { throw new Error('Table column target index out of range'); } tableRowChildren[targetIndex].remove(); } } return tableNode; } function $deleteTableRowAtSelection() { const selection = lexical.$getSelection(); if (!(lexical.$isRangeSelection(selection) || $isTableSelection(selection))) { formatDevErrorMessage(`Expected a RangeSelection or TableSelection`); } const [anchor, focus] = selection.isBackward() ? [selection.focus.getNode(), selection.anchor.getNode()] : [selection.anchor.getNode(), selection.focus.getNode()]; const [anchorCell,, grid] = $getNodeTriplet(anchor); const [focusCell] = $getNodeTriplet(focus); const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap(grid, anchorCell, focusCell); const { startRow: anchorStartRow } = anchorCellMap; const { startRow: focusStartRow } = focusCellMap; const focusEndRow = focusStartRow + focusCell.__rowSpan - 1; if (gridMap.length === focusEndRow - anchorStartRow + 1) { // Empty grid grid.remove(); return; } const columnCount = gridMap[0].length; const nextRow = gridMap[focusEndRow + 1]; const nextRowNode = grid.getChildAtIndex(focusEndRow + 1); for (let row = focusEndRow; row >= anchorStartRow; row--) { for (let column = columnCount - 1; column >= 0; column--) { const { cell, startRow: cellStartRow, startColumn: cellStartColumn } = gridMap[row][column]; if (cellStartColumn !== column) { // Don't repeat work for the same Cell continue; } // Rows overflowing top or bottom have to be trimmed if (cellStartRow < anchorStartRow || cellStartRow + cell.__rowSpan - 1 > focusEndRow) { const intersectionStart = Math.max(cellStartRow, anchorStartRow); const intersectionEnd = Math.min(cell.__rowSpan + cellStartRow - 1, focusEndRow); const overflowRowsCount = intersectionStart <= intersectionEnd ? intersectionEnd - intersectionStart + 1 : 0; cell.setRowSpan(cell.__rowSpan - overflowRowsCount); } // Rows overflowing bottom have to be moved to the next row if (cellStartRow >= anchorStartRow && cellStartRow + cell.__rowSpan - 1 > focusEndRow && // Handle overflow only once row === focusEndRow) { if (!(nextRowNode !== null)) { formatDevErrorMessage(`Expected nextRowNode not to be null`); } let insertAfterCell = null; for (let columnIndex = 0; columnIndex < column; columnIndex++) { const currentCellMap = nextRow[columnIndex]; const currentCell = currentCellMap.cell; // Checking the cell having startRow as same as nextRow if (currentCellMap.startRow === row + 1) { insertAfterCell = currentCell; } if (currentCell.__colSpan > 1) { columnIndex += currentCell.__colSpan - 1; } } if (insertAfterCell === null) { $insertFirst(nextRowNode, cell); } else { insertAfterCell.insertAfter(cell); } } } const rowNode = grid.getChildAtIndex(row); if (!$isTableRowNode(rowNode)) { formatDevErrorMessage(`Expected TableNode childAtIndex(${String(row)}) to be RowNode`); } rowNode.remove(); } if (nextRow !== undefined) { const { cell } = nextRow[0]; $moveSelectionToCell(cell); } else { const previousRow = gridMap[anchorStartRow - 1]; const { cell } = previousRow[0]; $moveSelectionToCell(cell); } } /** * @deprecated renamed to {@link $deleteTableRowAtSelection} */ const $deleteTableRow__EXPERIMENTAL = $deleteTableRowAtSelection; function $deleteTableColumnAtSelection() { const selection = lexical.$getSelection(); if (!(lexical.$isRangeSelection(selection) || $isTableSelection(selection))) { formatDevErrorMessage(`Expected a RangeSelection or TableSelection`); } const anchor = selection.anchor.getNode(); const focus = selection.focus.getNode(); const [anchorCell,, grid] = $getNodeTriplet(anchor); const [focusCell] = $getNodeTriplet(focus); const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap(grid, anchorCell, focusCell); const { startColumn: anchorStartColumn } = anchorCellMap; const { startRow: focusStartRow, startColumn: focusStartColumn } = focusCellMap; const startColumn = Math.min(anchorStartColumn, focusStartColumn); const endColumn = Math.max(anchorStartColumn + anchorCell.__colSpan - 1, focusStartColumn + focusCell.__colSpan - 1); const selectedColumnCount = endColumn - startColumn + 1; const columnCount = gridMap[0].length; if (columnCount === endColumn - startColumn + 1) { // Empty grid grid.selectPrevious(); grid.remove(); return; } const rowCount = gridMap.length; for (let row = 0; row < rowCount; row++) { for (let column = startColumn; column <= endColumn; column++) { const { cell, startColumn: cellStartColumn } = gridMap[row][column]; if (cellStartColumn < startColumn) { if (column === startColumn) { const overflowLeft = startColumn - cellStartColumn; // Overflowing left cell.setColSpan(cell.__colSpan - // Possible overflow right too Math.min(selectedColumnCount, cell.__colSpan - overflowLeft)); } } else if (cellStartColumn + cell.__colSpan - 1 > endColumn) { if (column === endColumn) { // Overflowing right const inSelectedArea = endColumn - cellStartColumn + 1; cell.setColSpan(cell.__colSpan - inSelectedArea); } } else { cell.remove(); } } } const focusRowMap = gridMap[focusStartRow]; const nextColumn = anchorStartColumn > focusStartColumn ? focusRowMap[anchorStartColumn + anchorCell.__colSpan] : focusRowMap[focusStartColumn + focusCell.__colSpan]; if (nextColumn !== undefined) { const { cell } = nextColumn; $moveSelectionToCell(cell); } else { const previousRow = focusStartColumn < anchorStartColumn ? focusRowMap[focusStartColumn - 1] : focusRowMap[anchorStartColumn - 1]; const { cell } = previousRow; $moveSelectionToCell(cell); } const colWidths = grid.getColWidths(); if (colWidths) { const newColWidths = [...colWidths]; newColWidths.splice(startColumn, selectedColumnCount); grid.setColWidths(newColWidths); } } /** * @deprecated renamed to {@link $deleteTableColumnAtSelection} */ const $deleteTableColumn__EXPERIMENTAL = $deleteTableColumnAtSelection; function $moveSelectionToCell(cell) { const firstDescendant = cell.getFirstDescendant(); if (firstDescendant == null) { cell.selectStart(); } else { firstDescendant.getParentOrThrow().selectStart(); } } function $insertFirst(parent, node) { const firstChild = parent.getFirstChild(); if (firstChild !== null) { firstChild.insertBefore(node); } else { parent.append(node); } } function $mergeCells(cellNodes) { if (cellNodes.length === 0) { return null; } // Find the table node const tableNode = $getTableNodeFromLexicalNodeOrThrow(cellNodes[0]); const [gridMap] = $computeTableMapSkipCellCheck(tableNode, null, null); // Find the boundaries of the selection including merged cells let minRow = Infinity; let maxRow = -Infinity; let minCol = Infinity; let maxCol = -Infinity; // First pass: find the actual boundaries considering merged cells const processedCells = new Set(); for (const row of gridMap) { for (const mapCell of row) { if (!mapCell || !mapCell.cell) { continue; } const cellKey = mapCell.cell.getKey(); if (processedCells.has(cellKey)) { continue; } if (cellNodes.some(cell => cell.is(mapCell.cell))) { processedCells.add(cellKey); // Get the actual position of this cell in the grid const cellStartRow = mapCell.startRow; const cellStartCol = mapCell.startColumn; const cellRowSpan = mapCell.cell.__rowSpan || 1; const cellColSpan = mapCell.cell.__colSpan || 1; // Update boundaries considering the cell's actual position and span minRow = Math.min(minRow, cellStartRow); maxRow = Math.max(maxRow, cellStartRow + cellRowSpan - 1); minCol = Math.min(minCol, cellStartCol); maxCol = Math.max(maxCol, cellStartCol + cellColSpan - 1); } } } // Validate boundaries if (minRow === Infinity || minCol === Infinity) { return null; } // The total span of the merged cell const totalRowSpan = maxRow - minRow + 1; const totalColSpan = maxCol - minCol + 1; // Use the top-left cell as the target cell const targetCellMap = gridMap[minRow][minCol]; if (!targetCellMap.cell) { return null; } const targetCell = targetCellMap.cell; // Set the spans for the target cell targetCell.setColSpan(totalColSpan); targetCell.setRowSpan(totalRowSpan); // Move content from other cells to the target cell const seenCells = new Set([targetCell.getKey()]); // Second pass: merge content and remove other cells for (let row = minRow; row <= maxRow; row++) { for (let col = minCol; col <= maxCol; col++) { const mapCell = gridMap[row][col]; if (!mapCell.cell) { continue; } const currentCell = mapCell.cell; const key = currentCell.getKey(); if (!seenCells.has(key)) { seenCells.add(key); const isEmpty = $cellContainsEmptyParagraph(currentCell); if (!isEmpty) { targetCell.append(...currentCell.getChildren()); } currentCell.remove(); } } } // Ensure target cell has content if (targetCell.getChildrenSize() === 0) { targetCell.append(lexical.$createParagraphNode()); } return targetCell; } function $cellContainsEmptyParagraph(cell) { if (cell.getChildrenSize() !== 1) { return false; } const firstChild = cell.getFirstChildOrThrow(); if (!lexical.$isParagraphNode(firstChild) || !firstChild.isEmpty()) { return false; } return true; } function $unmergeCell() { const selection = lexical.$getSelection(); if (!(lexical.$isRangeSelection(selection) || $isTableSelection(selection))) { formatDevErrorMessage(`Expected a RangeSelection or TableSelection`); } const anchor = selection.anchor.getNode(); const cellNode = utils.$findMatchingParent(anchor, $isTableCellNode); if (!$isTableCellNode(cellNode)) { formatDevErrorMessage(`Expected to find a parent TableCellNode`); } return $unmergeCellNode(cellNode); } function $unmergeCellNode(cellNode) { const [cell, row, grid] = $getNodeTriplet(cellNode); const colSpan = cell.__colSpan; const rowSpan = cell.__rowSpan; if (colSpan === 1 && rowSpan === 1) { return; } const [map, cellMap] = $computeTableMap(grid, cell, cell); const { startColumn, startRow } = cellMap; // Create a heuristic for what the style of the unmerged cells should be // based on whether every row or column already had that state before the // unmerge. const baseColStyle = cell.__headerState & TableCellHeaderStates.COLUMN; const colStyles = Array.from({ length: colSpan }, (_v, i) => { let colStyle = baseColStyle; for (let rowIdx = 0; colStyle !== 0 && rowIdx < map.length; rowIdx++) { colStyle &= map[rowIdx][i + startColumn].cell.__headerState; } return colStyle; }); const baseRowStyle = cell.__headerState & TableCellHeaderStates.ROW; const rowStyles = Array.from({ length: rowSpan }, (_v, i) => { let rowStyle = baseRowStyle; for (let colIdx = 0; rowStyle !== 0 && colIdx < map[0].length; colIdx++) { rowStyle &= map[i + startRow][colIdx].cell.__headerState; } return rowStyle; }); if (colSpan > 1) { for (let i = 1; i < colSpan; i++) { cell.insertAfter($createTableCellNode(colStyles[i] | rowStyles[0]).append(lexical.$createParagraphNode())); } cell.setColSpan(1); } if (rowSpan > 1) { let currentRowNode; for (let i = 1; i < rowSpan; i++) { const currentRow = startRow + i; const currentRowMap = map[currentRow]; currentRowNode = (currentRowNode || row).getNextSibling(); if (!$isTableRowNode(currentRowNode)) { formatDevErrorMessage(`Expected row next sibling to be a row`); } let insertAfterCell = null; for (let column = 0; column < startColumn; column++) { const currentCellMap = currentRowMap[column]; const currentCell = currentCellMap.cell; if (currentCellMap.startRow === currentRow) { insertAfterCell = currentCell; } if (currentCell.__colSpan > 1) { column += currentCell.__colSpan - 1; } } if (insertAfterCell === null) { for (let j = colSpan - 1; j >= 0; j--) { $insertFirst(currentRowNode, $createTableCellNode(colStyles[j] | rowStyles[i]).append(lexical.$createParagraphNode())); } } else { for (let j = colSpan - 1; j >= 0; j--) { insertAfterCell.insertAfter($createTableCellNode(colStyles[j] | rowStyles[i]).append(lexical.$createParagraphNode())); } } } cell.setRowSpan(1); } } function $computeTableMap(tableNode, cellA, cellB) { const [tableMap, cellAValue, cellBValue] = $computeTableMapSkipCellCheck(tableNode, cellA, cellB); if (!(cellAValue !== null)) { formatDevErrorMessage(`Anchor not found in Table`); } if (!(cellBValue !== null)) { formatDevErrorMessage(`Focus not found in Table`); } return [tableMap, cellAValue, cellBValue]; } function $computeTableMapSkipCellCheck(tableNode, cellA, cellB) { const tableMap = []; let cellAValue = null; let cellBValue = null; function getMapRow(i) { let row = tableMap[i]; if (row === undefined) { tableMap[i] = row = []; } return row; } const gridChildren = tableNode.getChildren(); for (let rowIdx = 0; rowIdx < gridChildren.length; rowIdx++) { const row = gridChildren[rowIdx]; if (!$isTableRowNode(row)) { formatDevErrorMessage(`Expected TableNode children to be TableRowNode`); } const startMapRow = getMapRow(rowIdx); for (let cell = row.getFirstChild(), colIdx = 0; cell != null; cell = cell.getNextSibling()) { if (!$isTableCellNode(cell)) { formatDevErrorMessage(`Expected TableRowNode children to be TableCellNode`); } // Skip past any columns that were merged from a higher row while (startMapRow[colIdx] !== undefined) { colIdx++; } const value = { cell, startColumn: colIdx, startRow: rowIdx }; const { __rowSpan: rowSpan, __colSpan: colSpan } = cell; for (let j = 0; j < rowSpan; j++) { if (rowIdx + j >= gridChildren.length) { // The table is non-rectangular with a rowSpan // below the last in the table. // We should probably handle this with a node transform // to ensure that tables are always rectangular but this // will avoid crashes such as #6584 // Note that there are probably still latent bugs // regarding colSpan or general cell count mismatches. break; } const mapRow = getMapRow(rowIdx + j); for (let i = 0; i < colSpan; i++) { mapRow[colIdx + i] = value; } } if (cellA !== null && cellAValue === null && cellA.is(cell)) { cellAValue = value; } if (cellB !== null && cellBValue === null && cellB.is(cell)) { cellBValue = value; } } } return [tableMap, cellAValue, cellBValue]; } function $getNodeTriplet(source) { let cell; if (source instanceof TableCellNode) { cell = source; } else if ('__type' in source) { const cell_ = utils.$findMatchingParent(source, $isTableCellNode); if (!$isTableCellNode(cell_)) { formatDevErrorMessage(`Expected to find a parent TableCellNode`); } cell = cell_; } else { const cell_ = utils.$findMatchingParent(source.getNode(), $isTableCellNode); if (!$isTableCellNode(cell_)) { formatDevErrorMessage(`Expected to find a parent TableCellNode`); } cell = cell_; } const row = cell.getParent(); if (!$isTableRowNode(row)) { formatDevErrorMessage(`Expected TableCellNode to have a parent TableRowNode`); } const grid = row.getParent(); if (!$isTableNode(grid)) { formatDevErrorMessage(`Expected TableRowNode to have a parent TableNode`); } return [cell, row, grid]; } function $computeTableCellRectSpans(map, boundary) { const { minColumn, maxColumn, minRow, maxRow } = boundary; let topSpan = 1; let leftSpan = 1; let rightSpan = 1; let bottomSpan = 1; const topRow = map[minRow]; const bottomRow = map[maxRow]; for (let col = minColumn; col <= maxColumn; col++) { topSpan = Math.max(topSpan, topRow[col].cell.__rowSpan); bottomSpan = Math.max(bottomSpan, bottomRow[col].cell.__rowSpan); } for (let row = minRow; row <= maxRow; row++) { leftSpan = Math.max(leftSpan, map[row][minColumn].cell.__colSpan); rightSpan = Math.max(rightSpan, map[row][maxColumn].cell.__colSpan); } return { bottomSpan, leftSpan, rightSpan, topSpan }; } function $computeTableCellRectBoundary(map, cellAMap, cellBMap) { // Initial boundaries based on the anchor and focus cells let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn); let minRow = Math.min(cellAMap.startRow, cellBMap.startRow); let maxColumn = Math.max(cellAMap.startColumn + cellAMap.cell.__colSpan - 1, cellBMap.startColumn + cellBMap.cell.__colSpan - 1); let maxRow = Math.max(cellAMap.startRow + cellAMap.cell.__rowSpan - 1, cellBMap.startRow + cellBMap.cell.__rowSpan - 1); // Keep expanding until we have a complete rectangle let hasChanges; do { hasChanges = false; // Check all cells in the table for (let row = 0; row < map.length; row++) { for (let col = 0; col < map[0].length; col++) { const cell = map[row][col]; if (!cell) { continue; } const cellEndCol = cell.startColumn + cell.cell.__colSpan - 1; const cellEndRow = cell.startRow + cell.cell.__rowSpan - 1; // Check if this cell intersects with our current selection rectangle const intersectsHorizontally = cell.startColumn <= maxColumn && cellEndCol >= minColumn; const intersectsVertically = cell.startRow <= maxRow && cellEndRow >= minRow; // If the cell intersects either horizontally or vertically if (intersectsHorizontally && intersectsVertically) { // Expand boundaries to include this cell completely const newMinColumn = Math.min(minColumn, cell.startColumn); const newMaxColumn = Math.max(maxColumn, cellEndCol); const newMinRow = Math.min(minRow, cell.startRow); const newMaxRow = Math.max(maxRow, cellEndRow); // Check if boundaries changed if (newMinColumn !== minColumn || newMaxColumn !== maxColumn || newMinRow !== minRow || newMaxRow !== maxRow) { minColumn = newMinColumn; maxColumn = newMaxColumn; minRow = newMinRow; maxRow = newMaxRow; hasChanges = true; } } } } } while (hasChanges); return { maxColumn, maxRow, minColumn, minRow }; } function $getTableCellNodeRect(tableCellNode) { const [cellNode,, gridNode] = $getNodeTriplet(tableCellNode); const rows = gridNode.getChildren(); const rowCount = rows.length; const columnCount = rows[0].getChildren().length; // Create a matrix of the same size as the table to track the position of each cell const cellMatrix = new Array(rowCount); for (let i = 0; i < rowCount; i++) { cellMatrix[i] = new Array(columnCount); } for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { const row = rows[rowIndex]; const cells = row.getChildren(); let columnIndex = 0; for (let cellIndex = 0; cellIndex < cells.length; cellIndex++) { // Find the next available position in the matrix, skip the position of merged cells while (cellMatrix[rowIndex][columnIndex]) { columnIndex++; } const cell = cells[cellIndex]; const rowSpan = cell.__rowSpan || 1; const colSpan = cell.__colSpan || 1; // Put the cell into the corresponding position in the matrix for (let i = 0; i < rowSpan; i++) { for (let j = 0; j < colSpan; j++) { cellMatrix[rowIndex + i][columnIndex + j] = cell; } } // Return to the original index, row span and column span of the cell. if (cellNode === cell) { return { colSpan, columnIndex, rowIndex, rowSpan }; } columnIndex += colSpan; } } return null; } function $getCellNodes(tableSelection) { const [[anchorNode, anchorCell, anchorRow, anchorTable], [focusNode, focusCell, focusRow, focusTable]] = ['anchor', 'focus'].map(k => { const node = tableSelection[k].getNode(); const cellNode = utils.$findMatchingParent(node, $isTableCellNode); if (!$isTableCellNode(cellNode)) { formatDevErrorMessage(`Expected TableSelection ${k} to be (or a child of) TableCellNode, got key ${node.getKey()} of type ${node.getType()}`); } const rowNode = cellNode.getParent(); if (!$isTableRowNode(rowNode)) { formatDevErrorMessage(`Expected TableSelection ${k} cell parent to be a TableRowNode`); } const tableNode = rowNode.getParent(); if (!$isTableNode(tableNode)) { formatDevErrorMessage(`Expected TableSelection ${k} row parent to be a TableNode`); } return [node, cellNode, rowNode, tableNode]; }); // TODO: nested tables may violate this if (!anchorTable.is(focusTable)) { formatDevErrorMessage(`Expected TableSelection anchor and focus to be in the same table`); } return { anchorCell, anchorNode, anchorRow, anchorTable, focusCell, focusNode, focusRow, focusTable }; } class TableSelection { constructor(tableKey, anchor, focus) { this.anchor = anchor; this.focus = focus; anchor._selection = this; focus._selection = this; this._cachedNodes = null; this.dirty = false; this.tableKey = tableKey; } getStartEndPoints() { return [this.anchor, this.focus]; } /** * {@link $createTableSelection} unfortunately makes it very easy to create * nonsense selections, so we have a method to see if the selection probably * makes sense. * * @returns true if the TableSelection is (probably) valid */ isValid() { return this.tableKey !== 'root' && this.anchor.key !== 'root' && this.anchor.type === 'element' && this.focus.key !== 'root' && this.focus.type === 'element'; } /** * Returns whether the Selection is "backwards", meaning the focus * logically precedes the anchor in the EditorState. * @returns true if the Selection is backwards, false otherwise. */ isBackward() { return this.focus.isBefore(this.anchor); } getCachedNodes() { return this._cachedNodes; } setCachedNodes(nodes) { this._cachedNodes = nodes; } is(selection) { return $isTableSelection(selection) && this.tableKey === selection.tableKey && this.anchor.is(selection.anchor) && this.focus.is(selection.focus); } set(tableKey, anchorCellKey, focusCellKey) { // note: closure compiler's acorn does not support ||= this.dirty = this.dirty || tableKey !== this.tableKey || anchorCellKey !== this.anchor.key || focusCellKey !== this.focus.key; this.tableKey = tableKey; this.anchor.key = anchorCellKey; this.focus.key = focusCellKey; this._cachedNodes = null; } clone() { return new TableSelection(this.tableKey, lexical.$createPoint(this.anchor.key, this.anchor.offset, this.anchor.type), lexical.$createPoint(this.focus.key, this.focus.offset, this.focus.type)); } isCollapsed() { return false; } extract() { return this.getNodes(); } insertRawText(text) { // Do nothing? } insertText() { // Do nothing? } /** * Returns whether the provided TextFormatType is present on the Selection. * This will be true if any paragraph in table cells has the specified format. * * @param type the TextFormatType to check for. * @returns true if the provided format is currently toggled on on the Selection, false otherwise. */ hasFormat(type) { let format = 0; const cellNodes = this.getNodes().filter($isTableCellNode); cellNodes.forEach(cellNode => { const paragraph = cellNode.getFirstChild(); if (lexical.$isParagraphNode(paragraph)) { format |= paragraph.getTextFormat(); } }); const formatFlag = lexical.TEXT_TYPE_TO_FORMAT[type]; return (format & formatFlag) !== 0; } insertNodes(nodes) { const focusNode = this.focus.getNode(); if (!lexical.$isElementNode(focusNode)) { formatDevErrorMessage(`Expected TableSelection focus to be an ElementNode`); } const selection = lexical.$normalizeSelection__EXPERIMENTAL(focusNode.select(0, focusNode.getChildrenSize())); selection.insertNodes(nodes); } // TODO Deprecate this method. It's confusing when used with colspan|rowspan getShape() { const { anchorCell, focusCell } = $getCellNodes(this); const anchorCellNodeRect = $getTableCellNodeRect(anchorCell); if (!(anchorCellNodeRect !== null)) { formatDevErrorMessage(`getCellRect: expected to find AnchorNode`); } const focusCellNodeRect = $getTableCellNodeRect(focusCell); if (!(focusCellNodeRect !== null)) { formatDevErrorMessage(`getCellRect: expected to find focusCellNode`); } const startX = Math.min(anchorCellNodeRect.columnIndex, focusCellNodeRect.columnIndex); const stopX = Math.max(anchorCellNodeRect.columnIndex + anchorCellNodeRect.colSpan - 1, focusCellNodeRect.columnIndex + focusCellNodeRect.colSpan - 1); const startY = Math.min(anchorCellNodeRect.rowIndex, focusCellNodeRect.rowIndex); const stopY = Math.max(anchorCellNodeRect.rowIndex + anchorCellNodeRect.rowSpan - 1, focusCellNodeRect.rowIndex + focusCellNodeRect.rowSpan - 1); return { fromX: Math.min(startX, stopX), fromY: Math.min(startY, stopY), toX: Math.max(startX, stopX), toY: Math.max(startY, stopY) }; } getNodes() { if (!this.isValid()) { return []; } const cachedNodes = this._cachedNodes; if (cachedNodes !== null) { return cachedNodes; } const { anchorTable: tableNode, anchorCell, focusCell } = $getCellNodes(this); const focusCellGrid = focusCell.getParents()[1]; if (focusCellGrid !== tableNode) { if (!tableNode.isParentOf(focusCell)) { // focus is on higher Grid level than anchor const gridParent = tableNode.getParent(); if (!(gridParent != null)) { formatDevErrorMessage(`Expected gridParent to have a parent`); } this.set(this.tableKey, gridParent.getKey(), focusCell.getKey()); } else { // anchor is on higher Grid level than focus const focusCellParent = focusCellGrid.getParent(); if (!(focusCellParent != null)) { formatDevErrorMessage(`Expected focusCellParent to have a parent`); } this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey()); } return this.getNodes(); } // TODO Mapping the whole Grid every time not efficient. We need to compute the entire state only // once (on load) and iterate on it as updates occur. However, to do this we need to have the // ability to store a state. Killing TableSelection and moving the logic to the plugin would make // this possible. const [map, cellAMap, cellBMap] = $computeTableMap(tableNode, anchorCell, focusCell); const { minColumn, maxColumn, minRow, maxRow } = $computeTableCellRectBoundary(map, cellAMap, cellBMap); // We use a Map here because merged cells in the grid would otherwise // show up multiple times in the nodes array const nodeMap = new Map([[tableNode.getKey(), tableNode]]); let lastRow = null; for (let i = minRow; i <= maxRow; i++) { for (let j = minColumn; j <= maxColumn; j++) { const { cell } = map[i][j]; const currentRow = cell.getParent(); if (!$isTableRowNode(currentRow)) { formatDevErrorMessage(`Expected TableCellNode parent to be a TableRowNode`); } if (currentRow !== lastRow) { nodeMap.set(currentRow.getKey(), currentRow); lastRow = currentRow; } if (!nodeMap.has(cell.getKey())) { $visitRecursively(cell, childNode => { nodeMap.set(childNode.getKey(), childNode); }); } } } const nodes = Array.from(nodeMap.values()); if (!lexical.isCurrentlyReadOnlyMode()) { this._cachedNodes = nodes; } return nodes; } getTextContent() { const nodes = this.getNodes().filter(node => $isTableCellNode(node)); let textContent = ''; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const row = node.__parent; const nextRow = (nodes[i + 1] || {}).__parent; textContent += node.getTextContent() + (nextRow !== row ? '\n' : '\t'); } return textContent; } } function $isTableSelection(x) { return x instanceof TableSelection; } function $createTableSelection() { // TODO this is a suboptimal design, it doesn't make sense to have // a table selection that isn't associated with a table. This // constructor should have required arguments and in true we // should check that they point to a table and are element points to // cell nodes of that table. const anchor = lexical.$createPoint('root', 0, 'element'); const focus = lexical.$createPoint('root', 0, 'element'); return new TableSelection('root', anchor, focus); } function $createTableSelectionFrom(tableNode, anchorCell, focusCell) { const tableNodeKey = tableNode.getKey(); const anchorCellKey = anchorCell.getKey(); const focusCellKey = focusCell.getKey(); { if (!tableNode.isAttached()) { formatDevErrorMessage(`$createTableSelectionFrom: tableNode ${tableNodeKey} is not attached`); } if (!tableNode.is($findTableNode(anchorCell))) { formatDevErrorMessage(`$createTableSelectionFrom: anchorCell ${anchorCellKey} is not in table ${tableNodeKey}`); } if (!tableNode.is($findTableNode(focusCell))) { formatDevErrorMessage(`$createTableSelectionFrom: focusCell ${focusCellKey} is not in table ${tableNodeKey}`); } // TODO: Check for rectangular grid } const prevSelection = lexical.$getSelection(); const nextSelection = $isTableSelection(prevSelection) ? prevSelection.clone() : $createTableSelection(); nextSelection.set(tableNode.getKey(), anchorCell.getKey(), focusCell.getKey()); return nextSelection; } /** * Depth first visitor * @param node The starting node * @param $visit The function to call for each node. If the function returns false, then children of this node will not be explored */ function $visitRecursively(node, $visit) { const stack = [[node]]; for (let currentArray = stack.at(-1); currentArray !== undefined && stack.length > 0; currentArray = stack.at(-1)) { const currentNode = currentArray.pop(); if (currentNode === undefined) { stack.pop(); } else if ($visit(currentNode) !== false && lexical.$isElementNode(currentNode)) { stack.push(currentNode.getChildren()); } } } function $getTableAndElementByKey(tableNodeKey, editor = lexical.$getEditor()) { const tableNode = lexical.$getNodeByKey(tableNodeKey); if (!$isTableNode(tableNode)) { formatDevErrorMessage(`TableObserver: Expected tableNodeKey ${tableNodeKey} to be a TableNode`); } const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNodeKey)); if (!(tableElement !== null)) { formatDevErrorMessage(`TableObserver: Expected to find TableElement in DOM for key ${tableNodeKey}`); } return { tableElement, tableNode }; } class TableObserver { constructor(editor, tableNodeKey) { 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.isSelecting = false; this.pointerType = null; this.shouldCheckSelection = false; this.abortController = new AbortController(); this.listenerOptions = { signal: this.abortController.signal }; this.nextFocus = null; this.trackTable(); } getTable() { return this.table; } removeListeners() { this.abortController.abort('removeListeners'); Array.from(this.listenersToRemove).forEach(removeListener => removeListener()); this.listenersToRemove.clear(); } $lookup() { return $getTableAndElementByKey(this.tableNodeKey, this.editor); } trackTable() { const observer = new MutationObserver(records => { this.editor.getEditorState().read(() => { 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 { tableNode, tableElement } = this.$lookup(); this.table = getTable(tableNode, tableElement); }, { editor: this.editor }); }); this.editor.getEditorState().read(() => { const { tableNode, tableElement } = this.$lookup(); this.table = getTable(tableNode, tableElement); observer.observe(tableElement, { attributes: true, childList: true, subtree: true }); }, { editor: this.editor }); } $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(); const { tableNode, tableElement } = this.$lookup(); const grid = getTable(tableNode, tableElement); $updateDOMForSelection(editor, grid, null); if (lexical.$getSelection() !== null) { lexical.$setSelection(null); editor.dispatchCommand(lexical.SELECTION_CHANGE_COMMAND, undefined); } } $enableHighlightStyle() { const editor = this.editor; const { tableElement } = this.$lookup(); utils.removeClassNamesFromElement(tableElement, editor._config.theme.tableSelection); tableElement.classList.remove('disable-selection'); this.hasHijackedSelectionStyles = false; } $disableHighlightStyle() { const { tableElement } = this.$lookup(); utils.addClassNamesToElement(tableElement, this.editor._config.theme.tableSelection); this.hasHijackedSelectionStyles = true; } $updateTableTableSelection(selection) { if (selection !== null) { if (!(selection.tableKey === this.tableNodeKey)) { formatDevErrorMessage(`TableObserver.$updateTableTableSelection: selection.tableKey !== this.tableNodeKey ('${selection.tableKey}' !== '${this.tableNodeKey}')`); } const editor = this.editor; this.tableSelection = selection; this.isHighlightingCells = true; this.$disableHighlightStyle(); this.updateDOMSelection(); $updateDOMForSelection(editor, this.table, this.tableSelection); } else { this.$clearHighlight(); } } /** * @internal * Firefox has a strange behavior where pressing the down arrow key from * above the table will move the caret after the table and then lexical * will select the last cell instead of the first. * We do still want to let the browser handle caret movement but we will * use this property to "tag" the update so that we can recheck the * selection after the event is processed. */ setShouldCheckSelection() { this.shouldCheckSelection = true; } /** * @internal */ getAndClearShouldCheckSelection() { if (this.shouldCheckSelection) { this.shouldCheckSelection = false; return true; } return false; } /** * @internal * When handling mousemove events we track what the focus cell should be, but * the DOM selection may end up somewhere else entirely. We don't have an elegant * way to handle this after the DOM selection has been resolved in a * SELECTION_CHANGE_COMMAND callback. */ setNextFocus(nextFocus) { this.nextFocus = nextFocus; } /** @internal */ getAndClearNextFocus() { const { nextFocus } = this; if (nextFocus !== null) { this.nextFocus = null; } return nextFocus; } /** @internal */ updateDOMSelection() { if (this.anchorCell !== null && this.focusCell !== null) { const domSelection = lexical.getDOMSelection(this.editor._window); // We are not using a native selection for tables, and if we // set one then the reconciler will undo it. // TODO - it would make sense to have one so that native // copy/paste worked. Right now we have to emulate with // keyboard events but it won't fire if triggered from the menu if (domSelection && domSelection.rangeCount > 0) { domSelection.removeAllRanges(); } } } $setFocusCellForSelection(cell, ignoreStart = false) { const editor = this.editor; const { tableNode } = this.$lookup(); const cellX = cell.x; const cellY = cell.y; this.focusCell = cell; if (!this.isHighlightingCells && (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart)) { this.isHighlightingCells = true; this.$disableHighlightStyle(); } else if (cellX === this.focusX && cellY === this.focusY) { return false; } this.focusX = cellX; this.focusY = cellY; if (this.isHighlightingCells) { const focusTableCellNode = $getNearestTableCellInTableFromDOMNode(tableNode, cell.elem); if (this.tableSelection != null && this.anchorCellNodeKey != null && focusTableCellNode !== null) { this.focusCellNodeKey = focusTableCellNode.getKey(); this.tableSelection = $createTableSelectionFrom(tableNode, this.$getAnchorTableCellOrThrow(), focusTableCellNode); lexical.$setSelection(this.tableSelection); editor.dispatchCommand(lexical.SELECTION_CHANGE_COMMAND, undefined); $updateDOMForSelection(editor, this.table, this.tableSelection); return true; } } return false; } $getAnchorTableCell() { return this.anchorCellNodeKey ? lexical.$getNodeByKey(this.anchorCellNodeKey) : null; } $getAnchorTableCellOrThrow() { const anchorTableCell = this.$getAnchorTableCell(); if (!(anchorTableCell !== null)) { formatDevErrorMessage(`TableObserver anchorTableCell is null`); } return anchorTableCell; } $getFocusTableCell() { return this.focusCellNodeKey ? lexical.$getNodeByKey(this.focusCellNodeKey) : null; } $getFocusTableCellOrThrow() { const focusTableCell = this.$getFocusTableCell(); if (!(focusTableCell !== null)) { formatDevErrorMessage(`TableObserver focusTableCell is null`); } return focusTableCell; } $setAnchorCellForSelection(cell) { this.isHighlightingCells = false; this.anchorCell = cell; this.anchorX = cell.x; this.anchorY = cell.y; const { tableNode } = this.$lookup(); const anchorTableCellNode = $getNearestTableCellInTableFromDOMNode(tableNode, cell.elem); if (anchorTableCellNode !== null) { const anchorNodeKey = anchorTableCellNode.getKey(); this.tableSelection = this.tableSelection != null ? this.tableSelection.clone() : $createTableSelection(); this.anchorCellNodeKey = anchorNodeKey; } } $formatCells(type) { const selection = lexical.$getSelection(); if (!$isTableSelection(selection)) { formatDevErrorMessage(`Expected Table selection`); } const formatSelection = lexical.$createRangeSelection(); const anchor = formatSelection.anchor; const focus = formatSelection.focus; const cellNodes = selection.getNodes().filter($isTableCellNode); if (!(cellNodes.length > 0)) { formatDevErrorMessage(`No table cells present`); } const paragraph = cellNodes[0].getFirstChild(); const alignFormatWith = lexical.$isParagraphNode(paragraph) ? paragraph.getFormatFlags(type, null) : null; cellNodes.forEach(cellNode => { anchor.set(cellNode.getKey(), 0, 'element'); focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); formatSelection.formatText(type, alignFormatWith); }); lexical.$setSelection(selection); this.editor.dispatchCommand(lexical.SELECTION_CHANGE_COMMAND, undefined); } $clearText() { const { editor } = this; const tableNode = lexical.$getNodeByKey(this.tableNodeKey); if (!$isTableNode(tableNode)) { throw new Error('Expected TableNode.'); } const selection = lexical.$getSelection(); if (!$isTableSelection(selection)) { formatDevErrorMessage(`Expected TableSelection`); } const selectedNodes = selection.getNodes().filter($isTableCellNode); // Check if the entire table is selected by verifying first and last cells const firstRow = tableNode.getFirstChild(); const lastRow = tableNode.getLastChild(); const isEntireTableSelected = selectedNodes.length > 0 && firstRow !== null && lastRow !== null && $isTableRowNode(firstRow) && $isTableRowNode(lastRow) && selectedNodes[0] === firstRow.getFirstChild() && selectedNodes[selectedNodes.length - 1] === lastRow.getLastChild(); if (isEntireTableSelected) { tableNode.selectPrevious(); const parent = tableNode.getParent(); // Delete entire table tableNode.remove(); // Handle case when table was the only node if (lexical.$isRootNode(parent) && parent.isEmpty()) { editor.dispatchCommand(lexical.INSERT_PARAGRAPH_COMMAND, undefined); } return; } selectedNodes.forEach(cellNode => { if (lexical.$isElementNode(cellNode)) { const paragraphNode = lexical.$createParagraphNode(); const textNode = lexical.$createTextNode(); paragraphNode.append(textNode); cellNode.append(paragraphNode); cellNode.getChildren().forEach(child => { if (child !== paragraphNode) { child.remove(); } }); } }); $updateDOMForSelection(editor, this.table, null); lexical.$setSelection(null); editor.dispatchCommand(lexical.SELECTION_CHANGE_COMMAND, undefined); } } const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection'; const isPointerDownOnEvent = event => { return (event.buttons & 1) === 1; }; function isHTMLTableElement(el) { return lexical.isHTMLElement(el) && el.nodeName === 'TABLE'; } function getTableElement(tableNode, dom) { if (!dom) { return dom; } const element = isHTMLTableElement(dom) ? dom : tableNode.getDOMSlot(dom).element; if (!(element.nodeName === 'TABLE')) { formatDevErrorMessage(`getTableElement: Expecting table in as DOM node for TableNode, not ${dom.nodeName}`); } return element; } function getEditorWindow(editor) { return editor._window; } function $findParentTableCellNodeInTable(tableNode, node) { for (let currentNode = node, lastTableCellNode = 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 = [[lexical.KEY_ARROW_DOWN_COMMAND, 'down'], [lexical.KEY_ARROW_UP_COMMAND, 'up'], [lexical.KEY_ARROW_LEFT_COMMAND, 'backward'], [lexical.KEY_ARROW_RIGHT_COMMAND, 'forward']]; const DELETE_TEXT_COMMANDS = [lexical.DELETE_WORD_COMMAND, lexical.DELETE_LINE_COMMAND, lexical.DELETE_CHARACTER_COMMAND]; const DELETE_KEY_COMMANDS = [lexical.KEY_BACKSPACE_COMMAND, lexical.KEY_DELETE_COMMAND]; function applyTableHandlers(tableNode, element, editor, hasTabHandler) { const rootElement = editor.getRootElement(); const editorWindow = getEditorWindow(editor); if (!(rootElement !== null && editorWindow !== null)) { formatDevErrorMessage(`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 createPointerHandlers = () => { if (tableObserver.isSelecting) { return; } const onPointerUp = () => { tableObserver.isSelecting = false; editorWindow.removeEventListener('pointerup', onPointerUp); editorWindow.removeEventListener('pointermove', onPointerMove); }; const onPointerMove = moveEvent => { if (!isPointerDownOnEvent(moveEvent) && tableObserver.isSelecting) { tableObserver.isSelecting = false; editorWindow.removeEventListener('pointerup', onPointerUp); editorWindow.removeEventListener('pointermove', onPointerMove); return; } if (!lexical.isDOMNode(moveEvent.target)) { return; } let focusCell = null; // In firefox the moveEvent.target may be captured so we must always // consult the coordinates #7245 const override = !(IS_FIREFOX || tableElement.contains(moveEvent.target)); if (override) { focusCell = getDOMCellInTableFromTarget(tableElement, moveEvent.target); } else { for (const el of document.elementsFromPoint(moveEvent.clientX, moveEvent.clientY)) { focusCell = getDOMCellInTableFromTarget(tableElement, el); if (focusCell) { break; } } } if (focusCell && (tableObserver.focusCell === null || focusCell.elem !== tableObserver.focusCell.elem)) { tableObserver.setNextFocus({ focusCell, override }); editor.dispatchCommand(lexical.SELECTION_CHANGE_COMMAND, undefined); } }; tableObserver.isSelecting = true; editorWindow.addEventListener('pointerup', onPointerUp, tableObserver.listenerOptions); editorWindow.addEventListener('pointermove', onPointerMove, tableObserver.listenerOptions); }; const onPointerDown = event => { tableObserver.pointerType = event.pointerType; if (event.button !== 0 || !lexical.isDOMNode(event.target) || !editorWindow) { return; } const targetCell = getDOMCellFromTarget(event.target); if (targetCell !== null) { editor.update(() => { const prevSelection = lexical.$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) && (lexical.$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(targetCell); 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(targetCell); } } }); } createPointerHandlers(); }; tableElement.addEventListener('pointerdown', onPointerDown, tableObserver.listenerOptions); tableObserver.listenersToRemove.add(() => { tableElement.removeEventListener('pointerdown', onPointerDown); }); const onTripleClick = event => { if (event.detail >= 3 && lexical.isDOMNode(event.target)) { const targetCell = getDOMCellFromTarget(event.target); if (targetCell !== null) { event.preventDefault(); } } }; tableElement.addEventListener('mousedown', onTripleClick, tableObserver.listenerOptions); tableObserver.listenersToRemove.add(() => { tableElement.removeEventListener('mousedown', onTripleClick); }); // Clear selection when clicking outside of dom. const pointerDownCallback = event => { const target = event.target; if (event.button !== 0 || !lexical.isDOMNode(target)) { return; } editor.update(() => { const selection = lexical.$getSelection(); if ($isTableSelection(selection) && selection.tableKey === tableObserver.tableNodeKey && rootElement.contains(target)) { tableObserver.$clearHighlight(); } }); }; editorWindow.addEventListener('pointerdown', pointerDownCallback, tableObserver.listenerOptions); tableObserver.listenersToRemove.add(() => { editorWindow.removeEventListener('pointerdown', pointerDownCallback); }); for (const [command, direction] of ARROW_KEY_COMMANDS_WITH_DIRECTION) { tableObserver.listenersToRemove.add(editor.registerCommand(command, event => $handleArrowKey(editor, event, direction, tableNode, tableObserver), lexical.COMMAND_PRIORITY_HIGH)); } tableObserver.listenersToRemove.add(editor.registerCommand(lexical.KEY_ESCAPE_COMMAND, event => { const selection = lexical.$getSelection(); if ($isTableSelection(selection)) { const focusCellNode = $findParentTableCellNodeInTable(tableNode, selection.focus.getNode()); if (focusCellNode !== null) { stopEvent(event); focusCellNode.selectEnd(); return true; } } return false; }, lexical.COMMAND_PRIORITY_HIGH)); const deleteTextHandler = command => () => { const selection = lexical.$getSelection(); if (!$isSelectionInTable(selection, tableNode)) { return false; } if ($isTableSelection(selection)) { tableObserver.$clearText(); return true; } else if (lexical.$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 = utils.$findMatchingParent(selection.anchor.getNode(), n => lexical.$isElementNode(n)); const topLevelCellElementNode = nearestElementNode && utils.$findMatchingParent(nearestElementNode, n => lexical.$isElementNode(n) && $isTableCellNode(n.getParent())); if (!lexical.$isElementNode(topLevelCellElementNode) || !lexical.$isElementNode(nearestElementNode)) { return false; } if (command === lexical.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), lexical.COMMAND_PRIORITY_CRITICAL)); } const $deleteCellHandler = event => { const selection = lexical.$getSelection(); if (!($isTableSelection(selection) || lexical.$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, lexical.COMMAND_PRIORITY_CRITICAL)); } tableObserver.listenersToRemove.add(editor.registerCommand(lexical.CUT_COMMAND, event => { const selection = lexical.$getSelection(); if (selection) { if (!($isTableSelection(selection) || lexical.$isRangeSelection(selection))) { return false; } // Copying to the clipboard is async so we must capture the data // before we delete it void clipboard.copyToClipboard(editor, utils.objectKlassEquals(event, ClipboardEvent) ? event : null, clipboard.$getClipboardDataFromSelection(selection)); const intercepted = $deleteCellHandler(event); if (lexical.$isRangeSelection(selection)) { selection.removeText(); return true; } return intercepted; } return false; }, lexical.COMMAND_PRIORITY_CRITICAL)); tableObserver.listenersToRemove.add(editor.registerCommand(lexical.FORMAT_TEXT_COMMAND, payload => { const selection = lexical.$getSelection(); if (!$isSelectionInTable(selection, tableNode)) { return false; } if ($isTableSelection(selection)) { tableObserver.$formatCells(payload); return true; } else if (lexical.$isRangeSelection(selection)) { const tableCellNode = utils.$findMatchingParent(selection.anchor.getNode(), n => $isTableCellNode(n)); if (!$isTableCellNode(tableCellNode)) { return false; } } return false; }, lexical.COMMAND_PRIORITY_CRITICAL)); tableObserver.listenersToRemove.add(editor.registerCommand(lexical.FORMAT_ELEMENT_COMMAND, formatType => { const selection = lexical.$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(); 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 (lexical.$isElementNode(child) && !child.isInline()) { child.setFormat(formatType); } } } } return true; }, lexical.COMMAND_PRIORITY_CRITICAL)); tableObserver.listenersToRemove.add(editor.registerCommand(lexical.CONTROLLED_TEXT_INSERTION_COMMAND, payload => { const selection = lexical.$getSelection(); if (!$isSelectionInTable(selection, tableNode)) { return false; } if ($isTableSelection(selection)) { tableObserver.$clearHighlight(); return false; } else if (lexical.$isRangeSelection(selection)) { const tableCellNode = utils.$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, [lexical.$createTextNode(payload)]); return true; } } } return false; }, lexical.COMMAND_PRIORITY_CRITICAL)); if (hasTabHandler) { tableObserver.listenersToRemove.add(editor.registerCommand(lexical.KEY_TAB_COMMAND, event => { const selection = lexical.$getSelection(); if (!lexical.$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; }, lexical.COMMAND_PRIORITY_CRITICAL)); } tableObserver.listenersToRemove.add(editor.registerCommand(lexical.FOCUS_COMMAND, payload => { return tableNode.isSelected(); }, lexical.COMMAND_PRIORITY_HIGH)); tableObserver.listenersToRemove.add(editor.registerCommand(lexical.SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, (selectionPayload, dispatchEditor) => { if (editor !== dispatchEditor) { return false; } const { nodes, selection } = selectionPayload; const anchorAndFocus = selection.getStartEndPoints(); const isTableSelection = $isTableSelection(selection); const isRangeSelection = lexical.$isRangeSelection(selection); const isSelectionInsideOfGrid = isRangeSelection && utils.$findMatchingParent(selection.anchor.getNode(), n => $isTableCellNode(n)) !== null && utils.$findMatchingParent(selection.focus.getNode(), n => $isTableCellNode(n)) !== null || isTableSelection; if (nodes.length !== 1 || !$isTableNode(nodes[0]) || !isSelectionInsideOfGrid || anchorAndFocus === null) { return false; } const [anchor, focus] = anchorAndFocus; const [anchorCellNode, anchorRowNode, gridNode] = $getNodeTriplet(anchor); const focusCellNode = utils.$findMatchingParent(focus.getNode(), n => $isTableCellNode(n)); if (!$isTableCellNode(anchorCellNode) || !$isTableCellNode(focusCellNode) || !$isTableRowNode(anchorRowNode) || !$isTableNode(gridNode)) { return false; } const templateGrid = nodes[0]; const [initialGridMap, anchorCellMap, focusCellMap] = $computeTableMap(gridNode, anchorCellNode, focusCellNode); const [templateGridMap] = $computeTableMapSkipCellCheck(templateGrid, null, null); const initialRowCount = initialGridMap.length; const initialColCount = initialRowCount > 0 ? initialGridMap[0].length : 0; // If we have a range selection, we'll fit the template grid into the // table, growing the table if necessary. let startRow = anchorCellMap.startRow; let startCol = anchorCellMap.startColumn; let affectedRowCount = templateGridMap.length; let affectedColCount = affectedRowCount > 0 ? templateGridMap[0].length : 0; if (isTableSelection) { // If we have a table selection, we'll only modify the cells within // the selection boundary. const selectionBoundary = $computeTableCellRectBoundary(initialGridMap, anchorCellMap, focusCellMap); const selectionRowCount = selectionBoundary.maxRow - selectionBoundary.minRow + 1; const selectionColCount = selectionBoundary.maxColumn - selectionBoundary.minColumn + 1; startRow = selectionBoundary.minRow; startCol = selectionBoundary.minColumn; affectedRowCount = Math.min(affectedRowCount, selectionRowCount); affectedColCount = Math.min(affectedColCount, selectionColCount); } // Step 1: Unmerge all merged cells within the affected area let didPerformMergeOperations = false; const lastRowForUnmerge = Math.min(initialRowCount, startRow + affectedRowCount) - 1; const lastColForUnmerge = Math.min(initialColCount, startCol + affectedColCount) - 1; const unmergedKeys = new Set(); for (let row = startRow; row <= lastRowForUnmerge; row++) { for (let col = startCol; col <= lastColForUnmerge; col++) { const cellMap = initialGridMap[row][col]; if (unmergedKeys.has(cellMap.cell.getKey())) { continue; // cell was a merged cell that was already handled } if (cellMap.cell.__rowSpan === 1 && cellMap.cell.__colSpan === 1) { continue; // cell is not a merged cell } $unmergeCellNode(cellMap.cell); unmergedKeys.add(cellMap.cell.getKey()); didPerformMergeOperations = true; } } let [interimGridMap] = $computeTableMapSkipCellCheck(gridNode.getWritable(), null, null); // Step 2: Expand current table (if needed) const rowsToInsert = affectedRowCount - initialRowCount + startRow; for (let i = 0; i < rowsToInsert; i++) { const cellMap = interimGridMap[initialRowCount - 1][0]; $insertTableRowAtNode(cellMap.cell); } const colsToInsert = affectedColCount - initialColCount + startCol; for (let i = 0; i < colsToInsert; i++) { const cellMap = interimGridMap[0][initialColCount - 1]; $insertTableColumnAtNode(cellMap.cell, true, false); } [interimGridMap] = $computeTableMapSkipCellCheck(gridNode.getWritable(), null, null); // Step 3: Merge cells and set cell content, to match template grid for (let row = startRow; row < startRow + affectedRowCount; row++) { for (let col = startCol; col < startCol + affectedColCount; col++) { const templateRow = row - startRow; const templateCol = col - startCol; const templateCellMap = templateGridMap[templateRow][templateCol]; if (templateCellMap.startRow !== templateRow || templateCellMap.startColumn !== templateCol) { continue; // cell is a merged cell that was already handled } const templateCell = templateCellMap.cell; if (templateCell.__rowSpan !== 1 || templateCell.__colSpan !== 1) { const cellsToMerge = []; const lastRowForMerge = Math.min(row + templateCell.__rowSpan, startRow + affectedRowCount) - 1; const lastColForMerge = Math.min(col + templateCell.__colSpan, startCol + affectedColCount) - 1; for (let r = row; r <= lastRowForMerge; r++) { for (let c = col; c <= lastColForMerge; c++) { const cellMap = interimGridMap[r][c]; cellsToMerge.push(cellMap.cell); } } $mergeCells(cellsToMerge); didPerformMergeOperations = true; } const { cell } = interimGridMap[row][col]; const originalChildren = cell.getChildren(); templateCell.getChildren().forEach(child => { if (lexical.$isTextNode(child)) { const paragraphNode = lexical.$createParagraphNode(); paragraphNode.append(child); cell.append(child); } else { cell.append(child); } }); originalChildren.forEach(n => n.remove()); } } if (isTableSelection && didPerformMergeOperations) { // reset the table selection in case the anchor or focus cell was // removed via merge operations const [finalGridMap] = $computeTableMapSkipCellCheck(gridNode.getWritable(), null, null); const newAnchorCellMap = finalGridMap[anchorCellMap.startRow][anchorCellMap.startColumn]; newAnchorCellMap.cell.selectEnd(); } return true; }, lexical.COMMAND_PRIORITY_CRITICAL)); tableObserver.listenersToRemove.add(editor.registerCommand(lexical.SELECTION_CHANGE_COMMAND, () => { const selection = lexical.$getSelection(); const prevSelection = lexical.$getPreviousSelection(); const nextFocus = tableObserver.getAndClearNextFocus(); if (nextFocus !== null) { const { focusCell } = nextFocus; 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 (focusCell !== tableObserver.anchorCell && $isSelectionInTable(selection, tableNode)) { // The selection has crossed cells tableObserver.$setFocusCellForSelection(focusCell); return true; } } const shouldCheckSelection = tableObserver.getAndClearShouldCheckSelection(); // 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 (shouldCheckSelection && lexical.$isRangeSelection(prevSelection) && lexical.$isRangeSelection(selection) && selection.isCollapsed()) { 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(utils.$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 (lexical.$isRangeSelection(selection)) { const { anchor, focus } = selection; const anchorNode = anchor.getNode(); const focusNode = focus.getNode(); // Using explicit comparison with table node to ensure it's not a nested table // as in that case we'll leave selection resolving to that table const anchorCellNode = $findCellNode(anchorNode); const focusCellNode = $findCellNode(focusNode); const isAnchorInside = !!(anchorCellNode && tableNode.is($findTableNode(anchorCellNode))); const isFocusInside = !!(focusCellNode && tableNode.is($findTableNode(focusCellNode))); const isPartiallyWithinTable = isAnchorInside !== isFocusInside; const isWithinTable = isAnchorInside && isFocusInside; const isBackward = selection.isBackward(); if (isPartiallyWithinTable) { const newSelection = selection.clone(); if (isFocusInside) { const [tableMap] = $computeTableMap(tableNode, focusCellNode, focusCellNode); const firstCell = tableMap[0][0].cell; const lastCell = tableMap[tableMap.length - 1].at(-1).cell; newSelection.focus.set(isBackward ? firstCell.getKey() : lastCell.getKey(), isBackward ? firstCell.getChildrenSize() : lastCell.getChildrenSize(), 'element'); } else if (isAnchorInside) { const [tableMap] = $computeTableMap(tableNode, anchorCellNode, anchorCellNode); const firstCell = tableMap[0][0].cell; const lastCell = tableMap[tableMap.length - 1].at(-1).cell; /** * If isBackward, set the anchor to be at the end of the table so that when the cursor moves outside of * the table in the backward direction, the entire table will be selected from its end. * Otherwise, if forward, set the anchor to be at the start of the table so that when the focus is dragged * outside th end of the table, it will start from the beginning of the table. */ newSelection.anchor.set(isBackward ? lastCell.getKey() : firstCell.getKey(), isBackward ? lastCell.getChildrenSize() : 0, 'element'); } lexical.$setSelection(newSelection); $addHighlightStyleToTable(editor, tableObserver); } else if (isWithinTable) { // Handle case when selection spans across multiple cells but still // has range selection, then we convert it into table selection 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() && lexical.$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; } } } } else if (selection && $isTableSelection(selection) && selection.is(prevSelection) && selection.tableKey === tableNode.getKey()) { // if selection goes outside of the table we need to change it to Range selection const domSelection = lexical.getDOMSelection(editorWindow); if (domSelection && domSelection.anchorNode && domSelection.focusNode) { const focusNode = lexical.$getNearestNodeFromDOMNode(domSelection.focusNode); const isFocusOutside = focusNode && !tableNode.isParentOf(focusNode); const anchorNode = lexical.$getNearestNodeFromDOMNode(domSelection.anchorNode); const isAnchorInside = anchorNode && tableNode.isParentOf(anchorNode); if (isFocusOutside && isAnchorInside && domSelection.rangeCount > 0) { const newSelection = lexical.$createRangeSelectionFromDom(domSelection, editor); if (newSelection) { newSelection.anchor.set(tableNode.getKey(), selection.isBackward() ? tableNode.getChildrenSize() : 0, 'element'); domSelection.removeAllRanges(); lexical.$setSelection(newSelection); } } } } if (selection && !selection.is(prevSelection) && ($isTableSelection(selection) || $isTableSelection(prevSelection)) && tableObserver.tableSelection && !tableObserver.tableSelection.is(prevSelection)) { if ($isTableSelection(selection) && selection.tableKey === tableObserver.tableNodeKey) { tableObserver.$updateTableTableSelection(selection); } else if (!$isTableSelection(selection) && $isTableSelection(prevSelection) && prevSelection.tableKey === tableObserver.tableNodeKey) { tableObserver.$updateTableTableSelection(null); } return false; } if (tableObserver.hasHijackedSelectionStyles && !tableNode.isSelected()) { $removeHighlightStyleToTable(editor, tableObserver); } else if (!tableObserver.hasHijackedSelectionStyles && tableNode.isSelected()) { $addHighlightStyleToTable(editor, tableObserver); } return false; }, lexical.COMMAND_PRIORITY_CRITICAL)); tableObserver.listenersToRemove.add(editor.registerCommand(lexical.INSERT_PARAGRAPH_COMMAND, () => { const selection = lexical.$getSelection(); if (!lexical.$isRangeSelection(selection) || !selection.isCollapsed() || !$isSelectionInTable(selection, tableNode)) { return false; } const edgePosition = $getTableEdgeCursorPosition(editor, selection, tableNode); if (edgePosition) { $insertParagraphAtTableEdge(edgePosition, tableNode); return true; } return false; }, lexical.COMMAND_PRIORITY_CRITICAL)); return tableObserver; } function detachTableObserverFromTableElement(tableElement, tableObserver) { if (getTableObserverFromTableElement(tableElement) === tableObserver) { delete tableElement[LEXICAL_ELEMENT_KEY]; } } function attachTableObserverToTableElement(tableElement, tableObserver) { if (!(getTableObserverFromTableElement(tableElement) === null)) { formatDevErrorMessage(`tableElement already has an attached TableObserver`); } tableElement[LEXICAL_ELEMENT_KEY] = tableObserver; } function getTableObserverFromTableElement(tableElement) { return tableElement[LEXICAL_ELEMENT_KEY] || null; } function getDOMCellFromTarget(node) { let currentNode = 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; } function getDOMCellInTableFromTarget(table, node) { if (!table.contains(node)) { return null; } let cell = null; for (let currentNode = 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; } function getTable(tableNode, dom) { const tableElement = getTableElement(tableNode, dom); const domRows = []; const grid = { columns: 0, domRows, rows: 0 }; let currentNode = tableElement.querySelector('tr'); let x = 0; let y = 0; domRows.length = 0; while (currentNode != null) { const nodeMame = currentNode.nodeName; if (nodeMame === 'TD' || nodeMame === 'TH') { const elem = currentNode; 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; } function $updateDOMForSelection(editor, table, selection) { 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'); } } }); } function $forEachTableCell(grid, cb) { const { domRows } = grid; for (let y = 0; y < domRows.length; y++) { const row = domRows[y]; if (!row) { continue; } for (let x = 0; x < row.length; x++) { const cell = row[x]; if (!cell) { continue; } const lexicalNode = lexical.$getNearestNodeFromDOMNode(cell.elem); if (lexicalNode !== null) { cb(cell, lexicalNode, { x, y }); } } } } function $addHighlightStyleToTable(editor, tableSelection) { tableSelection.$disableHighlightStyle(); $forEachTableCell(tableSelection.table, cell => { cell.highlighted = true; $addHighlightToDOM(editor, cell); }); } function $removeHighlightStyleToTable(editor, tableObserver) { tableObserver.$enableHighlightStyle(); $forEachTableCell(tableObserver.table, cell => { const elem = cell.elem; cell.highlighted = false; $removeHighlightFromDOM(editor, cell); if (!elem.getAttribute('style')) { elem.removeAttribute('style'); } }); } function $selectAdjacentCell(tableCellNode, direction) { const siblingMethod = direction === 'next' ? 'getNextSibling' : 'getPreviousSibling'; const childMethod = direction === 'next' ? 'getFirstChild' : 'getLastChild'; const sibling = tableCellNode[siblingMethod](); if (lexical.$isElementNode(sibling)) { return sibling.selectEnd(); } const parentRow = utils.$findMatchingParent(tableCellNode, $isTableRowNode); if (!(parentRow !== null)) { formatDevErrorMessage(`selectAdjacentCell: Cell not in table row`); } for (let nextRow = parentRow[siblingMethod](); $isTableRowNode(nextRow); nextRow = nextRow[siblingMethod]()) { const child = nextRow[childMethod](); if (lexical.$isElementNode(child)) { return child.selectEnd(); } } const parentTable = utils.$findMatchingParent(parentRow, $isTableNode); if (!(parentTable !== null)) { formatDevErrorMessage(`selectAdjacentCell: Row not in table`); } return direction === 'next' ? parentTable.selectNext() : parentTable.selectPrevious(); } const selectTableNodeInDirection = (tableObserver, tableNode, x, y, direction) => { const isForward = direction === 'forward'; switch (direction) { case 'backward': case 'forward': if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) { selectTableCellNode(tableNode.getCellNodeFromCordsOrThrow(x + (isForward ? 1 : -1), y, tableObserver.table), isForward); } else { if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) { selectTableCellNode(tableNode.getCellNodeFromCordsOrThrow(isForward ? 0 : tableObserver.table.columns - 1, y + (isForward ? 1 : -1), tableObserver.table), isForward); } else if (!isForward) { tableNode.selectPrevious(); } else { tableNode.selectNext(); } } return true; case 'up': if (y !== 0) { selectTableCellNode(tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table), false); } else { tableNode.selectPrevious(); } return true; case 'down': if (y !== tableObserver.table.rows - 1) { selectTableCellNode(tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table), true); } else { tableNode.selectNext(); } return true; default: return false; } }; function getCorner(rect, cellValue) { let colName; let rowName; if (cellValue.startColumn === rect.minColumn) { colName = 'minColumn'; } else if (cellValue.startColumn + cellValue.cell.__colSpan - 1 === rect.maxColumn) { colName = 'maxColumn'; } else { return null; } if (cellValue.startRow === rect.minRow) { rowName = 'minRow'; } else if (cellValue.startRow + cellValue.cell.__rowSpan - 1 === rect.maxRow) { rowName = 'maxRow'; } else { return null; } return [colName, rowName]; } function getCornerOrThrow(rect, cellValue) { const corner = getCorner(rect, cellValue); if (!(corner !== null)) { formatDevErrorMessage(`getCornerOrThrow: cell ${cellValue.cell.getKey()} is not at a corner of rect`); } return corner; } function oppositeCorner([colName, rowName]) { return [colName === 'minColumn' ? 'maxColumn' : 'minColumn', rowName === 'minRow' ? 'maxRow' : 'minRow']; } function cellAtCornerOrThrow(tableMap, rect, [colName, rowName]) { const rowNum = rect[rowName]; const rowMap = tableMap[rowNum]; if (!(rowMap !== undefined)) { formatDevErrorMessage(`cellAtCornerOrThrow: ${rowName} = ${String(rowNum)} missing in tableMap`); } const colNum = rect[colName]; const cell = rowMap[colNum]; if (!(cell !== undefined)) { formatDevErrorMessage(`cellAtCornerOrThrow: ${colName} = ${String(colNum)} missing in tableMap`); } return cell; } function $extractRectCorners(tableMap, anchorCellValue, newFocusCellValue) { // We are sure that the focus now either contracts or expands the rect // but both the anchor and focus might be moved to ensure a rectangle // given a potentially ragged merge shape const rect = $computeTableCellRectBoundary(tableMap, anchorCellValue, newFocusCellValue); const anchorCorner = getCorner(rect, anchorCellValue); if (anchorCorner) { return [cellAtCornerOrThrow(tableMap, rect, anchorCorner), cellAtCornerOrThrow(tableMap, rect, oppositeCorner(anchorCorner))]; } const newFocusCorner = getCorner(rect, newFocusCellValue); if (newFocusCorner) { return [cellAtCornerOrThrow(tableMap, rect, oppositeCorner(newFocusCorner)), cellAtCornerOrThrow(tableMap, rect, newFocusCorner)]; } // TODO this doesn't have to be arbitrary, use the closest corner instead const newAnchorCorner = ['minColumn', 'minRow']; return [cellAtCornerOrThrow(tableMap, rect, newAnchorCorner), cellAtCornerOrThrow(tableMap, rect, oppositeCorner(newAnchorCorner))]; } function $adjustFocusInDirection(tableObserver, tableMap, anchorCellValue, focusCellValue, direction) { const rect = $computeTableCellRectBoundary(tableMap, anchorCellValue, focusCellValue); const spans = $computeTableCellRectSpans(tableMap, rect); const { topSpan, leftSpan, bottomSpan, rightSpan } = spans; const anchorCorner = getCornerOrThrow(rect, anchorCellValue); const [focusColumn, focusRow] = oppositeCorner(anchorCorner); let fCol = rect[focusColumn]; let fRow = rect[focusRow]; if (direction === 'forward') { fCol += focusColumn === 'maxColumn' ? 1 : leftSpan; } else if (direction === 'backward') { fCol -= focusColumn === 'minColumn' ? 1 : rightSpan; } else if (direction === 'down') { fRow += focusRow === 'maxRow' ? 1 : topSpan; } else if (direction === 'up') { fRow -= focusRow === 'minRow' ? 1 : bottomSpan; } const targetRowMap = tableMap[fRow]; if (targetRowMap === undefined) { return false; } const newFocusCellValue = targetRowMap[fCol]; if (newFocusCellValue === undefined) { return false; } // We can be certain that anchorCellValue and newFocusCellValue are // contained within the desired selection, but we are not certain if // they need to be expanded or not to maintain a rectangular shape const [finalAnchorCell, finalFocusCell] = $extractRectCorners(tableMap, anchorCellValue, newFocusCellValue); const anchorDOM = $getObserverCellFromCellNodeOrThrow(tableObserver, finalAnchorCell.cell); const focusDOM = $getObserverCellFromCellNodeOrThrow(tableObserver, finalFocusCell.cell); tableObserver.$setAnchorCellForSelection(anchorDOM); tableObserver.$setFocusCellForSelection(focusDOM, true); return true; } function $isSelectionInTable(selection, tableNode) { if (lexical.$isRangeSelection(selection) || $isTableSelection(selection)) { // TODO this should probably return false if there's an unrelated // shadow root between the node and the table (e.g. another table, // collapsible, etc.) const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode()); const isFocusInside = tableNode.isParentOf(selection.focus.getNode()); return isAnchorInside && isFocusInside; } return false; } function $isFullTableSelection(selection, tableNode) { if ($isTableSelection(selection)) { const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); if (tableNode && anchorNode && focusNode) { const [map] = $computeTableMap(tableNode, anchorNode, focusNode); return anchorNode.getKey() === map[0][0].cell.getKey() && focusNode.getKey() === map[map.length - 1].at(-1).cell.getKey(); } } return false; } function selectTableCellNode(tableCell, fromStart) { if (fromStart) { tableCell.selectStart(); } else { tableCell.selectEnd(); } } function $addHighlightToDOM(editor, cell) { const element = cell.elem; const editorThemeClasses = editor._config.theme; const node = lexical.$getNearestNodeFromDOMNode(element); if (!$isTableCellNode(node)) { formatDevErrorMessage(`Expected to find LexicalNode from Table Cell DOMNode`); } utils.addClassNamesToElement(element, editorThemeClasses.tableCellSelected); } function $removeHighlightFromDOM(editor, cell) { const element = cell.elem; const node = lexical.$getNearestNodeFromDOMNode(element); if (!$isTableCellNode(node)) { formatDevErrorMessage(`Expected to find LexicalNode from Table Cell DOMNode`); } const editorThemeClasses = editor._config.theme; utils.removeClassNamesFromElement(element, editorThemeClasses.tableCellSelected); } function $findCellNode(node) { const cellNode = utils.$findMatchingParent(node, $isTableCellNode); return $isTableCellNode(cellNode) ? cellNode : null; } function $findTableNode(node) { const tableNode = utils.$findMatchingParent(node, $isTableNode); return $isTableNode(tableNode) ? tableNode : null; } function $getBlockParentIfFirstNode(node) { for (let prevNode = node, currentNode = node; currentNode !== null; prevNode = currentNode, currentNode = currentNode.getParent()) { if (lexical.$isElementNode(currentNode)) { if (currentNode !== prevNode && currentNode.getFirstChild() !== prevNode) { // Not the first child or the initial node return null; } else if (!currentNode.isInline()) { return currentNode; } } } return null; } function $handleHorizontalArrowKeyRangeSelection(editor, event, selection, alter, isBackward, tableNode, tableObserver) { const initialFocus = lexical.$caretFromPoint(selection.focus, isBackward ? 'previous' : 'next'); if (lexical.$isExtendableTextPointCaret(initialFocus)) { return false; } let lastCaret = initialFocus; // TableCellNode is the only shadow root we are interested in piercing so // we find the last internal caret and then check its parent for (const nextCaret of lexical.$extendCaretToRange(initialFocus).iterNodeCarets('shadowRoot')) { if (!(lexical.$isSiblingCaret(nextCaret) && lexical.$isElementNode(nextCaret.origin))) { return false; } lastCaret = nextCaret; } const lastCaretParent = lastCaret.getParentAtCaret(); if (!$isTableCellNode(lastCaretParent)) { return false; } const anchorCell = lastCaretParent; const focusCaret = $findNextTableCell(lexical.$getSiblingCaret(anchorCell, lastCaret.direction)); const anchorCellTable = utils.$findMatchingParent(anchorCell, $isTableNode); if (!(anchorCellTable && anchorCellTable.is(tableNode))) { return false; } const anchorCellDOM = editor.getElementByKey(anchorCell.getKey()); const anchorDOMCell = getDOMCellFromTarget(anchorCellDOM); if (!anchorCellDOM || !anchorDOMCell) { return false; } const anchorCellTableElement = $getElementForTableNode(editor, anchorCellTable); tableObserver.table = anchorCellTableElement; if (!focusCaret) { if (alter === 'extend') { // extend the selection from a range inside the cell to a table selection of the cell tableObserver.$setAnchorCellForSelection(anchorDOMCell); tableObserver.$setFocusCellForSelection(anchorDOMCell, true); } else { // exit the table const outerFocusCaret = $getTableExitCaret(lexical.$getSiblingCaret(anchorCellTable, initialFocus.direction)); lexical.$setPointFromCaret(selection.anchor, outerFocusCaret); lexical.$setPointFromCaret(selection.focus, outerFocusCaret); } } else if (alter === 'extend') { const focusDOMCell = getDOMCellFromTarget(editor.getElementByKey(focusCaret.origin.getKey())); if (!focusDOMCell) { return false; } tableObserver.$setAnchorCellForSelection(anchorDOMCell); tableObserver.$setFocusCellForSelection(focusDOMCell, true); } else { // alter === 'move' const innerFocusCaret = lexical.$normalizeCaret(focusCaret); lexical.$setPointFromCaret(selection.anchor, innerFocusCaret); lexical.$setPointFromCaret(selection.focus, innerFocusCaret); } stopEvent(event); return true; } function $getTableExitCaret(initialCaret) { const adjacent = lexical.$getAdjacentChildCaret(initialCaret); return lexical.$isChildCaret(adjacent) ? lexical.$normalizeCaret(adjacent) : initialCaret; } function $findNextTableCell(initialCaret) { for (const nextCaret of lexical.$extendCaretToRange(initialCaret).iterNodeCarets('root')) { const { origin } = nextCaret; if ($isTableCellNode(origin)) { // not sure why ts isn't narrowing here (even if the guard is on nextCaret.origin) // but returning a new caret is fine if (lexical.$isChildCaret(nextCaret)) { return lexical.$getChildCaret(origin, initialCaret.direction); } } else if (!$isTableRowNode(origin)) { break; } } return null; } function $handleArrowKey(editor, event, direction, tableNode, tableObserver) { if ((direction === 'up' || direction === 'down') && isTypeaheadMenuInView(editor)) { return false; } const selection = lexical.$getSelection(); if (!$isSelectionInTable(selection, tableNode)) { if (lexical.$isRangeSelection(selection)) { if (direction === 'backward') { if (selection.focus.offset > 0) { return false; } const parentNode = $getBlockParentIfFirstNode(selection.focus.getNode()); if (!parentNode) { return false; } const siblingNode = parentNode.getPreviousSibling(); if (!$isTableNode(siblingNode)) { return false; } stopEvent(event); if (event.shiftKey) { selection.focus.set(siblingNode.getParentOrThrow().getKey(), siblingNode.getIndexWithinParent(), 'element'); } else { siblingNode.selectEnd(); } return true; } else if (event.shiftKey && (direction === 'up' || direction === 'down')) { const focusNode = selection.focus.getNode(); const isTableUnselect = !selection.isCollapsed() && (direction === 'up' && !selection.isBackward() || direction === 'down' && selection.isBackward()); if (isTableUnselect) { let focusParentNode = utils.$findMatchingParent(focusNode, n => $isTableNode(n)); if ($isTableCellNode(focusParentNode)) { focusParentNode = utils.$findMatchingParent(focusParentNode, $isTableNode); } if (focusParentNode !== tableNode) { return false; } if (!focusParentNode) { return false; } const sibling = direction === 'down' ? focusParentNode.getNextSibling() : focusParentNode.getPreviousSibling(); if (!sibling) { return false; } let newOffset = 0; if (direction === 'up') { if (lexical.$isElementNode(sibling)) { newOffset = sibling.getChildrenSize(); } } let newFocusNode = sibling; if (direction === 'up') { if (lexical.$isElementNode(sibling)) { const lastCell = sibling.getLastChild(); newFocusNode = lastCell ? lastCell : sibling; newOffset = lexical.$isTextNode(newFocusNode) ? newFocusNode.getTextContentSize() : 0; } } const newSelection = selection.clone(); newSelection.focus.set(newFocusNode.getKey(), newOffset, lexical.$isTextNode(newFocusNode) ? 'text' : 'element'); lexical.$setSelection(newSelection); stopEvent(event); return true; } else if (lexical.$isRootOrShadowRoot(focusNode)) { const selectedNode = direction === 'up' ? selection.getNodes()[selection.getNodes().length - 1] : selection.getNodes()[0]; if (selectedNode) { const tableCellNode = $findParentTableCellNodeInTable(tableNode, selectedNode); if (tableCellNode !== null) { const firstDescendant = tableNode.getFirstDescendant(); const lastDescendant = tableNode.getLastDescendant(); if (!firstDescendant || !lastDescendant) { return false; } const [firstCellNode] = $getNodeTriplet(firstDescendant); const [lastCellNode] = $getNodeTriplet(lastDescendant); const firstCellCoords = tableNode.getCordsFromCellNode(firstCellNode, tableObserver.table); const lastCellCoords = tableNode.getCordsFromCellNode(lastCellNode, tableObserver.table); const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow(firstCellCoords.x, firstCellCoords.y, tableObserver.table); const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow(lastCellCoords.x, lastCellCoords.y, tableObserver.table); tableObserver.$setAnchorCellForSelection(firstCellDOM); tableObserver.$setFocusCellForSelection(lastCellDOM, true); return true; } } return false; } else { let focusParentNode = utils.$findMatchingParent(focusNode, n => lexical.$isElementNode(n) && !n.isInline()); if ($isTableCellNode(focusParentNode)) { focusParentNode = utils.$findMatchingParent(focusParentNode, $isTableNode); } if (!focusParentNode) { return false; } const sibling = direction === 'down' ? focusParentNode.getNextSibling() : focusParentNode.getPreviousSibling(); if ($isTableNode(sibling) && tableObserver.tableNodeKey === sibling.getKey()) { const firstDescendant = sibling.getFirstDescendant(); const lastDescendant = sibling.getLastDescendant(); if (!firstDescendant || !lastDescendant) { return false; } const [firstCellNode] = $getNodeTriplet(firstDescendant); const [lastCellNode] = $getNodeTriplet(lastDescendant); const newSelection = selection.clone(); newSelection.focus.set((direction === 'up' ? firstCellNode : lastCellNode).getKey(), direction === 'up' ? 0 : lastCellNode.getChildrenSize(), 'element'); stopEvent(event); lexical.$setSelection(newSelection); return true; } } } } if (direction === 'down' && $isScrollableTablesActive(editor)) { // Enable Firefox workaround tableObserver.setShouldCheckSelection(); } return false; } if (lexical.$isRangeSelection(selection)) { if (direction === 'backward' || direction === 'forward') { const alter = event.shiftKey ? 'extend' : 'move'; return $handleHorizontalArrowKeyRangeSelection(editor, event, selection, alter, direction === 'backward', tableNode, tableObserver); } if (selection.isCollapsed()) { const { anchor, focus } = selection; const anchorCellNode = utils.$findMatchingParent(anchor.getNode(), $isTableCellNode); const focusCellNode = utils.$findMatchingParent(focus.getNode(), $isTableCellNode); if (!$isTableCellNode(anchorCellNode) || !anchorCellNode.is(focusCellNode)) { return false; } const anchorCellTable = $findTableNode(anchorCellNode); if (anchorCellTable !== tableNode && anchorCellTable != null) { const anchorCellTableElement = getTableElement(anchorCellTable, editor.getElementByKey(anchorCellTable.getKey())); if (anchorCellTableElement != null) { tableObserver.table = getTable(anchorCellTable, anchorCellTableElement); return $handleArrowKey(editor, event, direction, anchorCellTable, tableObserver); } } const anchorCellDom = editor.getElementByKey(anchorCellNode.__key); const anchorDOM = editor.getElementByKey(anchor.key); if (anchorDOM == null || anchorCellDom == null) { return false; } let edgeSelectionRect; if (anchor.type === 'element') { edgeSelectionRect = anchorDOM.getBoundingClientRect(); } else { const domSelection = lexical.getDOMSelection(getEditorWindow(editor)); if (domSelection === null || domSelection.rangeCount === 0) { return false; } const range = domSelection.getRangeAt(0); edgeSelectionRect = range.getBoundingClientRect(); } const edgeChild = direction === 'up' ? anchorCellNode.getFirstChild() : anchorCellNode.getLastChild(); if (edgeChild == null) { return false; } const edgeChildDOM = editor.getElementByKey(edgeChild.__key); if (edgeChildDOM == null) { return false; } const edgeRect = edgeChildDOM.getBoundingClientRect(); const isExiting = direction === 'up' ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom; if (isExiting) { stopEvent(event); const cords = tableNode.getCordsFromCellNode(anchorCellNode, tableObserver.table); if (event.shiftKey) { const cell = tableNode.getDOMCellFromCordsOrThrow(cords.x, cords.y, tableObserver.table); tableObserver.$setAnchorCellForSelection(cell); tableObserver.$setFocusCellForSelection(cell, true); } else { return selectTableNodeInDirection(tableObserver, tableNode, cords.x, cords.y, direction); } return true; } } } else if ($isTableSelection(selection)) { const { anchor, focus } = selection; const anchorCellNode = utils.$findMatchingParent(anchor.getNode(), $isTableCellNode); const focusCellNode = utils.$findMatchingParent(focus.getNode(), $isTableCellNode); const [tableNodeFromSelection] = selection.getNodes(); if (!$isTableNode(tableNodeFromSelection)) { formatDevErrorMessage(`$handleArrowKey: TableSelection.getNodes()[0] expected to be TableNode`); } const tableElement = getTableElement(tableNodeFromSelection, editor.getElementByKey(tableNodeFromSelection.getKey())); if (!$isTableCellNode(anchorCellNode) || !$isTableCellNode(focusCellNode) || !$isTableNode(tableNodeFromSelection) || tableElement == null) { return false; } tableObserver.$updateTableTableSelection(selection); const grid = getTable(tableNodeFromSelection, tableElement); const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid); const anchorCell = tableNode.getDOMCellFromCordsOrThrow(cordsAnchor.x, cordsAnchor.y, grid); tableObserver.$setAnchorCellForSelection(anchorCell); stopEvent(event); if (event.shiftKey) { const [tableMap, anchorValue, focusValue] = $computeTableMap(tableNode, anchorCellNode, focusCellNode); return $adjustFocusInDirection(tableObserver, tableMap, anchorValue, focusValue, direction); } else { focusCellNode.selectEnd(); } return true; } return false; } function stopEvent(event) { event.preventDefault(); event.stopImmediatePropagation(); event.stopPropagation(); } function isTypeaheadMenuInView(editor) { // There is no inbuilt way to check if the component picker is in view // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu". const root = editor.getRootElement(); if (!root) { return false; } return root.hasAttribute('aria-controls') && root.getAttribute('aria-controls') === 'typeahead-menu'; } function $insertParagraphAtTableEdge(edgePosition, tableNode, children) { const paragraphNode = lexical.$createParagraphNode(); if (edgePosition === 'first') { tableNode.insertBefore(paragraphNode); } else { tableNode.insertAfter(paragraphNode); } paragraphNode.append(...(children || [])); paragraphNode.selectEnd(); } function $getTableEdgeCursorPosition(editor, selection, tableNode) { const tableNodeParent = tableNode.getParent(); if (!tableNodeParent) { return undefined; } // TODO: Add support for nested tables const domSelection = lexical.getDOMSelection(getEditorWindow(editor)); if (!domSelection) { return undefined; } const domAnchorNode = domSelection.anchorNode; const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey()); const tableElement = getTableElement(tableNode, editor.getElementByKey(tableNode.getKey())); // We are only interested in the scenario where the // native selection anchor is: // - at or inside the table's parent DOM // - and NOT at or inside the table DOM // It may be adjacent to the table DOM (e.g. in a wrapper) if (!domAnchorNode || !tableNodeParentDOM || !tableElement || !tableNodeParentDOM.contains(domAnchorNode) || tableElement.contains(domAnchorNode)) { return undefined; } const anchorCellNode = utils.$findMatchingParent(selection.anchor.getNode(), n => $isTableCellNode(n)); if (!anchorCellNode) { return undefined; } const parentTable = utils.$findMatchingParent(anchorCellNode, n => $isTableNode(n)); if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) { return undefined; } const [tableMap, cellValue] = $computeTableMap(tableNode, anchorCellNode, anchorCellNode); const firstCell = tableMap[0][0]; const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1]; const { startRow, startColumn } = cellValue; const isAtFirstCell = startRow === firstCell.startRow && startColumn === firstCell.startColumn; const isAtLastCell = startRow === lastCell.startRow && startColumn === lastCell.startColumn; if (isAtFirstCell) { return 'first'; } else if (isAtLastCell) { return 'last'; } else { return undefined; } } function $getObserverCellFromCellNodeOrThrow(tableObserver, tableCellNode) { const { tableNode } = tableObserver.$lookup(); const currentCords = tableNode.getCordsFromCellNode(tableCellNode, tableObserver.table); return tableNode.getDOMCellFromCordsOrThrow(currentCords.x, currentCords.y, tableObserver.table); } function $getNearestTableCellInTableFromDOMNode(tableNode, startingDOM, editorState) { return $findParentTableCellNodeInTable(tableNode, lexical.$getNearestNodeFromDOMNode(startingDOM, editorState)); } function isHTMLDivElement(element) { return utils.isHTMLElement(element) && element.nodeName === 'DIV'; } function updateColgroup(dom, config, colCount, colWidths) { const colGroup = dom.querySelector('colgroup'); if (!colGroup) { return; } const cols = []; for (let i = 0; i < colCount; i++) { const col = document.createElement('col'); const width = colWidths && colWidths[i]; if (width) { col.style.width = `${width}px`; } cols.push(col); } colGroup.replaceChildren(...cols); } function setRowStriping(dom, config, rowStriping) { if (rowStriping) { utils.addClassNamesToElement(dom, config.theme.tableRowStriping); dom.setAttribute('data-lexical-row-striping', 'true'); } else { utils.removeClassNamesFromElement(dom, config.theme.tableRowStriping); dom.removeAttribute('data-lexical-row-striping'); } } function setFrozenColumns(dom, tableElement, config, frozenColumnCount) { if (frozenColumnCount > 0) { utils.addClassNamesToElement(dom, config.theme.tableFrozenColumn); tableElement.setAttribute('data-lexical-frozen-column', 'true'); } else { utils.removeClassNamesFromElement(dom, config.theme.tableFrozenColumn); tableElement.removeAttribute('data-lexical-frozen-column'); } } function setFrozenRows(dom, tableElement, config, frozenRowCount) { if (frozenRowCount > 0) { utils.addClassNamesToElement(dom, config.theme.tableFrozenRow); tableElement.setAttribute('data-lexical-frozen-row', 'true'); } else { utils.removeClassNamesFromElement(dom, config.theme.tableFrozenRow); tableElement.removeAttribute('data-lexical-frozen-row'); } } function alignTableElement(dom, config, formatType) { if (!config.theme.tableAlignment) { return; } const removeClasses = []; const addClasses = []; for (const format of ['center', 'right']) { const classes = config.theme.tableAlignment[format]; if (!classes) { continue; } (format === formatType ? addClasses : removeClasses).push(classes); } utils.removeClassNamesFromElement(dom, ...removeClasses); utils.addClassNamesToElement(dom, ...addClasses); } const scrollableEditors = new WeakSet(); function $isScrollableTablesActive(editor = lexical.$getEditor()) { return scrollableEditors.has(editor); } function setScrollableTablesActive(editor, active) { if (active) { if (!editor._config.theme.tableScrollableWrapper) { console.warn('TableNode: hasHorizontalScroll is active but theme.tableScrollableWrapper is not defined.'); } scrollableEditors.add(editor); } else { scrollableEditors.delete(editor); } } /** @noInheritDoc */ class TableNode extends lexical.ElementNode { /** @internal */ static getType() { return 'table'; } getColWidths() { const self = this.getLatest(); return self.__colWidths; } setColWidths(colWidths) { const self = this.getWritable(); // NOTE: Node properties should be immutable. Freeze to prevent accidental mutation. self.__colWidths = colWidths !== undefined && true ? Object.freeze(colWidths) : colWidths; return self; } static clone(node) { return new TableNode(node.__key); } afterCloneFrom(prevNode) { super.afterCloneFrom(prevNode); this.__colWidths = prevNode.__colWidths; this.__rowStriping = prevNode.__rowStriping; this.__frozenColumnCount = prevNode.__frozenColumnCount; this.__frozenRowCount = prevNode.__frozenRowCount; } static importDOM() { return { table: _node => ({ conversion: $convertTableElement, priority: 1 }) }; } static importJSON(serializedNode) { return $createTableNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode) { return super.updateFromJSON(serializedNode).setRowStriping(serializedNode.rowStriping || false).setFrozenColumns(serializedNode.frozenColumnCount || 0).setFrozenRows(serializedNode.frozenRowCount || 0).setColWidths(serializedNode.colWidths); } constructor(key) { super(key); this.__rowStriping = false; this.__frozenColumnCount = 0; this.__frozenRowCount = 0; } exportJSON() { return { ...super.exportJSON(), colWidths: this.getColWidths(), frozenColumnCount: this.__frozenColumnCount ? this.__frozenColumnCount : undefined, frozenRowCount: this.__frozenRowCount ? this.__frozenRowCount : undefined, rowStriping: this.__rowStriping ? this.__rowStriping : undefined }; } extractWithChild(child, selection, destination) { return destination === 'html'; } getDOMSlot(element) { const tableElement = !isHTMLTableElement(element) ? element.querySelector('table') : element; if (!isHTMLTableElement(tableElement)) { formatDevErrorMessage(`TableNode.getDOMSlot: createDOM() did not return a table`); } return super.getDOMSlot(element).withElement(tableElement).withAfter(tableElement.querySelector('colgroup')); } createDOM(config, editor) { const tableElement = document.createElement('table'); if (this.__style) { tableElement.style.cssText = this.__style; } const colGroup = document.createElement('colgroup'); tableElement.appendChild(colGroup); lexical.setDOMUnmanaged(colGroup); utils.addClassNamesToElement(tableElement, config.theme.table); this.updateTableElement(null, tableElement, config); if ($isScrollableTablesActive(editor)) { const wrapperElement = document.createElement('div'); const classes = config.theme.tableScrollableWrapper; if (classes) { utils.addClassNamesToElement(wrapperElement, classes); } else { wrapperElement.style.cssText = 'overflow-x: auto;'; } wrapperElement.appendChild(tableElement); this.updateTableWrapper(null, wrapperElement, tableElement, config); return wrapperElement; } return tableElement; } updateTableWrapper(prevNode, tableWrapper, tableElement, config) { if (this.__frozenColumnCount !== (prevNode ? prevNode.__frozenColumnCount : 0)) { setFrozenColumns(tableWrapper, tableElement, config, this.__frozenColumnCount); } if (this.__frozenRowCount !== (prevNode ? prevNode.__frozenRowCount : 0)) { setFrozenRows(tableWrapper, tableElement, config, this.__frozenRowCount); } } updateTableElement(prevNode, tableElement, config) { if (this.__style !== (prevNode ? prevNode.__style : '')) { tableElement.style.cssText = this.__style; } if (this.__rowStriping !== (prevNode ? prevNode.__rowStriping : false)) { setRowStriping(tableElement, config, this.__rowStriping); } updateColgroup(tableElement, config, this.getColumnCount(), this.getColWidths()); alignTableElement(tableElement, config, this.getFormatType()); } updateDOM(prevNode, dom, config) { const slot = this.getDOMSlot(dom); const tableElement = slot.element; if (dom === tableElement === $isScrollableTablesActive()) { return true; } if (isHTMLDivElement(dom)) { this.updateTableWrapper(prevNode, dom, tableElement, config); } this.updateTableElement(prevNode, tableElement, config); return false; } exportDOM(editor) { const superExport = super.exportDOM(editor); const { element } = superExport; return { after: tableElement => { if (superExport.after) { tableElement = superExport.after(tableElement); } if (!isHTMLTableElement(tableElement) && utils.isHTMLElement(tableElement)) { tableElement = tableElement.querySelector('table'); } if (!isHTMLTableElement(tableElement)) { return null; } alignTableElement(tableElement, editor._config, this.getFormatType()); // Scan the table map to build a map of table cell key to the columns it needs const [tableMap] = $computeTableMapSkipCellCheck(this, null, null); const cellValues = new Map(); for (const mapRow of tableMap) { for (const mapValue of mapRow) { const key = mapValue.cell.getKey(); if (!cellValues.has(key)) { cellValues.set(key, { colSpan: mapValue.cell.getColSpan(), startColumn: mapValue.startColumn }); } } } // scan the DOM to find the table cell keys that were used and mark those columns const knownColumns = new Set(); for (const cellDOM of tableElement.querySelectorAll(':scope > tr > [data-temporary-table-cell-lexical-key]')) { const key = cellDOM.getAttribute('data-temporary-table-cell-lexical-key'); if (key) { const cellSpan = cellValues.get(key); cellDOM.removeAttribute('data-temporary-table-cell-lexical-key'); if (cellSpan) { cellValues.delete(key); for (let i = 0; i < cellSpan.colSpan; i++) { knownColumns.add(i + cellSpan.startColumn); } } } } // Compute the colgroup and columns in the export const colGroup = tableElement.querySelector(':scope > colgroup'); if (colGroup) { // Only include the for rows that are in the output const cols = Array.from(tableElement.querySelectorAll(':scope > colgroup > col')).filter((dom, i) => knownColumns.has(i)); colGroup.replaceChildren(...cols); } // Wrap direct descendant rows in a tbody for export const rows = tableElement.querySelectorAll(':scope > tr'); if (rows.length > 0) { const tBody = document.createElement('tbody'); for (const row of rows) { tBody.appendChild(row); } tableElement.append(tBody); } return tableElement; }, element: !isHTMLTableElement(element) && utils.isHTMLElement(element) ? element.querySelector('table') : element }; } canBeEmpty() { return false; } isShadowRoot() { return true; } getCordsFromCellNode(tableCellNode, table) { const { rows, domRows } = table; for (let y = 0; y < rows; y++) { const row = domRows[y]; if (row == null) { continue; } for (let x = 0; x < row.length; x++) { const cell = row[x]; if (cell == null) { continue; } const { elem } = cell; const cellNode = $getNearestTableCellInTableFromDOMNode(this, elem); if (cellNode !== null && tableCellNode.is(cellNode)) { return { x, y }; } } } throw new Error('Cell not found in table.'); } getDOMCellFromCords(x, y, table) { const { domRows } = table; const row = domRows[y]; if (row == null) { return null; } const index = x < row.length ? x : row.length - 1; const cell = row[index]; if (cell == null) { return null; } return cell; } getDOMCellFromCordsOrThrow(x, y, table) { const cell = this.getDOMCellFromCords(x, y, table); if (!cell) { throw new Error('Cell not found at cords.'); } return cell; } getCellNodeFromCords(x, y, table) { const cell = this.getDOMCellFromCords(x, y, table); if (cell == null) { return null; } const node = lexical.$getNearestNodeFromDOMNode(cell.elem); if ($isTableCellNode(node)) { return node; } return null; } getCellNodeFromCordsOrThrow(x, y, table) { const node = this.getCellNodeFromCords(x, y, table); if (!node) { throw new Error('Node at cords not TableCellNode.'); } return node; } getRowStriping() { return Boolean(this.getLatest().__rowStriping); } setRowStriping(newRowStriping) { const self = this.getWritable(); self.__rowStriping = newRowStriping; return self; } setFrozenColumns(columnCount) { const self = this.getWritable(); self.__frozenColumnCount = columnCount; return self; } getFrozenColumns() { return this.getLatest().__frozenColumnCount; } setFrozenRows(rowCount) { const self = this.getWritable(); self.__frozenRowCount = rowCount; return self; } getFrozenRows() { return this.getLatest().__frozenRowCount; } canSelectBefore() { return true; } canIndent() { return false; } getColumnCount() { const firstRow = this.getFirstChild(); if (!firstRow) { return 0; } let columnCount = 0; firstRow.getChildren().forEach(cell => { if ($isTableCellNode(cell)) { columnCount += cell.getColSpan(); } }); return columnCount; } } function $getElementForTableNode(editor, tableNode) { const tableElement = editor.getElementByKey(tableNode.getKey()); if (!(tableElement !== null)) { formatDevErrorMessage(`$getElementForTableNode: Table Element Not Found`); } return getTable(tableNode, tableElement); } function $convertTableElement(domNode) { const tableNode = $createTableNode(); if (domNode.hasAttribute('data-lexical-row-striping')) { tableNode.setRowStriping(true); } if (domNode.hasAttribute('data-lexical-frozen-column')) { tableNode.setFrozenColumns(1); } if (domNode.hasAttribute('data-lexical-frozen-row')) { tableNode.setFrozenRows(1); } const colGroup = domNode.querySelector(':scope > colgroup'); if (colGroup) { let columns = []; for (const col of colGroup.querySelectorAll(':scope > col')) { let width = col.style.width || ''; if (!PIXEL_VALUE_REG_EXP.test(width)) { // Also support deprecated width attribute for google docs width = col.getAttribute('width') || ''; if (!/^\d+$/.test(width)) { columns = undefined; break; } } columns.push(parseFloat(width)); } if (columns) { tableNode.setColWidths(columns); } } return { after: children => utils.$descendantsMatching(children, $isTableRowNode), node: tableNode }; } function $createTableNode() { return lexical.$applyNodeReplacement(new TableNode()); } function $isTableNode(node) { return node instanceof TableNode; } function $insertTableCommandListener({ rows, columns, includeHeaders }) { const selection = lexical.$getSelection() || lexical.$getPreviousSelection(); if (!selection || !lexical.$isRangeSelection(selection)) { return false; } // Prevent nested tables by checking if we're already inside a table if ($findTableNode(selection.anchor.getNode())) { return false; } const tableNode = $createTableNodeWithDimensions(Number(rows), Number(columns), includeHeaders); utils.$insertNodeToNearestRoot(tableNode); const firstDescendant = tableNode.getFirstDescendant(); if (lexical.$isTextNode(firstDescendant)) { firstDescendant.select(); } return true; } function $tableCellTransform(node) { if (!$isTableRowNode(node.getParent())) { // TableCellNode must be a child of TableRowNode. node.remove(); } else if (node.isEmpty()) { // TableCellNode should never be empty node.append(lexical.$createParagraphNode()); } } function $tableRowTransform(node) { if (!$isTableNode(node.getParent())) { // TableRowNode must be a child of TableNode. // TODO: Future support of tbody/thead/tfoot may change this node.remove(); } else { utils.$unwrapAndFilterDescendants(node, $isTableCellNode); } } function $tableTransform(node) { // TableRowNode is the only valid child for TableNode // TODO: Future support of tbody/thead/tfoot/caption may change this utils.$unwrapAndFilterDescendants(node, $isTableRowNode); const [gridMap] = $computeTableMapSkipCellCheck(node, null, null); const maxRowLength = gridMap.reduce((curLength, row) => { return Math.max(curLength, row.length); }, 0); const rowNodes = node.getChildren(); for (let i = 0; i < gridMap.length; ++i) { const rowNode = rowNodes[i]; if (!rowNode) { continue; } if (!$isTableRowNode(rowNode)) { formatDevErrorMessage(`TablePlugin: Expecting all children of TableNode to be TableRowNode, found ${rowNode.constructor.name} (type ${rowNode.getType()})`); } const rowLength = gridMap[i].reduce((acc, cell) => cell ? 1 + acc : acc, 0); if (rowLength === maxRowLength) { continue; } for (let j = rowLength; j < maxRowLength; ++j) { // TODO: inherit header state from another header or body const newCell = $createTableCellNode(); newCell.append(lexical.$createParagraphNode()); rowNode.append(newCell); } } } function $tableClickCommand(event) { if (event.detail < 3 || !lexical.isDOMNode(event.target)) { return false; } const startNode = lexical.$getNearestNodeFromDOMNode(event.target); if (startNode === null) { return false; } const blockNode = utils.$findMatchingParent(startNode, node => lexical.$isElementNode(node) && !node.isInline()); if (blockNode === null) { return false; } const rootNode = blockNode.getParent(); if (!$isTableCellNode(rootNode)) { return false; } blockNode.select(0); return true; } /** * Register a transform to ensure that all TableCellNode have a colSpan and rowSpan of 1. * This should only be registered when you do not want to support merged cells. * * @param editor The editor * @returns An unregister callback */ function registerTableCellUnmergeTransform(editor) { return editor.registerNodeTransform(TableCellNode, node => { if (node.getColSpan() > 1 || node.getRowSpan() > 1) { // When we have rowSpan we have to map the entire Table to understand where the new Cells // fit best; let's analyze all Cells at once to save us from further transform iterations const [,, gridNode] = $getNodeTriplet(node); const [gridMap] = $computeTableMap(gridNode, node, node); // TODO this function expects Tables to be normalized. Look into this once it exists const rowsCount = gridMap.length; const columnsCount = gridMap[0].length; let row = gridNode.getFirstChild(); if (!$isTableRowNode(row)) { formatDevErrorMessage(`Expected TableNode first child to be a RowNode`); } const unmerged = []; for (let i = 0; i < rowsCount; i++) { if (i !== 0) { row = row.getNextSibling(); if (!$isTableRowNode(row)) { formatDevErrorMessage(`Expected TableNode first child to be a RowNode`); } } let lastRowCell = null; for (let j = 0; j < columnsCount; j++) { const cellMap = gridMap[i][j]; const cell = cellMap.cell; if (cellMap.startRow === i && cellMap.startColumn === j) { lastRowCell = cell; unmerged.push(cell); } else if (cell.getColSpan() > 1 || cell.getRowSpan() > 1) { if (!$isTableCellNode(cell)) { formatDevErrorMessage(`Expected TableNode cell to be a TableCellNode`); } const newCell = $createTableCellNode(cell.__headerState); if (lastRowCell !== null) { lastRowCell.insertAfter(newCell); } else { utils.$insertFirst(row, newCell); } } } } for (const cell of unmerged) { cell.setColSpan(1); cell.setRowSpan(1); } } }); } function registerTableSelectionObserver(editor, hasTabHandler = true) { const tableSelections = new Map(); const initializeTableNode = (tableNode, nodeKey, dom) => { const tableElement = getTableElement(tableNode, dom); const tableSelection = applyTableHandlers(tableNode, tableElement, editor, hasTabHandler); tableSelections.set(nodeKey, [tableSelection, tableElement]); }; const unregisterMutationListener = editor.registerMutationListener(TableNode, nodeMutations => { editor.getEditorState().read(() => { for (const [nodeKey, mutation] of nodeMutations) { const tableSelection = tableSelections.get(nodeKey); if (mutation === 'created' || mutation === 'updated') { const { tableNode, tableElement } = $getTableAndElementByKey(nodeKey); if (tableSelection === undefined) { initializeTableNode(tableNode, nodeKey, tableElement); } else if (tableElement !== tableSelection[1]) { // The update created a new DOM node, destroy the existing TableObserver tableSelection[0].removeListeners(); tableSelections.delete(nodeKey); initializeTableNode(tableNode, nodeKey, tableElement); } } else if (mutation === 'destroyed') { if (tableSelection !== undefined) { tableSelection[0].removeListeners(); tableSelections.delete(nodeKey); } } } }, { editor }); }, { skipInitialization: false }); return () => { unregisterMutationListener(); // Hook might be called multiple times so cleaning up tables listeners as well, // as it'll be reinitialized during recurring call for (const [, [tableSelection]] of tableSelections) { tableSelection.removeListeners(); } }; } /** * Register the INSERT_TABLE_COMMAND listener and the table integrity transforms. The * table selection observer should be registered separately after this with * {@link registerTableSelectionObserver}. * * @param editor The editor * @returns An unregister callback */ function registerTablePlugin(editor) { if (!editor.hasNodes([TableNode])) { { formatDevErrorMessage(`TablePlugin: TableNode is not registered on editor`); } } return utils.mergeRegister(editor.registerCommand(INSERT_TABLE_COMMAND, $insertTableCommandListener, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ({ nodes, selection }, dispatchEditor) => { if (editor !== dispatchEditor || !lexical.$isRangeSelection(selection)) { return false; } const isInsideTableCell = $findTableNode(selection.anchor.getNode()) !== null; return isInsideTableCell && nodes.some($isTableNode); }, lexical.COMMAND_PRIORITY_EDITOR), editor.registerCommand(lexical.CLICK_COMMAND, $tableClickCommand, lexical.COMMAND_PRIORITY_EDITOR), editor.registerNodeTransform(TableNode, $tableTransform), editor.registerNodeTransform(TableRowNode, $tableRowTransform), editor.registerNodeTransform(TableCellNode, $tableCellTransform)); } exports.$computeTableMap = $computeTableMap; exports.$computeTableMapSkipCellCheck = $computeTableMapSkipCellCheck; exports.$createTableCellNode = $createTableCellNode; exports.$createTableNode = $createTableNode; exports.$createTableNodeWithDimensions = $createTableNodeWithDimensions; exports.$createTableRowNode = $createTableRowNode; exports.$createTableSelection = $createTableSelection; exports.$createTableSelectionFrom = $createTableSelectionFrom; exports.$deleteTableColumn = $deleteTableColumn; exports.$deleteTableColumnAtSelection = $deleteTableColumnAtSelection; exports.$deleteTableColumn__EXPERIMENTAL = $deleteTableColumn__EXPERIMENTAL; exports.$deleteTableRowAtSelection = $deleteTableRowAtSelection; exports.$deleteTableRow__EXPERIMENTAL = $deleteTableRow__EXPERIMENTAL; exports.$findCellNode = $findCellNode; exports.$findTableNode = $findTableNode; exports.$getElementForTableNode = $getElementForTableNode; exports.$getNodeTriplet = $getNodeTriplet; exports.$getTableAndElementByKey = $getTableAndElementByKey; exports.$getTableCellNodeFromLexicalNode = $getTableCellNodeFromLexicalNode; exports.$getTableCellNodeRect = $getTableCellNodeRect; exports.$getTableColumnIndexFromTableCellNode = $getTableColumnIndexFromTableCellNode; exports.$getTableNodeFromLexicalNodeOrThrow = $getTableNodeFromLexicalNodeOrThrow; exports.$getTableRowIndexFromTableCellNode = $getTableRowIndexFromTableCellNode; exports.$getTableRowNodeFromTableCellNodeOrThrow = $getTableRowNodeFromTableCellNodeOrThrow; exports.$insertTableColumn = $insertTableColumn; exports.$insertTableColumnAtSelection = $insertTableColumnAtSelection; exports.$insertTableColumn__EXPERIMENTAL = $insertTableColumn__EXPERIMENTAL; exports.$insertTableRow = $insertTableRow; exports.$insertTableRowAtSelection = $insertTableRowAtSelection; exports.$insertTableRow__EXPERIMENTAL = $insertTableRow__EXPERIMENTAL; exports.$isScrollableTablesActive = $isScrollableTablesActive; exports.$isTableCellNode = $isTableCellNode; exports.$isTableNode = $isTableNode; exports.$isTableRowNode = $isTableRowNode; exports.$isTableSelection = $isTableSelection; exports.$mergeCells = $mergeCells; exports.$removeTableRowAtIndex = $removeTableRowAtIndex; exports.$unmergeCell = $unmergeCell; exports.INSERT_TABLE_COMMAND = INSERT_TABLE_COMMAND; exports.TableCellHeaderStates = TableCellHeaderStates; exports.TableCellNode = TableCellNode; exports.TableNode = TableNode; exports.TableObserver = TableObserver; exports.TableRowNode = TableRowNode; exports.applyTableHandlers = applyTableHandlers; exports.getDOMCellFromTarget = getDOMCellFromTarget; exports.getTableElement = getTableElement; exports.getTableObserverFromTableElement = getTableObserverFromTableElement; exports.registerTableCellUnmergeTransform = registerTableCellUnmergeTransform; exports.registerTablePlugin = registerTablePlugin; exports.registerTableSelectionObserver = registerTableSelectionObserver; exports.setScrollableTablesActive = setScrollableTablesActive;