mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-12-15 15:44:03 +08:00
22d078b47f
Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies.
374 lines
11 KiB
TypeScript
374 lines
11 KiB
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 {$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;
|
|
}
|