]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Started on table actions
authorDan Brown <redacted>
Fri, 2 Aug 2024 14:28:54 +0000 (15:28 +0100)
committerDan Brown <redacted>
Fri, 2 Aug 2024 14:28:54 +0000 (15:28 +0100)
Started building table cell form/actions

resources/js/wysiwyg/index.ts
resources/js/wysiwyg/nodes/custom-table.ts
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/defaults/buttons/tables.ts
resources/js/wysiwyg/ui/defaults/forms/controls.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/defaults/forms/objects.ts [moved from resources/js/wysiwyg/ui/defaults/form-definitions.ts with 87% similarity]
resources/js/wysiwyg/ui/defaults/forms/tables.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/defaults/modals.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts [new file with mode: 0644]
resources/js/wysiwyg/ui/index.ts
resources/js/wysiwyg/ui/toolbars.ts

index 1e9dd25df7c879e1ed08c88bc7d037093652f052..71a007f590ea6ff5c371e147cd27438afe8ace31 100644 (file)
@@ -11,6 +11,7 @@ import {EditorUiContext} from "./ui/framework/core";
 import {listen as listenToCommonEvents} from "./common-events";
 import {handleDropEvents} from "./drop-handling";
 import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler";
+import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
 
 export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
     const config: CreateEditorArgs = {
@@ -48,6 +49,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
         registerRichText(editor),
         registerHistory(editor, createEmptyHistoryState(), 300),
         registerTableResizer(editor, editWrap),
+        registerTableSelectionHandler(editor),
         registerTaskListHandler(editor, editArea),
     );
 
index 1107f0a906b5faad7bb28a02909c41935777a2c7..7dda24a7a30da71c0008f1d378257791798e4a1a 100644 (file)
@@ -157,7 +157,7 @@ export function $createCustomTableNode(): CustomTableNode {
     return new CustomTableNode();
 }
 
-export function $isCustomTableNode(node: LexicalNode | null | undefined): boolean {
+export function $isCustomTableNode(node: LexicalNode | null | undefined): node is CustomTableNode {
     return node instanceof CustomTableNode;
 }
 
index 1a367d0dd1a7c94a03f119bb6dbe3bbac203a55d..0354b7935ff40e42b4f829fa2c273d9bd9ed29c9 100644 (file)
@@ -3,7 +3,9 @@
 ## In progress
 
 - Table features
-  - Continued table dropdown menu 
+  - Continued table dropdown menu
+  - Connect up cell properties form
+  - Merge cell action
 
 ## Main Todo
 
 - 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)
 
+## Secondary Todo
+
+- Color picker support in table form color fields
+
 ## Bugs
 
 - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node.
index b6b92e1970f4c4eca75dcd9780fe166331431b94..e3f7bb570e5c0a194d001997bc8575b9ed679f2c 100644 (file)
@@ -18,8 +18,8 @@ import {
     $deleteTableColumn__EXPERIMENTAL,
     $deleteTableRow__EXPERIMENTAL,
     $insertTableColumn__EXPERIMENTAL,
-    $insertTableRow__EXPERIMENTAL,
-    $isTableNode,
+    $insertTableRow__EXPERIMENTAL, $isTableCellNode,
+    $isTableNode, $isTableSelection, $unmergeCell, TableCellNode,
 } from "@lexical/table";
 
 
