UNPKG

@lexical/table

Version:

This package provides the Table feature for Lexical.

1,446 lines (1,391 loc) 159 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ '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 selectedRowCount = anchorCell.__rowSpan; 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 have to be trimmed if (row === anchorStartRow && cellStartRow < anchorStartRow) { const overflowTop = anchorStartRow - cellStartRow; cell.setRowSpan(cell.__rowSpan - Math.min(selectedRowCount, cell.__rowSpan - overflowTop)); } // Rows overflowing bottom have to be trimmed and moved to the next row if (cellStartRow >= anchorStartRow && cellStartRow + cell.__rowSpan - 1 > focusEndRow) { cell.setRowSpan(cell.__rowSpan - (focusEndRow - cellStartRow + 1)); 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 <tr> 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 +