BookStackApp/BookStack

View on GitHub
resources/js/wysiwyg/utils/table-copy-paste.ts

Summary

Maintainability
C
1 day
Test Coverage
import {NodeClipboard} from "./node-clipboard";
import {CustomTableRowNode} from "../nodes/custom-table-row";
import {$getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection} from "./tables";
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
import {$createCustomTableCellNode, $isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell";
import {CustomTableNode} from "../nodes/custom-table";
import {TableMap} from "./table-map";
import {$isTableSelection} from "@lexical/table";
import {$getNodeFromSelection} from "./selection";

const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>();

export function isRowClipboardEmpty(): boolean {
    return rowClipboard.size() === 0;
}

export function validateRowsToCopy(rows: CustomTableRowNode[]): void {
    let commonRowSize: number|null = null;

    for (const row of rows) {
        const cells = row.getChildren().filter(n => $isCustomTableCellNode(n));
        let rowSize = 0;
        for (const cell of cells) {
            rowSize += cell.getColSpan() || 1;
            if (cell.getRowSpan() > 1) {
                throw Error('Cannot copy rows with merged cells');
            }
        }

        if (commonRowSize === null) {
            commonRowSize = rowSize;
        } else if (commonRowSize !== rowSize) {
            throw Error('Cannot copy rows with inconsistent sizes');
        }
    }
}

export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: CustomTableNode): void {
    const tableColCount = (new TableMap(targetTable)).columnCount;
    for (const row of rows) {
        const cells = row.getChildren().filter(n => $isCustomTableCellNode(n));
        let rowSize = 0;
        for (const cell of cells) {
            rowSize += cell.getColSpan() || 1;
        }

        if (rowSize > tableColCount) {
            throw Error('Cannot paste rows that are wider than target table');
        }

        while (rowSize < tableColCount) {
            row.append($createCustomTableCellNode());
            rowSize++;
        }
    }
}

export function $cutSelectedRowsToClipboard(): void {
    const rows = $getTableRowsFromSelection($getSelection());
    validateRowsToCopy(rows);
    rowClipboard.set(...rows);
    for (const row of rows) {
        row.remove();
    }
}

export function $copySelectedRowsToClipboard(): void {
    const rows = $getTableRowsFromSelection($getSelection());
    validateRowsToCopy(rows);
    rowClipboard.set(...rows);
}

export function $pasteClipboardRowsBefore(editor: LexicalEditor): void {
    const selection = $getSelection();
    const rows = $getTableRowsFromSelection(selection);
    const table = $getTableFromSelection(selection);
    const lastRow = rows[rows.length - 1];
    if (lastRow && table) {
        const clipboardRows = rowClipboard.get(editor);
        validateRowsToPaste(clipboardRows, table);
        for (const row of clipboardRows) {
            lastRow.insertBefore(row);
        }
    }
}

export function $pasteClipboardRowsAfter(editor: LexicalEditor): void {
    const selection = $getSelection();
    const rows = $getTableRowsFromSelection(selection);
    const table = $getTableFromSelection(selection);
    const lastRow = rows[rows.length - 1];
    if (lastRow && table) {
        const clipboardRows = rowClipboard.get(editor).reverse();
        validateRowsToPaste(clipboardRows, table);
        for (const row of clipboardRows) {
            lastRow.insertAfter(row);
        }
    }
}

const columnClipboard: NodeClipboard<CustomTableCellNode>[] = [];

function setColumnClipboard(columns: CustomTableCellNode[][]): void {
    const newClipboards = columns.map(cells => {
        const clipboard = new NodeClipboard<CustomTableCellNode>();
        clipboard.set(...cells);
        return clipboard;
    });

    columnClipboard.splice(0, columnClipboard.length, ...newClipboards);
}

type TableRange = {from: number, to: number};

export function isColumnClipboardEmpty(): boolean {
    return columnClipboard.length === 0;
}

function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|null {
    if ($isTableSelection(selection)) {
        const shape = selection.getShape()
        return {from: shape.fromX, to: shape.toX};
    }

    const cell = $getNodeFromSelection(selection, $isCustomTableCellNode);
    const table = $getTableFromSelection(selection);
    if (!$isCustomTableCellNode(cell) || !table) {
        return null;
    }

    const map = new TableMap(table);
    const range = map.getRangeForCell(cell);
    if (!range) {
        return null;
    }

    return {from: range.fromX, to: range.toX};
}