@@ -128,4 +128,62 @@ export const deleteColumn: EditorButtonDefinition = {
     isActive() {
         return false;
     }
+};
+
+export const cellProperties: EditorButtonDefinition = {
+    label: 'Cell properties',
+    action(context: EditorUiContext) {
+        context.editor.getEditorState().read(() => {
+            const cell = $getNodeFromSelection($getSelection(), $isTableCellNode);
+            if ($isTableCellNode(cell)) {
+
+                const modalForm = context.manager.createModal('cell_properties');
+                modalForm.show({});
+            }
+        });
+    },
+    isActive() {
+        return false;
+    },
+    isDisabled(selection) {
+        return !$selectionContainsNodeType(selection, $isTableCellNode);
+    }
+};
+
+export const mergeCells: EditorButtonDefinition = {
+    label: 'Merge cells',
+    action(context: EditorUiContext) {
+        context.editor.update(() => {
+            // Todo - Needs to be done manually
+            // Playground reference:
+            // https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx#L299
+        });
+    },
+    isActive() {
+        return false;
+    },
+    isDisabled(selection) {
+        return !$isTableSelection(selection);
+    }
+};
+
+export const splitCell: EditorButtonDefinition = {
+    label: 'Split cell',
+    action(context: EditorUiContext) {
+        context.editor.update(() => {
+            $unmergeCell();
+        });
+    },
+    isActive() {
+        return false;
+    },
+    isDisabled(selection) {
+        const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode|null;
+        if (cell) {
+            const merged = cell.getRowSpan() > 1 || cell.getColSpan() > 1;
+            return !merged;
+        }
+
+        return true;
+    }
 };
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts
new file mode 100644 (file)
index 0000000..bcb2f5b
--- /dev/null
@@ -0,0 +1,18 @@
+import {EditorFormDefinition} from "../../framework/forms";
+import {EditorUiContext} from "../../framework/core";
+import {setEditorContentFromHtml} from "../../../actions";
+
+export const source: EditorFormDefinition = {
+    submitText: 'Save',
+    async action(formData, context: EditorUiContext) {
+        setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || '');
+        return true;
+    },
+    fields: [
+        {
+            label: 'Source',
+            name: 'source',
+            type: 'textarea',
+        },
+    ],
+};
\ No newline at end of file
similarity index 87%
rename from resources/js/wysiwyg/ui/defaults/form-definitions.ts
rename to resources/js/wysiwyg/ui/defaults/forms/objects.ts
index 6c0a54f2333024acff0aede76846f85d4e99d762..7a388751bc420733df52ecfce6605fb178ea139a 100644 (file)
@@ -1,13 +1,49 @@
-import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../framework/forms";
-import {EditorUiContext} from "../framework/core";
+import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../../framework/forms";
+import {EditorUiContext} from "../../framework/core";
+import {$createTextNode, $getSelection} from "lexical";
+import {$createImageNode} from "../../../nodes/image";
 import {$createLinkNode} from "@lexical/link";
-import {$createTextNode, $getSelection, LexicalNode} from "lexical";
-import {$createImageNode} from "../../nodes/image";
-import {setEditorContentFromHtml} from "../../actions";
-import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../nodes/media";
-import {$getNodeFromSelection} from "../../helpers";
+import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media";
+import {$getNodeFromSelection} from "../../../helpers";
 import {$insertNodeToNearestRoot} from "@lexical/utils";
 
+export const image: EditorFormDefinition = {
+    submitText: 'Apply',
+    async action(formData, context: EditorUiContext) {
+        context.editor.update(() => {
+            const selection = $getSelection();
+            const imageNode = $createImageNode(formData.get('src')?.toString() || '', {
+                alt: formData.get('alt')?.toString() || '',
+                height: Number(formData.get('height')?.toString() || '0'),
+                width: Number(formData.get('width')?.toString() || '0'),
+            });
+            selection?.insertNodes([imageNode]);
+        });
+        return true;
+    },
+    fields: [
+        {
+            label: 'Source',
+            name: 'src',
+            type: 'text',
+        },
+        {
+            label: 'Alternative description',
+            name: 'alt',
+            type: 'text',
+        },
+        {
+            label: 'Width',
+            name: 'width',
+            type: 'text',
+        },
+        {
+            label: 'Height',
+            name: 'height',
+            type: 'text',
+        },
+    ],
+};
 
 export const link: EditorFormDefinition = {
     submitText: 'Apply',
@@ -54,44 +90,6 @@ export const link: EditorFormDefinition = {
     ],
 };
 
-export const image: EditorFormDefinition = {
-    submitText: 'Apply',
-    async action(formData, context: EditorUiContext) {
-        context.editor.update(() => {
-            const selection = $getSelection();
-            const imageNode = $createImageNode(formData.get('src')?.toString() || '', {
-                alt: formData.get('alt')?.toString() || '',
-                height: Number(formData.get('height')?.toString() || '0'),
-                width: Number(formData.get('width')?.toString() || '0'),
-            });
-            selection?.insertNodes([imageNode]);
-        });
-        return true;
-    },
-    fields: [
-        {
-            label: 'Source',
-            name: 'src',
-            type: 'text',
-        },
-        {
-            label: 'Alternative description',
-            name: 'alt',
-            type: 'text',
-        },
-        {
-            label: 'Width',
-            name: 'width',
-            type: 'text',
-        },
-        {
-            label: 'Height',
-            name: 'height',
-            type: 'text',
-        },
-    ],
-};
-
 export const media: EditorFormDefinition = {
     submitText: 'Save',
     async action(formData, context: EditorUiContext) {
@@ -169,19 +167,4 @@ export const media: EditorFormDefinition = {
             }
         },
     ],
