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;
 | 
						|
}
 |