@lexical/table
Version:
This package provides the Table feature for Lexical.
519 lines (481 loc) • 15.6 kB
text/typescript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {TableConfig} from './LexicalTableExtension';
import {type NamedSignalsOutput, type Signal, signal} from '@lexical/extension';
import invariant from '@lexical/internal/invariant';
import {
$dfs,
$insertFirst,
$insertNodeToNearestRoot,
$unwrapAndFilterDescendants,
} from '@lexical/utils';
import {
$createParagraphNode,
$findMatchingParent,
$getNearestNodeFromDOMNode,
$getPreviousSelection,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
$isTextNode,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
type CommandPayloadType,
type ElementNode,
isDOMNode,
type LexicalEditor,
mergeRegister,
type NodeKey,
type RangeSelection,
SELECT_ALL_COMMAND,
SELECTION_CHANGE_COMMAND,
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
} from 'lexical';
import {
$createTableCellNode,
$isTableCellNode,
TableCellNode,
} from './LexicalTableCellNode';
import {
INSERT_TABLE_COMMAND,
type InsertTableCommandPayload,
} from './LexicalTableCommands';
import {$isTableNode, TableNode} from './LexicalTableNode';
import {$getTableAndElementByKey, TableObservers} from './LexicalTableObserver';
import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode';
import {
$createTableSelectionFrom,
$isTableSelection,
type TableSelection,
} from './LexicalTableSelection';
import {
$findTableNode,
$handleTableSelectionChangeCommand,
applyTableHandlers,
getTableElement,
registerTableWindowHandlers,
} from './LexicalTableSelectionHelpers';
import {
$computeTableMap,
$computeTableMapSkipCellCheck,
$createTableNodeWithDimensions,
$getNodeTriplet,
$insertTableIntoGrid,
} from './LexicalTableUtils';
function $insertTable(
{rows, columns, includeHeaders}: InsertTableCommandPayload,
hasNestedTables: boolean,
): boolean {
const selection = $getSelection() || $getPreviousSelection();
if (!selection || !$isRangeSelection(selection)) {
return false;
}
// Prevent nested tables by checking if we're already inside a table
if (!hasNestedTables && $findTableNode(selection.anchor.getNode())) {
return false;
}
const tableNode = $createTableNodeWithDimensions(
Number(rows),
Number(columns),
includeHeaders,
);
$insertNodeToNearestRoot(tableNode);
const firstDescendant = tableNode.getFirstDescendant();
if ($isTextNode(firstDescendant)) {
firstDescendant.select();
}
return true;
}
function $tableCellTransform(node: TableCellNode) {
if (!$isTableRowNode(node.getParent())) {
// TableCellNode must be a child of TableRowNode.
node.remove();
} else if (node.isEmpty()) {
// TableCellNode should never be empty
node.append($createParagraphNode());
}
}
function $tableRowTransform(node: TableRowNode) {
if (!$isTableNode(node.getParent())) {
// TableRowNode must be a child of TableNode.
// TODO: Future support of tbody/thead/tfoot may change this
node.remove();
} else {
$unwrapAndFilterDescendants(node, $isTableCellNode);
}
}
function $tableTransform(node: TableNode) {
// TableRowNode is the only valid child for TableNode
// TODO: Future support of tbody/thead/tfoot/caption may change this
$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;
}
invariant(
$isTableRowNode(rowNode),
'TablePlugin: Expecting all children of TableNode to be TableRowNode, found %s (type %s)',
rowNode.constructor.name,
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($createParagraphNode());
rowNode.append(newCell);
}
}
const colWidths = node.getColWidths();
const columnCount = node.getColumnCount();
if (colWidths && colWidths.length !== columnCount) {
let newColWidths: number[] | undefined = undefined;
if (columnCount < colWidths.length) {
newColWidths = colWidths.slice(0, columnCount);
} else if (colWidths.length > 0) {
// Repeat the last column width.
const fillWidth = colWidths[colWidths.length - 1];
newColWidths = [
...colWidths,
...Array(columnCount - colWidths.length).fill(fillWidth),
];
}
node.setColWidths(newColWidths);
}
}
function $tableClickCommand(event: MouseEvent): boolean {
if (event.detail < 3 || !isDOMNode(event.target)) {
return false;
}
const startNode = $getNearestNodeFromDOMNode(event.target);
if (startNode === null) {
return false;
}
const blockNode = $findMatchingParent(
startNode,
(node): node is ElementNode => $isElementNode(node) && !node.isInline(),
);
if (blockNode === null) {
return false;
}
const rootNode = blockNode.getParent();
if (!$isTableCellNode(rootNode)) {
return false;
}
blockNode.select(0);
return true;
}
function $tableSelectAllCommand(): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// Check if the selection is inside a table
const anchorNode = selection.anchor.getNode();
const tableNode = $findTableNode(anchorNode);
if (tableNode === null) {
return false;
}
// CRITICAL: Only intercept if table is the ONLY child of root
// This is required to reproduce the bug: table must be the only content, no empty paragraphs
// This prevents breaking other tests that expect RangeSelection when there's content outside table
const root = $getRoot();
if (!root.is(tableNode.getParent()) || root.getChildrenSize() !== 1) {
return false;
}
// At this point, table is the only child
// This is the exact scenario from issue #8074: table is the only content in editor
// Get the table map to find first and last cells (handles merged cells correctly)
const [tableMap] = $computeTableMapSkipCellCheck(tableNode, null, null);
if (tableMap.length === 0 || tableMap[0].length === 0) {
return false;
}
// Get the first cell (top-left)
const firstCellMap = tableMap[0][0];
if (!firstCellMap || !firstCellMap.cell) {
return false;
}
// Get the last cell (bottom-right)
const lastRow = tableMap[tableMap.length - 1];
const lastCellMap = lastRow[lastRow.length - 1];
if (!lastCellMap || !lastCellMap.cell) {
return false;
}
// Create a TableSelection that selects all cells
const tableSelection = $createTableSelectionFrom(
tableNode,
firstCellMap.cell,
lastCellMap.cell,
);
$setSelection(tableSelection);
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
*/
export function registerTableCellUnmergeTransform(
editor: LexicalEditor,
): () => void {
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();
invariant(
$isTableRowNode(row),
'Expected TableNode first child to be a RowNode',
);
const unmerged = [];
for (let i = 0; i < rowsCount; i++) {
if (i !== 0) {
row = row.getNextSibling();
invariant(
$isTableRowNode(row),
'Expected TableNode first child to be a RowNode',
);
}
let lastRowCell: null | TableCellNode = 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) {
invariant(
$isTableCellNode(cell),
'Expected TableNode cell to be a TableCellNode',
);
const newCell = $createTableCellNode(cell.__headerState);
if (lastRowCell !== null) {
lastRowCell.insertAfter(newCell);
} else {
$insertFirst(row, newCell);
}
}
}
}
for (const cell of unmerged) {
cell.setColSpan(1);
cell.setRowSpan(1);
}
}
});
}
export function registerTableSelectionObserver(
editor: LexicalEditor,
hasTabHandler: boolean = true,
): () => void {
const tableObservers = new TableObservers();
const initializeTableNode = (
tableNode: TableNode,
nodeKey: NodeKey,
dom: HTMLElement,
) => {
const tableElement = getTableElement(tableNode, dom);
const tableSelection = applyTableHandlers(
tableNode,
tableElement,
editor,
hasTabHandler,
tableObservers,
);
tableObservers.observers.set(nodeKey, [tableSelection, tableElement]);
};
return mergeRegister(
registerTableWindowHandlers(editor, tableObservers),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
return $handleTableSelectionChangeCommand(tableObservers, editor);
},
COMMAND_PRIORITY_HIGH,
),
editor.registerMutationListener(
TableNode,
nodeMutations => {
editor.read('latest', () => {
for (const [nodeKey, mutation] of nodeMutations) {
const tableSelection = tableObservers.observers.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
tableObservers.removeObserver(nodeKey);
initializeTableNode(tableNode, nodeKey, tableElement);
}
} else if (mutation === 'destroyed') {
tableObservers.removeObserver(nodeKey);
}
}
});
},
{skipInitialization: false},
),
() => {
// Hook might be called multiple times so cleaning up tables listeners as well,
// as it'll be reinitialized during recurring call
tableObservers.removeAllObservers();
},
);
}
/**
* Register table command listeners 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
*/
export function registerTablePlugin(
editor: LexicalEditor,
options?: Pick<NamedSignalsOutput<TableConfig>, 'hasNestedTables'>,
): () => void {
if (!editor.hasNodes([TableNode])) {
invariant(false, 'TablePlugin: TableNode is not registered on editor');
}
const {hasNestedTables = signal(false)} = options ?? {};
return mergeRegister(
editor.registerCommand(
INSERT_TABLE_COMMAND,
payload => {
return $insertTable(payload, hasNestedTables.peek());
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
(payload, dispatchEditor) => {
if (editor !== dispatchEditor) {
return false;
}
return $tableSelectionInsertClipboardNodesCommand(
payload,
hasNestedTables,
);
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
SELECT_ALL_COMMAND,
$tableSelectAllCommand,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CLICK_COMMAND,
$tableClickCommand,
COMMAND_PRIORITY_EDITOR,
),
editor.registerNodeTransform(TableNode, $tableTransform),
editor.registerNodeTransform(TableRowNode, $tableRowTransform),
editor.registerNodeTransform(TableCellNode, $tableCellTransform),
);
}
function $tableSelectionInsertClipboardNodesCommand(
selectionPayload: CommandPayloadType<
typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND
>,
hasNestedTables: Signal<boolean>,
) {
const {nodes, selection} = selectionPayload;
const hasTables = nodes.some(
n => $isTableNode(n) || $dfs(n).some(d => $isTableNode(d.node)),
);
if (!hasTables) {
if ($isTableSelection(selection)) {
let text = '';
let lastWasBlock = false;
for (const node of nodes) {
const isBlock = $isElementNode(node) && !node.isInline();
if (text.length > 0 && (isBlock || lastWasBlock)) {
text += '\n';
}
text += node.getTextContent();
lastWasBlock = isBlock;
}
selection.insertRawText(text);
return true;
}
return false;
}
const isTableSelection = $isTableSelection(selection);
const isRangeSelection = $isRangeSelection(selection);
const isSelectionInsideOfGrid =
(isRangeSelection &&
$findMatchingParent(selection.anchor.getNode(), n =>
$isTableCellNode(n),
) !== null &&
$findMatchingParent(selection.focus.getNode(), n =>
$isTableCellNode(n),
) !== null) ||
isTableSelection;
if (!isSelectionInsideOfGrid) {
// Not pasting in a grid - no special handling required.
return false;
}
// When pasting just a table, flatten the table on the destination table, even when nested tables are allowed.
if (nodes.length === 1 && $isTableNode(nodes[0])) {
return $insertTableIntoGrid(nodes[0], selection);
}
// If nested tables are enabled, allow pasting a table into a single cell.
if (
isRangeSelection &&
hasNestedTables.peek() &&
!$isMultiCellTableSelection(selection)
) {
return false;
}
// If we reached this point, there's a table in the selection and nested tables are not allowed - reject the paste.
return true;
}
function $isMultiCellTableSelection(
selection: TableSelection | RangeSelection,
) {
if (
$isTableSelection(selection) &&
!selection.focus.getNode().is(selection.anchor.getNode())
) {
return true;
}
if (
$isRangeSelection(selection) &&
$isTableCellNode(selection.anchor.getNode()) &&
!selection.anchor.getNode().is(selection.focus.getNode())
) {
return true;
}
return false;
}