-};
-
-export const source: EditorFormDefinition = {
-    submitText: 'Save',
-    async action(formData, context: EditorUiContext) {
-        setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || '');
-        return true;
-    },
-    fields: [
-        {
-            label: 'Source',
-            name: 'source',
-            type: 'textarea',
-        },
-    ],
 };
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts
new file mode 100644 (file)
index 0000000..a045ba5
--- /dev/null
@@ -0,0 +1,112 @@
+import {
+    EditorFormDefinition,
+    EditorFormFieldDefinition,
+    EditorFormTabs,
+    EditorSelectFormFieldDefinition
+} from "../../framework/forms";
+import {EditorUiContext} from "../../framework/core";
+import {setEditorContentFromHtml} from "../../../actions";
+
+export const cellProperties: EditorFormDefinition = {
+    submitText: 'Save',
+    async action(formData, context: EditorUiContext) {
+        setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || '');
+        return true;
+    },
+    fields: [
+        {
+            build() {
+                const generalFields: EditorFormFieldDefinition[] = [
+                    {
+                        label: 'Width',
+                        name: 'width',
+                        type: 'text',
+                    },
+                    {
+                        label: 'Height',
+                        name: 'height',
+                        type: 'text',
+                    },
+                    {
+                        label: 'Cell type',
+                        name: 'type',
+                        type: 'select',
+                        valuesByLabel: {
+                            'Cell': 'cell',
+                            'Header cell': 'header',
+                        }
+                    } as EditorSelectFormFieldDefinition,
+                    {
+                        label: 'Horizontal align',
+                        name: 'h_align',
+                        type: 'select',
+                        valuesByLabel: {
+                            'None': '',
+                            'Left': 'left',
+                            'Center': 'center',
+                            'Right': 'right',
+                        }
+                    } as EditorSelectFormFieldDefinition,
+                    {
+                        label: 'Vertical align',
+                        name: 'v_align',
+                        type: 'select',
+                        valuesByLabel: {
+                            'None': '',
+                            'Top': 'top',
+                            'Middle': 'middle',
+                            'Bottom': 'bottom',
+                        }
+                    } as EditorSelectFormFieldDefinition,
+                ];
+
+                const advancedFields: EditorFormFieldDefinition[] = [
+                    {
+                        label: 'Border width',
+                        name: 'border_width',
+                        type: 'text',
+                    },
+                    {
+                        label: 'Border style',
+                        name: 'border_style',
+                        type: 'select',
+                        valuesByLabel: {
+                            'Select...': '',
+                            "Solid": 'solid',
+                            "Dotted": 'dotted',
+                            "Dashed": 'dashed',
+                            "Double": 'double',
+                            "Groove": 'groove',
+                            "Ridge": 'ridge',
+                            "Inset": 'inset',
+                            "Outset": 'outset',
+                            "None": 'none',
+                            "Hidden": 'hidden',
+                        }
+                    } as EditorSelectFormFieldDefinition,
+                    {
+                        label: 'Border color',
+                        name: 'border_color',
+                        type: 'text',
+                    },
+                    {
+                        label: 'Background color',
+                        name: 'background_color',
+                        type: 'text',
+                    },
+                ];
+
+                return new EditorFormTabs([
+                    {
+                        label: 'General',
+                        contents: generalFields,
+                    },
+                    {
+                        label: 'Advanced',
+                        contents: advancedFields,
+                    }
+                ])
+            }
+        },
+    ],
+};
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts
new file mode 100644 (file)
index 0000000..3035160
--- /dev/null
@@ -0,0 +1,27 @@
+import {EditorFormModalDefinition} from "../framework/modals";
+import {image, link, media} from "./forms/objects";
+import {source} from "./forms/controls";
+import {cellProperties} from "./forms/tables";
+
+export const modals: Record<string, EditorFormModalDefinition> = {
+    link: {
+        title: 'Insert/Edit link',
+        form: link,
+    },
+    image: {
+        title: 'Insert/Edit Image',
+        form: image,
+    },
+    media: {
+        title: 'Insert/Edit Media',
+        form: media,
+    },
+    source: {
+        title: 'Source code',
+        form: source,
+    },
+    cell_properties: {
+        title: 'Cell Properties',
+        form: cellProperties,
+    },
+};
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts
new file mode 100644 (file)
index 0000000..0557b37
--- /dev/null
@@ -0,0 +1,80 @@
+import {$getNodeByKey, LexicalEditor} from "lexical";
+import {NodeKey} from "lexical/LexicalNode";
+import {
+    $isTableNode,
+    applyTableHandlers,
+    HTMLTableElementWithWithTableSelectionState,
+    TableNode,
+    TableObserver
+} from "@lexical/table";
+import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table";
+
+// File adapted from logic in:
+// https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-react/src/LexicalTablePlugin.ts#L49
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+// License: MIT
+
+class TableSelectionHandler {
+
+    protected editor: LexicalEditor
+    protected tableSelections = new Map<NodeKey, TableObserver>();
+    protected unregisterMutationListener = () => {};
+
+    constructor(editor: LexicalEditor) {
+        this.editor = editor;
+        this.init();
+    }
+
+    protected init() {
+        this.unregisterMutationListener = this.editor.registerMutationListener(CustomTableNode, (mutations) => {
+            for (const [nodeKey, mutation] of mutations) {
+                if (mutation === 'created') {
+                    this.editor.getEditorState().read(() => {
+                        const tableNode = $getNodeByKey<CustomTableNode>(nodeKey);
+                        if ($isCustomTableNode(tableNode)) {
+                            this.initializeTableNode(tableNode);
+                        }
+                    });
+                } else if (mutation === 'destroyed') {
+                    const tableSelection = this.tableSelections.get(nodeKey);
+
+                    if (tableSelection !== undefined) {
+                        tableSelection.removeListeners();
+                        this.tableSelections.delete(nodeKey);
+                    }
+                }
+            }
+        });
+    }
+
+    protected initializeTableNode(tableNode: TableNode) {
+        const nodeKey = tableNode.getKey();
+        const tableElement = this.editor.getElementByKey(
+            nodeKey,
+        ) as HTMLTableElementWithWithTableSelectionState;
+        if (tableElement && !this.tableSelections.has(nodeKey)) {
+            const tableSelection = applyTableHandlers(
+                tableNode,
+                tableElement,
+                this.editor,
+                false,
+            );
+            this.tableSelections.set(nodeKey, tableSelection);
+        }
+    };
+
+    teardown() {
+        this.unregisterMutationListener();
+        for (const [, tableSelection] of this.tableSelections) {
+            tableSelection.removeListeners();
+        }
+    }
+}
+
+export function registerTableSelectionHandler(editor: LexicalEditor): (() => void) {
+    const resizer = new TableSelectionHandler(editor);
+
+    return () => {
+        resizer.teardown();
+    };
+}
\ No newline at end of file
index a3f150e529ecf5d45e18a9f896e8fdb8036e549a..5fbaec91b55ad20182cc73dc13e7a821daa3a2bc 100644 (file)
@@ -6,11 +6,11 @@ import {
     getMainEditorFullToolbar, getTableToolbarContent
 } from "./toolbars";
 import {EditorUIManager} from "./framework/manager";
