From 6e852d2e652e881e5f0096efa2b35ae3d712b4a3 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 27 May 2024 20:23:45 +0100
Subject: [PATCH] Lexical: Played with commands, extracted & improved callout
 node

---
 package-lock.json                             |   1 +
 package.json                                  |   1 +
 resources/js/components/wysiwyg-editor.js     |   1 +
 resources/js/wysiwyg/index.mjs                | 113 ++++++------------
 resources/js/wysiwyg/nodes/callout.js         |  98 +++++++++++++++
 resources/js/wysiwyg/nodes/index.js           |  14 +++
 .../pages/parts/wysiwyg-editor.blade.php      |   4 +
 7 files changed, 156 insertions(+), 76 deletions(-)
 create mode 100644 resources/js/wysiwyg/nodes/callout.js
 create mode 100644 resources/js/wysiwyg/nodes/index.js

diff --git a/package-lock.json b/package-lock.json
index 6a992f4d0..6cd32760d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
         "@lexical/history": "^0.15.0",
         "@lexical/html": "^0.15.0",
         "@lexical/rich-text": "^0.15.0",
+        "@lexical/selection": "^0.15.0",
         "@lexical/utils": "^0.15.0",
         "@lezer/highlight": "^1.2.0",
         "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
diff --git a/package.json b/package.json
index 706c18738..42b86fdc7 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
     "@lexical/history": "^0.15.0",
     "@lexical/html": "^0.15.0",
     "@lexical/rich-text": "^0.15.0",
+    "@lexical/selection": "^0.15.0",
     "@lexical/utils": "^0.15.0",
     "@lezer/highlight": "^1.2.0",
     "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js
