From dc1a40ea7465277ae82027e07926e230549c89f0 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 29 May 2024 20:38:31 +0100
Subject: [PATCH] Lexical: Added ui container type

Structured UI logical to be fairly standard and mostly covered via
a base class that handles context and core dom work.
---
 package-lock.json                             | 10 +++
 package.json                                  |  1 +
 resources/js/wysiwyg/helpers.ts               | 20 +++++-
 resources/js/wysiwyg/nodes/index.ts           |  4 +-
 .../button-definitions.ts}                    | 65 ++++++++++++-------
 resources/js/wysiwyg/ui/editor-button.ts      | 45 -------------
 .../js/wysiwyg/ui/framework/base-elements.ts  | 39 +++++++++++
 resources/js/wysiwyg/ui/framework/buttons.ts  | 40 ++++++++++++
 .../js/wysiwyg/ui/framework/containers.ts     | 40 ++++++++++++
 resources/js/wysiwyg/ui/index.ts              | 42 ++----------
 resources/js/wysiwyg/ui/toolbars.ts           | 43 ++++++++++++
 .../pages/parts/wysiwyg-editor.blade.php      |  1 +
 12 files changed, 240 insertions(+), 110 deletions(-)
 rename resources/js/wysiwyg/ui/{buttons.ts => defaults/button-definitions.ts} (57%)
 delete mode 100644 resources/js/wysiwyg/ui/editor-button.ts
 create mode 100644 resources/js/wysiwyg/ui/framework/base-elements.ts
 create mode 100644 resources/js/wysiwyg/ui/framework/buttons.ts
 create mode 100644 resources/js/wysiwyg/ui/framework/containers.ts
 create mode 100644 resources/js/wysiwyg/ui/toolbars.ts

diff --git a/package-lock.json b/package-lock.json
index 2b6b677c2..2cddccb59 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
         "@codemirror/view": "^6.22.2",
         "@lexical/history": "^0.15.0",
         "@lexical/html": "^0.15.0",
+        "@lexical/link": "^0.15.0",
         "@lexical/rich-text": "^0.15.0",
         "@lexical/selection": "^0.15.0",
         "@lexical/utils": "^0.15.0",
@@ -729,6 +730,15 @@
         "lexical": "0.15.0"
       }
     },