-import {image as imageFormDefinition, link as linkFormDefinition, media as mediaFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions";
 import {ImageDecorator} from "./decorators/image";
 import {EditorUiContext} from "./framework/core";
 import {CodeBlockDecorator} from "./decorators/code-block";
 import {DiagramDecorator} from "./decorators/diagram";
+import {modals} from "./defaults/modals";
 
 export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record<string, any>): EditorUiContext {
     const manager = new EditorUIManager();
@@ -30,22 +30,9 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
     manager.setToolbar(getMainEditorFullToolbar());
 
     // Register modals
-    manager.registerModal('link', {
-        title: 'Insert/Edit link',
-        form: linkFormDefinition,
-    });
-    manager.registerModal('image', {
-        title: 'Insert/Edit Image',
-        form: imageFormDefinition
-    });
-    manager.registerModal('media', {
-        title: 'Insert/Edit Media',
-        form: mediaFormDefinition,
-    });
-    manager.registerModal('source', {
-        title: 'Source code',
-        form: sourceFormDefinition,
-    });
+    for (const key of Object.keys(modals)) {
+        manager.registerModal(key, modals[key]);
+    }
 
     // Register context toolbars
     manager.registerContextToolbar('image', {
index d2b179eb6ce46c904c1b6d1d03249efafd8deb4a..43f00c0014be7143262e17d35d0417971231326a 100644 (file)
@@ -9,12 +9,13 @@ import {EditorTableCreator} from "./framework/blocks/table-creator";
 import {EditorColorButton} from "./framework/blocks/color-button";
 import {EditorOverflowContainer} from "./framework/blocks/overflow-container";
 import {
+    cellProperties,
     deleteColumn,
     deleteRow,
     deleteTable, deleteTableMenuAction, insertColumnAfter,
     insertColumnBefore,
     insertRowAbove,
-    insertRowBelow,
+    insertRowBelow, mergeCells, splitCell,
     table
 } from "./defaults/buttons/tables";
 import {fullscreen, redo, source, undo} from "./defaults/buttons/controls";
@@ -118,6 +119,11 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement {
                 new EditorDropdownButton({button: {...table, format: 'long'}, showOnHover: true}, [
                     new EditorTableCreator(),
                 ]),
+                new EditorDropdownButton({button: {label: 'Cell'}}, [
+                    new EditorButton(cellProperties),
+                    new EditorButton(mergeCells),
+                    new EditorButton(splitCell),
+                ]),
                 new EditorButton(deleteTableMenuAction),
             ]),