resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts
/**
* 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 {$findMatchingParent} from '@lexical/utils';
import {
$createPoint,
$getNodeByKey,
$isElementNode,
$normalizeSelection__EXPERIMENTAL,
BaseSelection,
isCurrentlyReadOnlyMode,
LexicalNode,
NodeKey,
PointType,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode';
import {$isTableNode} from './LexicalTableNode';
import {$isTableRowNode} from './LexicalTableRowNode';
import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils';
export type TableSelectionShape = {
fromX: number;
fromY: number;
toX: number;
toY: number;
};
export type TableMapValueType = {
cell: TableCellNode;
startRow: number;
startColumn: number;
};
export type TableMapType = Array<Array<TableMapValueType>>;
export class TableSelection implements BaseSelection {
tableKey: NodeKey;
anchor: PointType;
focus: PointType;
_cachedNodes: Array<LexicalNode> | null;
dirty: boolean;
constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) {
this.anchor = anchor;
this.focus = focus;
anchor._selection = this;
focus._selection = this;
this._cachedNodes = null;
this.dirty = false;
this.tableKey = tableKey;
}
getStartEndPoints(): [PointType, PointType] {
return [this.anchor, this.focus];
}
/**
* 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(): boolean {
return this.focus.isBefore(this.anchor);
}
getCachedNodes(): LexicalNode[] | null {
return this._cachedNodes;
}
setCachedNodes(nodes: LexicalNode[] | null): void {
this._cachedNodes = nodes;
}
is(selection: null | BaseSelection): boolean {
if (!$isTableSelection(selection)) {
return false;
}
return (
this.tableKey === selection.tableKey &&
this.anchor.is(selection.anchor) &&
this.focus.is(selection.focus)
);
}
set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void {
this.dirty = true;
this.tableKey = tableKey;
this.anchor.key = anchorCellKey;
this.focus.key = focusCellKey;
this._cachedNodes = null;
}
clone(): TableSelection {
return new TableSelection(this.tableKey, this.anchor, this.focus);
}
isCollapsed(): boolean {
return false;
}
extract(): Array<LexicalNode> {
return this.getNodes();
}
insertRawText(text: string): void {
// Do nothing?
}
insertText(): void {
// Do nothing?
}
insertNodes(nodes: Array<LexicalNode>) {
const focusNode = this.focus.getNode();
invariant(
$isElementNode(focusNode),
'Expected TableSelection focus to be an ElementNode',
);
const selection = $normalizeSelection__EXPERIMENTAL(
focusNode.select(0, focusNode.getChildrenSize()),
);
selection.insertNodes(nodes);
}
// TODO Deprecate this method. It's confusing when used with colspan|rowspan
getShape(): TableSelectionShape {
const anchorCellNode = $getNodeByKey(this.anchor.key);
invariant(
$isTableCellNode(anchorCellNode),
'Expected TableSelection anchor to be (or a child of) TableCellNode',
);
const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode);
invariant(
anchorCellNodeRect !== null,
'getCellRect: expected to find AnchorNode',
);
const focusCellNode = $getNodeByKey(this.focus.key);
invariant(
$isTableCellNode(focusCellNode),
'Expected TableSelection focus to be (or a child of) TableCellNode',
);
const focusCellNodeRect = $getTableCellNodeRect(focusCellNode);
invariant(
focusCellNodeRect !== null,
'getCellRect: expected to find focusCellNode',
);
const startX = Math.min(
anchorCellNodeRect.columnIndex,
focusCellNodeRect.columnIndex,
);
const stopX = Math.max(
anchorCellNodeRect.columnIndex,
focusCellNodeRect.columnIndex,
);
const startY = Math.min(
anchorCellNodeRect.rowIndex,
focusCellNodeRect.rowIndex,
);
const stopY = Math.max(
anchorCellNodeRect.rowIndex,
focusCellNodeRect.rowIndex,
);
return {
fromX: Math.min(startX, stopX),
fromY: Math.min(startY, stopY),
toX: Math.max(startX, stopX),
toY: Math.max(startY, stopY),
};
}
getNodes(): Array<LexicalNode> {
const cachedNodes = this._cachedNodes;
if (cachedNodes !== null) {
return cachedNodes;
}
const anchorNode = this.anchor.getNode();
const focusNode = this.focus.getNode();
const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode);
// todo replace with triplet
const focusCell = $findMatchingParent(focusNode, $isTableCellNode);
invariant(
$isTableCellNode(anchorCell),
'Expected TableSelection anchor to be (or a child of) TableCellNode',
);
invariant(
$isTableCellNode(focusCell),
'Expected TableSelection focus to be (or a child of) TableCellNode',
);
const anchorRow = anchorCell.getParent();
invariant(
$isTableRowNode(anchorRow),
'Expected anchorCell to have a parent TableRowNode',
);
const tableNode = anchorRow.getParent();
invariant(
$isTableNode(tableNode),
'Expected tableNode to have a parent TableNode',
);
const focusCellGrid = focusCell.getParents()[1];
if (focusCellGrid !== tableNode) {
if (!tableNode.isParentOf(focusCell)) {
// focus is on higher Grid level than anchor
const gridParent = tableNode.getParent();
invariant(gridParent != null, '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();
invariant(
focusCellParent != null,
'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,
);
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,
);
let exploredMinColumn = minColumn;
let exploredMinRow = minRow;
let exploredMaxColumn = minColumn;
let exploredMaxRow = minRow;
function expandBoundary(mapValue: TableMapValueType): void {
const {
cell,
startColumn: cellStartColumn,
startRow: cellStartRow,
} = mapValue;
minColumn = Math.min(minColumn, cellStartColumn);
minRow = Math.min(minRow, cellStartRow);
maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1);
maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1);
}
while (
minColumn < exploredMinColumn ||
minRow < exploredMinRow ||
maxColumn > exploredMaxColumn ||
maxRow > exploredMaxRow
) {
if (minColumn < exploredMinColumn) {
// Expand on the left
const rowDiff = exploredMaxRow - exploredMinRow;
const previousColumn = exploredMinColumn - 1;
for (let i = 0; i <= rowDiff; i++) {
expandBoundary(map[exploredMinRow + i][previousColumn]);
}
exploredMinColumn = previousColumn;
}
if (minRow < exploredMinRow) {
// Expand on top
const columnDiff = exploredMaxColumn - exploredMinColumn;
const previousRow = exploredMinRow - 1;
for (let i = 0; i <= columnDiff; i++) {
expandBoundary(map[previousRow][exploredMinColumn + i]);
}
exploredMinRow = previousRow;
}
if (maxColumn > exploredMaxColumn) {
// Expand on the right
const rowDiff = exploredMaxRow - exploredMinRow;
const nextColumn = exploredMaxColumn + 1;
for (let i = 0; i <= rowDiff; i++) {
expandBoundary(map[exploredMinRow + i][nextColumn]);
}
exploredMaxColumn = nextColumn;
}
if (maxRow > exploredMaxRow) {
// Expand on the bottom
const columnDiff = exploredMaxColumn - exploredMinColumn;
const nextRow = exploredMaxRow + 1;
for (let i = 0; i <= columnDiff; i++) {
expandBoundary(map[nextRow][exploredMinColumn + i]);
}
exploredMaxRow = nextRow;
}
}
const nodes: Array<LexicalNode> = [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();
invariant(
$isTableRowNode(currentRow),
'Expected TableCellNode parent to be a TableRowNode',
);
if (currentRow !== lastRow) {
nodes.push(currentRow);
}
nodes.push(cell, ...$getChildrenRecursively(cell));
lastRow = currentRow;
}
}
if (!isCurrentlyReadOnlyMode()) {
this._cachedNodes = nodes;
}
return nodes;
}
getTextContent(): string {
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;
}
}
export function $isTableSelection(x: unknown): x is TableSelection {
return x instanceof TableSelection;
}
export function $createTableSelection(): TableSelection {
const anchor = $createPoint('root', 0, 'element');
const focus = $createPoint('root', 0, 'element');
return new TableSelection('root', anchor, focus);
}
export function $getChildrenRecursively(node: LexicalNode): Array<LexicalNode> {
const nodes = [];
const stack = [node];
while (stack.length > 0) {
const currentNode = stack.pop();
invariant(
currentNode !== undefined,
"Stack.length > 0; can't be undefined",
);
if ($isElementNode(currentNode)) {
stack.unshift(...currentNode.getChildren());
}
if (currentNode !== node) {
nodes.push(currentNode);
}
}
return nodes;
}