]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Improved table row copy/paste
authorDan Brown <redacted>
Thu, 22 Aug 2024 09:08:08 +0000 (10:08 +0100)
committerDan Brown <redacted>
Thu, 22 Aug 2024 09:08:08 +0000 (10:08 +0100)
Added safeguarding/matching of source/target sizes to prevent broken
tables.

resources/js/wysiwyg/nodes/custom-table-cell.ts
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/defaults/buttons/tables.ts
resources/js/wysiwyg/ui/framework/core.ts
resources/js/wysiwyg/ui/index.ts
resources/js/wysiwyg/utils/node-clipboard.ts [moved from resources/js/wysiwyg/services/node-clipboard.ts with 96% similarity]
resources/js/wysiwyg/utils/table-copy-paste.ts [new file with mode: 0644]
resources/js/wysiwyg/utils/table-map.ts

index 15c305dcb0fd6f35078d1ff181d216b5044444e3..793302cfec47350f4a2ccdd372a3bec0586067b6 100644 (file)
@@ -235,7 +235,7 @@ export function $convertTableCellNodeElement(
 
 
 export function $createCustomTableCellNode(
-    headerState: TableCellHeaderState,
+    headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS,
     colSpan = 1,
     width?: number,
 ): CustomTableCellNode {
index f339a6ed470c2f4e3555f73fc0e3098a70530993..dcc866888932b2ae3baf7064e82fea8a3bce8aaa 100644 (file)
@@ -2,7 +2,7 @@
 
 ## In progress
 
-// 
+- Table Cut/Copy/Paste column
 
 ## Main Todo
 
@@ -10,7 +10,6 @@
 - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
 - Media resize support (like images)
 - Table caption text support
-- Table Cut/Copy/Paste column
 - Mac: Shortcut support via command.
 
 ## Secondary Todo
index 6242f0b1dbc936aa4db4ed06b38d8ed0a7bed90c..1a9ffb0d33dfc07380da3f967f81233bc0d5845d 100644 (file)
@@ -27,8 +27,12 @@ import {
     $getTableRowsFromSelection,
     $mergeTableCellsInSelection
 } from "../../../utils/tables";
-import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row";
-import {NodeClipboard} from "../../../services/node-clipboard";
+import {$isCustomTableRowNode} from "../../../nodes/custom-table-row";
+import {
+    $copySelectedRowsToClipboard,
+    $cutSelectedRowsToClipboard,
+    $pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty
+} from "../../../utils/table-copy-paste";
 
 const neverActive = (): boolean => false;
 const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode);
@@ -168,17 +172,15 @@ export const rowProperties: EditorButtonDefinition = {
     isDisabled: cellNotSelected,
 };
 
-const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>(CustomTableRowNode);
-
 export const cutRow: EditorButtonDefinition = {
     label: 'Cut row',
     format: 'long',
     action(context: EditorUiContext) {
         context.editor.update(() => {
-            const rows = $getTableRowsFromSelection($getSelection());
-            rowClipboard.set(...rows);
-            for (const row of rows) {
-                row.remove();
+            try {
+                $cutSelectedRowsToClipboard();
+            } catch (e: any) {
+                context.error(e.toString());
             }
         });
     },
@@ -191,8 +193,11 @@ export const copyRow: EditorButtonDefinition = {
     format: 'long',
     action(context: EditorUiContext) {
         context.editor.getEditorState().read(() => {
-            const rows = $getTableRowsFromSelection($getSelection());
-            rowClipboard.set(...rows);
+            try {
+                $copySelectedRowsToClipboard();
+            } catch (e: any) {
+                context.error(e.toString());
+            }
         });
     },
     isActive: neverActive,
@@ -204,17 +209,15 @@ export const pasteRowBefore: EditorButtonDefinition = {
     format: 'long',
     action(context: EditorUiContext) {
         context.editor.update(() => {
-            const rows = $getTableRowsFromSelection($getSelection());
-            const lastRow = rows[rows.length - 1];
-            if (lastRow) {
-                for (const row of rowClipboard.get(context.editor)) {
-                    lastRow.insertBefore(row);
-                }
+            try {
+                $pasteClipboardRowsBefore(context.editor);
+            } catch (e: any) {
+                context.error(e.toString());
             }
         });
     },
     isActive: neverActive,
-    isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0,
+    isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
 };
 
 export const pasteRowAfter: EditorButtonDefinition = {
@@ -222,17 +225,15 @@ export const pasteRowAfter: EditorButtonDefinition = {
     format: 'long',
     action(context: EditorUiContext) {
         context.editor.update(() => {
-            const rows = $getTableRowsFromSelection($getSelection());
-            const lastRow = rows[rows.length - 1];
-            if (lastRow) {
-                for (const row of rowClipboard.get(context.editor).reverse()) {
-                    lastRow.insertAfter(row);
-                }
+            try {
+                $pasteRowsAfter(context.editor);
+            } catch (e: any) {
+                context.error(e.toString());
             }
         });
     },
     isActive: neverActive,
-    isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0,
+    isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
 };
 
 export const cutColumn: EditorButtonDefinition = {
index b6fe52dcdff8a9109cd9cdb7a23449405cc58354..a04f3c74a8efadd9ef85eeb79152c2a10f7fefb0 100644 (file)
@@ -14,6 +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
     manager: EditorUIManager; // UI Manager instance for this editor
     options: Record<string, any>; // General user options which may be used by sub elements
 };
index 116d6e1fc820eb170ca216817a7153689dc936d3..bfa76bb82a887b34a4ee387bebf3d91f9c810ee6 100644 (file)
@@ -20,7 +20,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
         editorDOM: element,
         scrollDOM: scrollContainer,
         manager,
-        translate: (text: string): string => text,
+        translate: (text: string): string => text, // TODO - Implement
+        error(error: string): void {
+            window.$events.error(error); // TODO - Translate
+        },
         options,
     };
     manager.setContext(context);
similarity index 96%
rename from resources/js/wysiwyg/services/node-clipboard.ts
rename to resources/js/wysiwyg/utils/node-clipboard.ts
index 7d880db98895ce187bf0f4e23a3139b5f88d15cb..385c4c46c75175e89d5bb05909726350175a2045 100644 (file)
@@ -44,10 +44,10 @@ export class NodeClipboard<T extends LexicalNode> {
         }
     }
 
-    get(editor: LexicalEditor): LexicalNode[] {
+    get(editor: LexicalEditor): T[] {
         return this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => {
             return node !== null;
-        });
+        }) as T[];
     }
 
     size(): number {
diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts
new file mode 100644 (file)
index 0000000..ae8ef3d
--- /dev/null
@@ -0,0 +1,97 @@
+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 {CustomTableNode} from "../nodes/custom-table";
+import {TableMap} from "./table-map";
+
+const rowClipboard: NodeClipboard<CustomTableRowNode> = new NodeClipboard<CustomTableRowNode>(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 $pasteRowsAfter(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);
+        }
+    }
+}
\ No newline at end of file
index 2b7eba62c035056635984f187d5f53532d818683..bc9721d96d683ea6d28b4084a496a9ac50321d69 100644 (file)
@@ -93,4 +93,4 @@ export class TableMap {
 
         return [...cells.values()];
     }
-}
+}
\ No newline at end of file