index bcd480ce6..98732dab7 100644
--- a/resources/js/components/wysiwyg-editor.js
+++ b/resources/js/components/wysiwyg-editor.js
@@ -25,6 +25,7 @@ export class WysiwygEditor extends Component {
      * @return {{html: String}}
      */
     getContent() {
+        // TODO - Update
         return {
             html: this.editor.getContent(),
         };
diff --git a/resources/js/wysiwyg/index.mjs b/resources/js/wysiwyg/index.mjs
index 4c4f16ce3..decfa4f22 100644
--- a/resources/js/wysiwyg/index.mjs
+++ b/resources/js/wysiwyg/index.mjs
@@ -1,85 +1,23 @@
-import {$getRoot, createEditor, ElementNode} from 'lexical';
+import {
+    $createParagraphNode,
+    $getRoot,
+    $getSelection,
+    COMMAND_PRIORITY_LOW,
+    createCommand,
+    createEditor
+} from 'lexical';
 import {createEmptyHistoryState, registerHistory} from '@lexical/history';
-import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text';
-import {mergeRegister} from '@lexical/utils';
+import {registerRichText} from '@lexical/rich-text';
+import {$getNearestBlockElementAncestorOrThrow, mergeRegister} from '@lexical/utils';
 import {$generateNodesFromDOM} from '@lexical/html';
-
-class CalloutParagraph extends ElementNode {
-    __category = 'info';
-
-    static getType() {
-        return 'callout';
-    }
-
-    static clone(node) {
-        return new CalloutParagraph(node.__category, node.__key);
-    }
-
-    constructor(category, key) {
-        super(key);
-        this.__category = category;
-    }
-
-    createDOM(_config, _editor) {
-        const dom = document.createElement('p');
-        dom.classList.add('callout', this.__category || '');
-        return dom;
-    }
-
-    updateDOM(prevNode, dom) {
-        // Returning false tells Lexical that this node does not need its
-        // DOM element replacing with a new copy from createDOM.
-        return false;
-    }
-
-    static importDOM() {
-        return {
-            p: node => {
-                if (node.classList.contains('callout')) {
-                    return {
-                        conversion: element => {
-                            let category = 'info';
-                            const categories = ['info', 'success', 'warning', 'danger'];
-
-                            for (const c of categories) {
-                                if (element.classList.contains(c)) {
-                                    category = c;
-                                    break;
-                                }
-                            }
-
-                            return {
-                                node: new CalloutParagraph(category),
-                            };
-                        },
-                        priority: 3,
-                    }
-                }
-                return null;
-            }
-        }
-    }
-
-    exportJSON() {
-        return {
-            ...super.exportJSON(),
-            type: 'callout',
-            version: 1,
-            category: this.__category,
-        };
-    }
-}
-
-// TODO - Extract callout to own file
-// TODO - Add helper functions
-//   https://lexical.dev/docs/concepts/nodes#creating-custom-nodes
+import {getNodesForPageEditor} from "./nodes/index.js";
+import {$createCalloutNode, $isCalloutNode} from "./nodes/callout.js";
+import {$setBlocksType} from "@lexical/selection";
 
 export function createPageEditorInstance(editArea) {
-    console.log('creating editor', editArea);
-
     const config = {
         namespace: 'BookStackPageEditor',
-        nodes: [HeadingNode, QuoteNode, CalloutParagraph],
+        nodes: getNodesForPageEditor(),
         onError: console.error,
     };
 
@@ -106,4 +44,27 @@ export function createPageEditorInstance(editArea) {
         console.log('editorState', editorState.toJSON());
         debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
     });
+
+    // Todo - How can we store things like IDs and alignment?
+    //   Node overrides?
+    //   https://lexical.dev/docs/concepts/node-replacement
+
+    // Example of creating, registering and using a custom command
+
+    const SET_BLOCK_CALLOUT_COMMAND = createCommand();
+    editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category = 'info') => {
+        const selection = $getSelection();
+        const blockElement = $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]);
+        if ($isCalloutNode(blockElement)) {
+            $setBlocksType(selection, $createParagraphNode);
+        } else {
+            $setBlocksType(selection, () => $createCalloutNode(category));
+        }
+        return true;
+    }, COMMAND_PRIORITY_LOW);
+
+    const button = document.getElementById('lexical-button');
+    button.addEventListener('click', event => {
+        editor.dispatchCommand(SET_BLOCK_CALLOUT_COMMAND, 'info');
+    });
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/nodes/callout.js b/resources/js/wysiwyg/nodes/callout.js
new file mode 100644
index 000000000..db90f22a9
--- /dev/null
+++ b/resources/js/wysiwyg/nodes/callout.js
@@ -0,0 +1,98 @@
+import {$createParagraphNode, ElementNode} from 'lexical';
+
+export class Callout extends ElementNode {
+
+    __category = 'info';
+
+    static getType() {
+        return 'callout';
+    }
+
+    static clone(node) {
+        return new Callout(node.__category, node.__key);
+    }
+
+    constructor(category, key) {
+        super(key);
+        this.__category = category;
+    }
+
+    createDOM(_config, _editor) {
+        const element = document.createElement('p');
+        element.classList.add('callout', this.__category || '');
+        return element;
+    }
+
+    updateDOM(prevNode, dom) {
+        // Returning false tells Lexical that this node does not need its
+        // DOM element replacing with a new copy from createDOM.
+        return false;
+    }
+
+    insertNewAfter(selection, restoreSelection) {
+        const anchorOffset = selection ? selection.anchor.offset : 0;
+        const newElement = anchorOffset === this.getTextContentSize() || !selection
+            ? $createParagraphNode() : $createCalloutNode(this.__category);
+
+        newElement.setDirection(this.getDirection());
+        this.insertAfter(newElement, restoreSelection);
+
+        if (anchorOffset === 0 && !this.isEmpty() && selection) {
+            const paragraph = $createParagraphNode();
+            paragraph.select();
+            this.replace(paragraph, true);
+        }
+
+        return newElement;
+    }
+
+    static importDOM() {
+        return {
+            p: node => {
+                if (node.classList.contains('callout')) {
+                    return {
+                        conversion: element => {
+                            let category = 'info';
+                            const categories = ['info', 'success', 'warning', 'danger'];
+
+                            for (const c of categories) {
+                                if (element.classList.contains(c)) {
+                                    category = c;
+                                    break;
+                                }
+                            }
+
+                            return {
+                                node: new Callout(category),
+                            };
+                        },
+                        priority: 3,
+                    };
+                }
+                return null;
+            },
+        };
+    }
+
+    exportJSON() {
+        return {
+            ...super.exportJSON(),
+            type: 'callout',
+            version: 1,
+            category: this.__category,
+        };
+    }
+
+    static importJSON(serializedNode) {
+        return $createCalloutNode(serializedNode.category);
+    }
+
+}
+
+export function $createCalloutNode(category = 'info') {
+    return new Callout(category);
+}
+
+export function $isCalloutNode(node) {
+    return node instanceof Callout;
+}
diff --git a/resources/js/wysiwyg/nodes/index.js b/resources/js/wysiwyg/nodes/index.js
new file mode 100644
index 000000000..ada229d9e
--- /dev/null
+++ b/resources/js/wysiwyg/nodes/index.js
@@ -0,0 +1,14 @@
+import {HeadingNode, QuoteNode} from '@lexical/rich-text';
+import {Callout} from './callout';
+
+/**
+ * Load the nodes for lexical.
+ * @returns {LexicalNode[]}
+ */
+export function getNodesForPageEditor() {
+    return [
+        Callout,
+        HeadingNode,
+        QuoteNode,
+    ];
+}
diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php
index 7528b1e02..30be1a214 100644
--- a/resources/views/pages/parts/wysiwyg-editor.blade.php
+++ b/resources/views/pages/parts/wysiwyg-editor.blade.php
@@ -6,6 +6,10 @@
      option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
      class="">
 
+    <div>
+        <button type="button" id="lexical-button">Callout</button>
+    </div>
+
     <div refs="wysiwyg-editor@edit-area" contenteditable="true">
         <p>Some content here</p>
         <h2>List below this h2 header</h2>