@terrible-lexical/react
Version:
This package provides Lexical components and hooks for React applications.
225 lines (207 loc) • 7.03 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 {
HTMLTableElementWithWithTableSelectionState,
InsertTableCommandPayload,
TableSelection,
} from '@terrible-lexical/table/src';
import type {
DEPRECATED_GridCellNode,
ElementNode,
LexicalNode,
NodeKey,
} from 'terrible-lexical';
import {useLexicalComposerContext} from '@terrible-lexical/react/src/LexicalComposerContext';
import {
$createTableCellNode,
$createTableNodeWithDimensions,
$isTableNode,
applyTableHandlers,
INSERT_TABLE_COMMAND,
TableCellNode,
TableNode,
TableRowNode,
} from '@terrible-lexical/table/src';
import {$insertNodeToNearestRoot} from '@terrible-lexical/utils/src';
import {
$getNodeByKey,
$isTextNode,
$nodesOfType,
COMMAND_PRIORITY_EDITOR,
DEPRECATED_$computeGridMap,
DEPRECATED_$getNodeTriplet,
DEPRECATED_$isGridRowNode,
} from 'terrible-lexical';
import {useEffect} from 'react';
import invariant from '@terrible-lexical/shared/src/invariant';
// TODO extract to utils
function $insertFirst(parent: ElementNode, node: LexicalNode): void {
const firstChild = parent.getFirstChild();
if (firstChild !== null) {
firstChild.insertBefore(node);
} else {
parent.append(node);
}
}
export function TablePlugin({
hasCellMerge = true,
hasCellBackgroundColor = true,
hasTabHandler = true,
}: {
hasCellMerge?: boolean;
hasCellBackgroundColor?: boolean;
hasTabHandler?: boolean;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([TableNode, TableCellNode, TableRowNode])) {
invariant(
false,
'TablePlugin: TableNode, TableCellNode or TableRowNode not registered on editor',
);
}
return editor.registerCommand<InsertTableCommandPayload>(
INSERT_TABLE_COMMAND,
({columns, rows, includeHeaders}) => {
const tableNode = $createTableNodeWithDimensions(
Number(rows),
Number(columns),
includeHeaders,
);
$insertNodeToNearestRoot(tableNode);
const firstDescendant = tableNode.getFirstDescendant();
if ($isTextNode(firstDescendant)) {
firstDescendant.select();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
);
}, [editor]);
useEffect(() => {
const tableSelections = new Map<NodeKey, TableSelection>();
const initializeTableNode = (tableNode: TableNode) => {
const nodeKey = tableNode.getKey();
const tableElement = editor.getElementByKey(
nodeKey,
) as HTMLTableElementWithWithTableSelectionState;
if (tableElement && !tableSelections.has(nodeKey)) {
const tableSelection = applyTableHandlers(
tableNode,
tableElement,
editor,
hasTabHandler,
);
tableSelections.set(nodeKey, tableSelection);
}
};
// Plugins might be loaded _after_ initial content is set, hence existing table nodes
// won't be initialized from mutation[create] listener. Instead doing it here,
editor.getEditorState().read(() => {
const tableNodes = $nodesOfType(TableNode);
for (const tableNode of tableNodes) {
if ($isTableNode(tableNode)) {
initializeTableNode(tableNode);
}
}
});
const unregisterMutationListener = editor.registerMutationListener(
TableNode,
(nodeMutations) => {
for (const [nodeKey, mutation] of nodeMutations) {
if (mutation === 'created') {
editor.getEditorState().read(() => {
const tableNode = $getNodeByKey<TableNode>(nodeKey);
if ($isTableNode(tableNode)) {
initializeTableNode(tableNode);
}
});
} else if (mutation === 'destroyed') {
const tableSelection = tableSelections.get(nodeKey);
if (tableSelection !== undefined) {
tableSelection.removeListeners();
tableSelections.delete(nodeKey);
}
}
}
},
);
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();
}
};
}, [editor, hasTabHandler]);
// Unmerge cells when the feature isn't enabled
useEffect(() => {
if (hasCellMerge) {
return;
}
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] = DEPRECATED_$getNodeTriplet(node);
const [gridMap] = DEPRECATED_$computeGridMap(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(
DEPRECATED_$isGridRowNode(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(
DEPRECATED_$isGridRowNode(row),
'Expected TableNode first child to be a RowNode',
);
}
let lastRowCell: null | DEPRECATED_GridCellNode = 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) {
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);
}
}
});
}, [editor, hasCellMerge]);
// Remove cell background color when feature is disabled
useEffect(() => {
if (hasCellBackgroundColor) {
return;
}
return editor.registerNodeTransform(TableCellNode, (node) => {
if (node.getBackgroundColor() !== null) {
node.setBackgroundColor(null);
}
});
}, [editor, hasCellBackgroundColor, hasCellMerge]);
return null;
}