From 1ebb0f8c93a0e74c0d6537480e899b3ca766b45f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 22 Aug 2024 13:28:30 +0100 Subject: [PATCH] Lexical: Added table column cut/copy/paste support --- resources/js/wysiwyg/todo.md | 2 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 52 +++-- resources/js/wysiwyg/ui/framework/core.ts | 2 +- resources/js/wysiwyg/ui/index.ts | 5 +- resources/js/wysiwyg/utils/node-clipboard.ts | 5 - .../js/wysiwyg/utils/table-copy-paste.ts | 185 +++++++++++++++++- resources/js/wysiwyg/utils/table-map.ts | 50 ++++- resources/js/wysiwyg/utils/tables.ts | 12 +- 8 files changed, 273 insertions(+), 40 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index dcc866888..5df26bd8c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Table Cut/Copy/Paste column +// ## Main Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 1a9ffb0d3..49e36bdac 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -29,9 +29,15 @@ import { } from "../../../utils/tables"; import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; import { + $copySelectedColumnsToClipboard, $copySelectedRowsToClipboard, + $cutSelectedColumnsToClipboard, $cutSelectedRowsToClipboard, - $pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty + $pasteClipboardRowsBefore, + $pasteClipboardRowsAfter, + isColumnClipboardEmpty, + isRowClipboardEmpty, + $pasteClipboardColumnsBefore, $pasteClipboardColumnsAfter } from "../../../utils/table-copy-paste"; const neverActive = (): boolean => false; @@ -180,7 +186,7 @@ export const cutRow: EditorButtonDefinition = { try { $cutSelectedRowsToClipboard(); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -196,7 +202,7 @@ export const copyRow: EditorButtonDefinition = { try { $copySelectedRowsToClipboard(); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -212,7 +218,7 @@ export const pasteRowBefore: EditorButtonDefinition = { try { $pasteClipboardRowsBefore(context.editor); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -226,9 +232,9 @@ export const pasteRowAfter: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.update(() => { try { - $pasteRowsAfter(context.editor); + $pasteClipboardRowsAfter(context.editor); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -240,8 +246,12 @@ export const cutColumn: EditorButtonDefinition = { label: 'Cut column', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + try { + $cutSelectedColumnsToClipboard(); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, @@ -253,7 +263,11 @@ export const copyColumn: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - // TODO + try { + $copySelectedColumnsToClipboard(); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, @@ -264,24 +278,32 @@ export const pasteColumnBefore: EditorButtonDefinition = { label: 'Paste column before', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + try { + $pasteClipboardColumnsBefore(context.editor); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(), }; export const pasteColumnAfter: EditorButtonDefinition = { label: 'Paste column after', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + try { + $pasteClipboardColumnsAfter(context.editor); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(), }; export const insertColumnBefore: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index a04f3c74a..3433b96e8 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -14,7 +14,7 @@ export type EditorUiContext = { containerDOM: HTMLElement; // DOM element which contains all editor elements scrollDOM: HTMLElement; // DOM element which is the main content scroll container translate: (text: string) => string; // Translate function - error: (text: string) => void; // Error reporting function + error: (text: string|Error) => void; // Error reporting function manager: EditorUIManager; // UI Manager instance for this editor options: Record; // General user options which may be used by sub elements }; diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index bfa76bb82..3b6d195b7 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -21,8 +21,9 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro scrollDOM: scrollContainer, manager, translate: (text: string): string => text, // TODO - Implement - error(error: string): void { - window.$events.error(error); // TODO - Translate + error(error: string|Error): void { + const message = error instanceof Error ? error.message : error; + window.$events.error(message); // TODO - Translate }, options, }; diff --git a/resources/js/wysiwyg/utils/node-clipboard.ts b/resources/js/wysiwyg/utils/node-clipboard.ts index 385c4c46c..dd3b4dfbe 100644 --- a/resources/js/wysiwyg/utils/node-clipboard.ts +++ b/resources/js/wysiwyg/utils/node-clipboard.ts @@ -30,13 +30,8 @@ function unserializeNodeRecursive(editor: LexicalEditor, {node, children}: Seria } export class NodeClipboard { - nodeClass: {importJSON: (s: SerializedLexicalNode) => T}; protected store: SerializedLexicalNodeWithChildren[] = []; - constructor(nodeClass: {importJSON: (s: any) => T}) { - this.nodeClass = nodeClass; - } - set(...nodes: LexicalNode[]): void { this.store.splice(0, this.store.length); for (const node of nodes) { diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts index ae8ef3d35..12c19b0fb 100644 --- a/resources/js/wysiwyg/utils/table-copy-paste.ts +++ b/resources/js/wysiwyg/utils/table-copy-paste.ts @@ -1,12 +1,14 @@ import {NodeClipboard} from "./node-clipboard"; import {CustomTableRowNode} from "../nodes/custom-table-row"; -import {$getTableFromSelection, $getTableRowsFromSelection} from "./tables"; -import {$getSelection, LexicalEditor} from "lexical"; -import {$createCustomTableCellNode, $isCustomTableCellNode} from "../nodes/custom-table-cell"; +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 = new NodeClipboard(CustomTableRowNode); +const rowClipboard: NodeClipboard = new NodeClipboard(); export function isRowClipboardEmpty(): boolean { return rowClipboard.size() === 0; @@ -82,7 +84,7 @@ export function $pasteClipboardRowsBefore(editor: LexicalEditor): void { } } -export function $pasteRowsAfter(editor: LexicalEditor): void { +export function $pasteClipboardRowsAfter(editor: LexicalEditor): void { const selection = $getSelection(); const rows = $getTableRowsFromSelection(selection); const table = $getTableFromSelection(selection); @@ -94,4 +96,177 @@ export function $pasteRowsAfter(editor: LexicalEditor): void { lastRow.insertAfter(row); } } +} + +const columnClipboard: NodeClipboard[] = []; + +function setColumnClipboard(columns: CustomTableCellNode[][]): void { + const newClipboards = columns.map(cells => { + const clipboard = new NodeClipboard(); + 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); } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts index bc9721d96..607deffe1 100644 --- a/resources/js/wysiwyg/utils/table-map.ts +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -2,6 +2,13 @@ import {CustomTableNode} from "../nodes/custom-table"; import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; import {$isTableRowNode} from "@lexical/table"; +export type CellRange = { + fromX: number; + fromY: number; + toX: number; + toY: number; +} + export class TableMap { rowCount: number = 0; @@ -77,11 +84,11 @@ export class TableMap { return this.cells[position]; } - public getCellsInRange(fromX: number, fromY: number, toX: number, toY: number): CustomTableCellNode[] { - const minX = Math.max(Math.min(fromX, toX), 0); - const maxX = Math.min(Math.max(fromX, toX), this.columnCount - 1); - const minY = Math.max(Math.min(fromY, toY), 0); - const maxY = Math.min(Math.max(fromY, toY), this.rowCount - 1); + public getCellsInRange(range: CellRange): CustomTableCellNode[] { + const minX = Math.max(Math.min(range.fromX, range.toX), 0); + const maxX = Math.min(Math.max(range.fromX, range.toX), this.columnCount - 1); + const minY = Math.max(Math.min(range.fromY, range.toY), 0); + const maxY = Math.min(Math.max(range.fromY, range.toY), this.rowCount - 1); const cells = new Set(); @@ -93,4 +100,37 @@ export class TableMap { return [...cells.values()]; } + + public getCellsInColumn(columnIndex: number): CustomTableCellNode[] { + return this.getCellsInRange({ + fromX: columnIndex, + toX: columnIndex, + fromY: 0, + toY: this.rowCount - 1, + }); + } + + public getRangeForCell(cell: CustomTableCellNode): CellRange|null { + let range: CellRange|null = null; + const cellKey = cell.getKey(); + + for (let y = 0; y < this.rowCount; y++) { + for (let x = 0; x < this.columnCount; x++) { + const index = (y * this.columnCount) + x; + const lCell = this.cells[index]; + if (lCell.getKey() === cellKey) { + if (range === null) { + range = {fromX: x, toX: x, fromY: y, toY: y}; + } else { + range.fromX = Math.min(range.fromX, x); + range.toX = Math.max(range.toX, x); + range.fromY = Math.min(range.fromY, y); + range.toY = Math.max(range.toY, y); + } + } + } + } + + return range; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index d0fd17e2c..aa8ec89ba 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -168,12 +168,12 @@ export function $mergeTableCellsInSelection(selection: TableSelection): void { const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1); const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1); - const mergeCells = tableMap.getCellsInRange( - selectionShape.fromX, - selectionShape.fromY, - fixedToX, - fixedToY, - ); + const mergeCells = tableMap.getCellsInRange({ + fromX: selectionShape.fromX, + fromY: selectionShape.fromY, + toX: fixedToX, + toY: fixedToY, + }); if (mergeCells.length === 0) { return;