function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTableNode): CustomTableCellNode[][] {
    const map = new TableMap(table);
    const columns = [];
    for (let x = range.from; x <= range.to; x++) {
        const cells = map.getCellsInColumn(x);
        columns.push(cells);
    }

    return columns;
}

function validateColumnsToCopy(columns: CustomTableCellNode[][]): void {
    let commonColSize: number|null = null;

    for (const cells of columns) {
        let colSize = 0;
        for (const cell of cells) {
            colSize += cell.getRowSpan() || 1;
            if (cell.getColSpan() > 1) {
                throw Error('Cannot copy columns with merged cells');
            }
        }

        if (commonColSize === null) {
            commonColSize = colSize;
        } else if (commonColSize !== colSize) {
            throw Error('Cannot copy columns with inconsistent sizes');
        }
    }
}

export function $cutSelectedColumnsToClipboard(): void {
    const selection = $getSelection();
    const range = $getSelectionColumnRange(selection);
    const table = $getTableFromSelection(selection);
    if (!range || !table) {
        return;
    }

    const colWidths = table.getColWidths();
    const columns = $getTableColumnCellsFromSelection(range, table);
    validateColumnsToCopy(columns);
    setColumnClipboard(columns);
    for (const cells of columns) {
        for (const cell of cells) {
            cell.remove();
        }
    }

    const newWidths = [...colWidths].splice(range.from, (range.to - range.from) + 1);
    table.setColWidths(newWidths);
}

export function $copySelectedColumnsToClipboard(): void {
    const selection = $getSelection();
    const range = $getSelectionColumnRange(selection);
    const table = $getTableFromSelection(selection);
    if (!range || !table) {
        return;
    }

    const columns = $getTableColumnCellsFromSelection(range, table);
    validateColumnsToCopy(columns);
    setColumnClipboard(columns);
}

function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: CustomTableNode) {
    const tableRowCount = (new TableMap(targetTable)).rowCount;
    for (const cells of columns) {
        let colSize = 0;
        for (const cell of cells) {
            colSize += cell.getRowSpan() || 1;
        }

        if (colSize > tableRowCount) {
            throw Error('Cannot paste columns that are taller than target table');
        }

        while (colSize < tableRowCount) {
            cells.push($createCustomTableCellNode());
            colSize++;
        }
    }
}

function $pasteClipboardColumns(editor: LexicalEditor, isBefore: boolean): void {
    const selection = $getSelection();
    const table = $getTableFromSelection(selection);
    const cells = $getTableCellsFromSelection(selection);
    const referenceCell = cells[isBefore ? 0 : cells.length - 1];
    if (!table || !referenceCell) {
        return;
    }

    const clipboardCols = columnClipboard.map(cb => cb.get(editor));
    if (!isBefore) {
        clipboardCols.reverse();
    }

    validateColumnsToPaste(clipboardCols, table);
    const map = new TableMap(table);
    const cellRange = map.getRangeForCell(referenceCell);
    if (!cellRange) {
        return;
    }

    const colIndex = isBefore ? cellRange.fromX : cellRange.toX;
    const colWidths = table.getColWidths();

    for (let y = 0; y < map.rowCount; y++) {
        const relCell = map.getCellAtPosition(colIndex, y);
        for (const cells of clipboardCols) {
            const newCell = cells[y];
            if (isBefore) {
                relCell.insertBefore(newCell);
            } else {
                relCell.insertAfter(newCell);
            }
        }
    }

    const refWidth = colWidths[colIndex];
    const addedWidths = clipboardCols.map(_ => refWidth);
    colWidths.splice(isBefore ? colIndex : colIndex + 1, 0, ...addedWidths);
}

export function $pasteClipboardColumnsBefore(editor: LexicalEditor): void {
    $pasteClipboardColumns(editor, true);
}

export function $pasteClipboardColumnsAfter(editor: LexicalEditor): void {
    $pasteClipboardColumns(editor, false);
}