From abbfd42a6c33d1c5e90517448add0f5909051d0e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 9 Aug 2024 21:58:45 +0100 Subject: [PATCH] Lexical: Kinda made row copy/paste work --- .../js/wysiwyg/services/node-clipboard.ts | 56 +++++++++++++++++++ resources/js/wysiwyg/todo.md | 5 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 46 +++++++++++---- 3 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 resources/js/wysiwyg/services/node-clipboard.ts diff --git a/resources/js/wysiwyg/services/node-clipboard.ts b/resources/js/wysiwyg/services/node-clipboard.ts new file mode 100644 index 000000000..7d880db98 --- /dev/null +++ b/resources/js/wysiwyg/services/node-clipboard.ts @@ -0,0 +1,56 @@ +import {$isElementNode, LexicalEditor, LexicalNode, SerializedLexicalNode} from "lexical"; + +type SerializedLexicalNodeWithChildren = { + node: SerializedLexicalNode, + children: SerializedLexicalNodeWithChildren[], +}; + +function serializeNodeRecursive(node: LexicalNode): SerializedLexicalNodeWithChildren { + const childNodes = $isElementNode(node) ? node.getChildren() : []; + return { + node: node.exportJSON(), + children: childNodes.map(n => serializeNodeRecursive(n)), + }; +} + +function unserializeNodeRecursive(editor: LexicalEditor, {node, children}: SerializedLexicalNodeWithChildren): LexicalNode|null { + const instance = editor._nodes.get(node.type)?.klass.importJSON(node); + if (!instance) { + return null; + } + + const childNodes = children.map(child => unserializeNodeRecursive(editor, child)); + for (const child of childNodes) { + if (child && $isElementNode(instance)) { + instance.append(child); + } + } + + return instance; +} + +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) { + this.store.push(serializeNodeRecursive(node)); + } + } + + get(editor: LexicalEditor): LexicalNode[] { + return this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => { + return node !== null; + }); + } + + size(): number { + return this.store.length; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index cf24ad677..b6325688e 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -7,6 +7,7 @@ - Caption text support - Resize to contents button - Remove formatting button + - Cut/Copy/Paste column ## Main Todo @@ -32,4 +33,6 @@ - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. -- Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file +- Template drag/drop not handled when outside core editor area (ignored in margin area). +- Table row copy/paste does not handle merged cells + - TinyMCE fills gaps with the cells that would be visually in the row \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 50353961f..c98f6c02f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -8,7 +8,7 @@ import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; -import {$getSelection, BaseSelection} from "lexical"; +import {$createNodeSelection, $createRangeSelection, $getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { $deleteTableColumn__EXPERIMENTAL, @@ -21,8 +21,11 @@ import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/ import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; import {$showCellPropertiesForm, $showRowPropertiesForm} from "../forms/tables"; -import {$mergeTableCellsInSelection} from "../../../utils/tables"; -import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; +import {$getTableRowsFromSelection, $mergeTableCellsInSelection} from "../../../utils/tables"; +import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row"; +import {NodeClipboard} from "../../../services/node-clipboard"; +import {r} from "@codemirror/legacy-modes/mode/r"; +import {$generateHtmlFromNodes} from "@lexical/html"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -177,12 +180,18 @@ export const rowProperties: EditorButtonDefinition = { isDisabled: cellNotSelected, }; +const rowClipboard: NodeClipboard = new NodeClipboard(CustomTableRowNode); + export const cutRow: EditorButtonDefinition = { label: 'Cut row', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + const rows = $getTableRowsFromSelection($getSelection()); + rowClipboard.set(...rows); + for (const row of rows) { + row.remove(); + } }); }, isActive: neverActive, @@ -194,7 +203,8 @@ export const copyRow: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - // TODO + const rows = $getTableRowsFromSelection($getSelection()); + rowClipboard.set(...rows); }); }, isActive: neverActive, @@ -205,24 +215,36 @@ export const pasteRowBefore: EditorButtonDefinition = { label: 'Paste row before', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + 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); + } + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0, }; export const pasteRowAfter: EditorButtonDefinition = { label: 'Paste row after', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + 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); + } + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0, }; export const cutColumn: EditorButtonDefinition = {