+    "node_modules/@lexical/link": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.15.0.tgz",
+      "integrity": "sha512-KBV/zWk5FxqZGNcq3IKGBDCcS4t0uteU1osAIG+pefo4waTkOOgibxxEJDop2QR5wtjkYva3Qp0D8ZyJDMMMlw==",
+      "dependencies": {
+        "@lexical/utils": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
     "node_modules/@lexical/list": {
       "version": "0.15.0",
       "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.15.0.tgz",
diff --git a/package.json b/package.json
index ca0f01f17..d9fa89c18 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
     "@codemirror/view": "^6.22.2",
     "@lexical/history": "^0.15.0",
     "@lexical/html": "^0.15.0",
+    "@lexical/link": "^0.15.0",
     "@lexical/rich-text": "^0.15.0",
     "@lexical/selection": "^0.15.0",
     "@lexical/utils": "^0.15.0",
diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts
index 737666ffa..7218f1ae6 100644
--- a/resources/js/wysiwyg/helpers.ts
+++ b/resources/js/wysiwyg/helpers.ts
@@ -3,13 +3,29 @@ import {
     $getSelection,
     $isTextNode,
     BaseSelection,
-    ElementFormatType,
     LexicalEditor, TextFormatType
 } from "lexical";
 import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
 import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
 import {$setBlocksType} from "@lexical/selection";
-import {TextNodeThemeClasses} from "lexical/LexicalEditor";
+
+export function el(tag: string, attrs: Record<string, string> = {}, children: (string|HTMLElement)[] = []): HTMLElement {
+    const el = document.createElement(tag);
+    const attrKeys = Object.keys(attrs);
+    for (const attr of attrKeys) {
+        el.setAttribute(attr, attrs[attr]);
+    }
+
+    for (const child of children) {
+        if (typeof child === 'string') {
+            el.append(document.createTextNode(child));
+        } else {
+            el.append(child);
+        }
+    }
+
+    return el;
+}
 
 export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean {
     if (!selection) {
diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts
index ffe1b027f..9f772df1e 100644
--- a/resources/js/wysiwyg/nodes/index.ts
+++ b/resources/js/wysiwyg/nodes/index.ts
@@ -2,6 +2,7 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text';
 import {CalloutNode} from './callout';
 import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical";
 import {CustomParagraphNode} from "./custom-paragraph";
+import {LinkNode} from "@lexical/link";
 
 /**
  * Load the nodes for lexical.
@@ -17,7 +18,8 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
             with: (node: ParagraphNode) => {
                 return new CustomParagraphNode();
             }
-        }
+        },
+        LinkNode,
     ];
 }
 
diff --git a/resources/js/wysiwyg/ui/buttons.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts
similarity index 57%
rename from resources/js/wysiwyg/ui/buttons.ts
rename to resources/js/wysiwyg/ui/defaults/button-definitions.ts
index cf5660ef0..874f632fe 100644
--- a/resources/js/wysiwyg/ui/buttons.ts
+++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts
@@ -1,4 +1,4 @@
-import {EditorButtonDefinition} from "./editor-button";
+import {EditorButtonDefinition} from "../framework/buttons";
 import {
     $createParagraphNode,
     $isParagraphNode,
@@ -8,8 +8,8 @@ import {
     REDO_COMMAND, TextFormatType,
     UNDO_COMMAND
 } from "lexical";
-import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../helpers";
-import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../nodes/callout";
+import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../../helpers";
+import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout";
 import {
     $createHeadingNode,
     $createQuoteNode,
@@ -18,21 +18,22 @@ import {
     HeadingNode,
     HeadingTagType
 } from "@lexical/rich-text";
+import {$isLinkNode, $toggleLink} from "@lexical/link";
 
-export const undoButton: EditorButtonDefinition = {
+export const undo: EditorButtonDefinition = {
     label: 'Undo',
     action(editor: LexicalEditor) {
-        editor.dispatchCommand(UNDO_COMMAND);
+        editor.dispatchCommand(UNDO_COMMAND, undefined);
     },
     isActive(selection: BaseSelection|null): boolean {
         return false;
     }
 }
 
-export const redoButton: EditorButtonDefinition = {
+export const redo: EditorButtonDefinition = {
     label: 'Redo',
     action(editor: LexicalEditor) {
-        editor.dispatchCommand(REDO_COMMAND);
+        editor.dispatchCommand(REDO_COMMAND, undefined);
     },
     isActive(selection: BaseSelection|null): boolean {
         return false;
@@ -55,10 +56,10 @@ function buildCalloutButton(category: CalloutCategory, name: string): EditorButt
     };
 }
 
-export const infoCalloutButton: EditorButtonDefinition = buildCalloutButton('info', 'Info');
-export const dangerCalloutButton: EditorButtonDefinition = buildCalloutButton('danger', 'Danger');
-export const warningCalloutButton: EditorButtonDefinition = buildCalloutButton('warning', 'Warning');
-export const successCalloutButton: EditorButtonDefinition = buildCalloutButton('success', 'Success');
+export const infoCallout: EditorButtonDefinition = buildCalloutButton('info', 'Info');
+export const dangerCallout: EditorButtonDefinition = buildCalloutButton('danger', 'Danger');
+export const warningCallout: EditorButtonDefinition = buildCalloutButton('warning', 'Warning');
+export const successCallout: EditorButtonDefinition = buildCalloutButton('success', 'Success');
 
 const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
       return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag;
@@ -80,12 +81,12 @@ function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefin
     };
 }
 
-export const h2Button: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header');
-export const h3Button: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header');
-export const h4Button: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header');
-export const h5Button: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header');
+export const h2: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header');
+export const h3: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header');
+export const h4: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header');
+export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header');
 
-export const blockquoteButton: EditorButtonDefinition = {
+export const blockquote: EditorButtonDefinition = {
     label: 'Blockquote',
     action(editor: LexicalEditor) {
         toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode);
@@ -95,7 +96,7 @@ export const blockquoteButton: EditorButtonDefinition = {
     }
 };
 
-export const paragraphButton: EditorButtonDefinition = {
+export const paragraph: EditorButtonDefinition = {
     label: 'Paragraph',
     action(editor: LexicalEditor) {
         toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode);
@@ -117,13 +118,27 @@ function buildFormatButton(label: string, format: TextFormatType): EditorButtonD
     };
 }
 
-export const boldButton: EditorButtonDefinition = buildFormatButton('Bold', 'bold');
-export const italicButton: EditorButtonDefinition = buildFormatButton('Italic', 'italic');
-export const underlineButton: EditorButtonDefinition = buildFormatButton('Underline', 'underline');
+export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold');
+export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic');
+export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline');
 // Todo - Text color
 // Todo - Highlight color
-export const strikethroughButton: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough');
-export const superscriptButton: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript');
-export const subscriptButton: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript');
-export const codeButton: EditorButtonDefinition = buildFormatButton('Inline Code', 'code');
-// Todo - Clear formatting
\ No newline at end of file
+export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough');
+export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript');
+export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript');
+export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code');
+// Todo - Clear formatting
+
+
+export const link: EditorButtonDefinition = {
+    label: 'Insert/edit link',
+    action(editor: LexicalEditor) {
+        editor.update(() => {
+            $toggleLink('http://example.com');
+        })
+    },
+    isActive(selection: BaseSelection|null): boolean {
+        return selectionContainsNodeType(selection, $isLinkNode);
+    }
+};
+
diff --git a/resources/js/wysiwyg/ui/editor-button.ts b/resources/js/wysiwyg/ui/editor-button.ts
deleted file mode 100644
index 2ce272fce..000000000
--- a/resources/js/wysiwyg/ui/editor-button.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import {BaseSelection, LexicalEditor} from "lexical";
-
-export interface EditorButtonDefinition {
-    label: string;
-    action: (editor: LexicalEditor) => void;
-    isActive: (selection: BaseSelection|null) => boolean;
-}
-
-export class EditorButton {
-    #definition: EditorButtonDefinition;
-    #editor: LexicalEditor;
-    #dom: HTMLButtonElement;
-
-    constructor(definition: EditorButtonDefinition, editor: LexicalEditor) {
-        this.#definition = definition;
-        this.#editor = editor;
-        this.#dom = this.buildDOM();
-    }
-
-    private buildDOM(): HTMLButtonElement {
-        const button = document.createElement("button");
-        button.setAttribute('type', 'button');
-        button.textContent = this.#definition.label;
-        button.classList.add('editor-toolbar-button');
-
-        button.addEventListener('click', event => {
-            this.runAction();
-        });
-
-        return button;
-    }
-
-    getDOMElement(): HTMLButtonElement {
-        return this.#dom;
-    }
-
-    runAction() {
-        this.#definition.action(this.#editor);
-    }
-
-    updateActiveState(selection: BaseSelection|null) {
-        const isActive = this.#definition.isActive(selection);
-        this.#dom.classList.toggle('editor-toolbar-button-active', isActive);
-    }
-}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/base-elements.ts b/resources/js/wysiwyg/ui/framework/base-elements.ts
new file mode 100644
index 000000000..665011782
--- /dev/null
+++ b/resources/js/wysiwyg/ui/framework/base-elements.ts
@@ -0,0 +1,39 @@
+import {BaseSelection, LexicalEditor} from "lexical";
+
+export type EditorUiStateUpdate = {
+    editor: LexicalEditor,
+    selection: BaseSelection|null,
+};
+
+export type EditorUiContext = {
+    editor: LexicalEditor,
+};
+
+export abstract class EditorUiElement {
+    protected dom: HTMLElement|null = null;
+    private context: EditorUiContext|null = null;
+
+    protected abstract buildDOM(): HTMLElement;
+
+    setContext(context: EditorUiContext): void {
+        this.context = context;
+    }
+
+    getContext(): EditorUiContext {
+        if (this.context === null) {
+            throw new Error('Attempted to use EditorUIContext before it has been set');
+        }
+
+        return this.context;
+    }
+
+    getDOMElement(): HTMLElement {
+        if (!this.dom) {
+            this.dom = this.buildDOM();
+        }
+
+        return this.dom;
+    }
+
+    abstract updateState(state: EditorUiStateUpdate): void;
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts
new file mode 100644
index 000000000..51c7d294d
--- /dev/null
+++ b/resources/js/wysiwyg/ui/framework/buttons.ts
@@ -0,0 +1,40 @@
+import {BaseSelection, LexicalEditor} from "lexical";
+import {EditorUiElement, EditorUiStateUpdate} from "./base-elements";
+import {el} from "../../helpers";
+
+export interface EditorButtonDefinition {
+    label: string;
+    action: (editor: LexicalEditor) => void;
+    isActive: (selection: BaseSelection|null) => boolean;
+}
+
+export class EditorButton extends EditorUiElement {
+    protected definition: EditorButtonDefinition;
+
+    constructor(definition: EditorButtonDefinition) {
+        super();
+        this.definition = definition;
+    }
+
+    protected buildDOM(): HTMLButtonElement {
+        const button = el('button', {
+            type: 'button',
+            class: 'editor-toolbar-button',
+        }, [this.definition.label]) as HTMLButtonElement;
+
+        button.addEventListener('click', event => {
+            this.definition.action(this.getContext().editor);
+        });
+
+        return button;
+    }
+
+    updateActiveState(selection: BaseSelection|null) {
+        const isActive = this.definition.isActive(selection);
+        this.dom?.classList.toggle('editor-toolbar-button-active', isActive);
+    }
+
+    updateState(state: EditorUiStateUpdate): void {
+        this.updateActiveState(state.selection);
+    }
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts
new file mode 100644
index 000000000..9ef59c72f
--- /dev/null
+++ b/resources/js/wysiwyg/ui/framework/containers.ts
@@ -0,0 +1,40 @@
+import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements";
+import {el} from "../../helpers";
+
+export class EditorContainerUiElement extends EditorUiElement {
+    protected children : EditorUiElement[];
+
+    constructor(children: EditorUiElement[]) {
+        super();
+        this.children = children;
+    }
+
+    protected buildDOM(): HTMLElement {
+        return el('div', {}, this.getChildren().map(child => child.getDOMElement()));
+    }
+
+    getChildren(): EditorUiElement[] {
+        return this.children;
+    }
+
+    updateState(state: EditorUiStateUpdate): void {
+        for (const child of this.children) {
+            child.updateState(state);
+        }
+    }
+
+    setContext(context: EditorUiContext) {
+        for (const child of this.getChildren()) {
+            child.setContext(context);
+        }
+    }
+}
+
+export class EditorFormatMenu extends EditorContainerUiElement {
+    buildDOM(): HTMLElement {
+        return el('div', {
+            class: 'editor-format-menu'
+        }, this.getChildren().map(child => child.getDOMElement()));
+    }
+
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts
index d04808fae..56ae9354a 100644
--- a/resources/js/wysiwyg/ui/index.ts
+++ b/resources/js/wysiwyg/ui/index.ts
@@ -4,49 +4,17 @@ import {
     LexicalEditor,
     SELECTION_CHANGE_COMMAND
 } from "lexical";
-import {EditorButton, EditorButtonDefinition} from "./editor-button";
-import {
-    blockquoteButton, boldButton, codeButton,
-    dangerCalloutButton,
-    h2Button,
-    h3Button, h4Button, h5Button,
-    infoCalloutButton, italicButton, paragraphButton, redoButton, strikethroughButton, subscriptButton,
-    successCalloutButton, superscriptButton, underlineButton, undoButton,
-    warningCalloutButton
-} from "./buttons";
-
-
-
-const toolbarButtonDefinitions: EditorButtonDefinition[] = [
-    undoButton, redoButton,
-
-    infoCalloutButton, warningCalloutButton, dangerCalloutButton, successCalloutButton,
-    h2Button, h3Button, h4Button, h5Button,
-    blockquoteButton, paragraphButton,
-
-    boldButton, italicButton, underlineButton, strikethroughButton,
-    superscriptButton, subscriptButton, codeButton,
-];
+import {getMainEditorFullToolbar} from "./toolbars";
 
 export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
-    const toolbarContainer = document.createElement('div');
-    toolbarContainer.classList.add('editor-toolbar-container');
-
-    const buttons = toolbarButtonDefinitions.map(definition => {
-        return new EditorButton(definition, editor);
-    });
-
-    const buttonElements = buttons.map(button => button.getDOMElement());
-
-    toolbarContainer.append(...buttonElements);
-    element.before(toolbarContainer);
+    const toolbar = getMainEditorFullToolbar();
+    toolbar.setContext({editor});
+    element.before(toolbar.getDOMElement());
 
     // Update button states on editor selection change
     editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
         const selection = $getSelection();
-        for (const button of buttons) {
-            button.updateActiveState(selection);
-        }
+        toolbar.updateState({editor, selection});
         return false;
     }, COMMAND_PRIORITY_LOW);
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts
new file mode 100644
index 000000000..0f46f5b2a
--- /dev/null
+++ b/resources/js/wysiwyg/ui/toolbars.ts
@@ -0,0 +1,43 @@
+import {EditorButton} from "./framework/buttons";
+import {
+    blockquote, bold, code,
+    dangerCallout,
+    h2, h3, h4, h5,
+    infoCallout, italic, link, paragraph,
+    redo, strikethrough, subscript,
+    successCallout, superscript, underline,
+    undo,
+    warningCallout
+} from "./defaults/button-definitions";
+import {EditorContainerUiElement, EditorFormatMenu} from "./framework/containers";
+
+
+export function getMainEditorFullToolbar(): EditorContainerUiElement {
+    return new EditorContainerUiElement([
+        new EditorButton(undo),
+        new EditorButton(redo),
+
+        new EditorFormatMenu([
+            new EditorButton(h2),
+            new EditorButton(h3),
+            new EditorButton(h4),
+            new EditorButton(h5),
+            new EditorButton(blockquote),
+            new EditorButton(paragraph),
+            new EditorButton(infoCallout),
+            new EditorButton(successCallout),
+            new EditorButton(warningCallout),
+            new EditorButton(dangerCallout),
+        ]),
+
+        new EditorButton(bold),
+        new EditorButton(italic),
+        new EditorButton(underline),
+        new EditorButton(strikethrough),
+        new EditorButton(superscript),
+        new EditorButton(subscript),
+        new EditorButton(code),
+
+        new EditorButton(link),
+    ]);
+}
\ No newline at end of file
diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php
index 90e42e576..b48e10570 100644
--- a/resources/views/pages/parts/wysiwyg-editor.blade.php
+++ b/resources/views/pages/parts/wysiwyg-editor.blade.php
@@ -14,6 +14,7 @@
 
     <div refs="wysiwyg-editor@edit-area" contenteditable="true">
         <p id="Content!">Some <strong>content</strong> here</p>
+        <p>This has a <a href="https://example.com" target="_blank" title="Link to example">link</a> in it</p>
         <h2>List below this h2 header</h2>
         <ul>
             <li>Hello</li>