]> BookStack Code Mirror - bookstack/commitdiff
Editors: Added lexical editor for testing
authorDan Brown <redacted>
Mon, 27 May 2024 14:39:41 +0000 (15:39 +0100)
committerDan Brown <redacted>
Mon, 27 May 2024 14:39:41 +0000 (15:39 +0100)
Started basic playground for testing lexical as a new WYSIWYG editor.
Moved out tinymce to be under wysiwyg-tinymce instead so lexical is the
default, but TinyMce code remains.

32 files changed:
dev/build/esbuild.js
package-lock.json
package.json
resources/js/components/index.js
resources/js/components/page-comment.js
resources/js/components/page-comments.js
resources/js/components/wysiwyg-editor-tinymce.js [new file with mode: 0644]
resources/js/components/wysiwyg-editor.js
resources/js/components/wysiwyg-input.js
resources/js/wysiwyg-tinymce/common-events.js [moved from resources/js/wysiwyg/common-events.js with 100% similarity]
resources/js/wysiwyg-tinymce/config.js [moved from resources/js/wysiwyg/config.js with 100% similarity]
resources/js/wysiwyg-tinymce/drop-paste-handling.js [moved from resources/js/wysiwyg/drop-paste-handling.js with 100% similarity]
resources/js/wysiwyg-tinymce/filters.js [moved from resources/js/wysiwyg/filters.js with 100% similarity]
resources/js/wysiwyg-tinymce/fixes.js [moved from resources/js/wysiwyg/fixes.js with 100% similarity]
resources/js/wysiwyg-tinymce/icons.js [moved from resources/js/wysiwyg/icons.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugin-codeeditor.js [moved from resources/js/wysiwyg/plugin-codeeditor.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugin-drawio.js [moved from resources/js/wysiwyg/plugin-drawio.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-about.js [moved from resources/js/wysiwyg/plugins-about.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-customhr.js [moved from resources/js/wysiwyg/plugins-customhr.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-details.js [moved from resources/js/wysiwyg/plugins-details.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-imagemanager.js [moved from resources/js/wysiwyg/plugins-imagemanager.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-stub.js [moved from resources/js/wysiwyg/plugins-stub.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-table-additions.js [moved from resources/js/wysiwyg/plugins-table-additions.js with 100% similarity]
resources/js/wysiwyg-tinymce/plugins-tasklist.js [moved from resources/js/wysiwyg/plugins-tasklist.js with 100% similarity]
resources/js/wysiwyg-tinymce/scrolling.js [moved from resources/js/wysiwyg/scrolling.js with 100% similarity]
resources/js/wysiwyg-tinymce/shortcuts.js [moved from resources/js/wysiwyg/shortcuts.js with 100% similarity]
resources/js/wysiwyg-tinymce/toolbars.js [moved from resources/js/wysiwyg/toolbars.js with 100% similarity]
resources/js/wysiwyg-tinymce/util.js [moved from resources/js/wysiwyg/util.js with 100% similarity]
resources/js/wysiwyg/index.mjs [new file with mode: 0644]
resources/views/pages/parts/form.blade.php
resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php [new file with mode: 0644]
resources/views/pages/parts/wysiwyg-editor.blade.php

index c5b3c9ef3a33ebb3411dc74257b68bf7be5bf6cf..20193db7fa94e7a9210d9cef1065d85ac7a691cf 100644 (file)
@@ -14,6 +14,7 @@ const entryPoints = {
     code: path.join(__dirname, '../../resources/js/code/index.mjs'),
     'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
     markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
+    wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.mjs'),
 };
 
 // Locate our output directory
index 63b0d2478e6fb9c749f78fa2805c5a2b42106429..6a992f4d0e04bec05a73633e7773bb5511547c38 100644 (file)
         "@codemirror/state": "^6.3.3",
         "@codemirror/theme-one-dark": "^6.1.2",
         "@codemirror/view": "^6.22.2",
+        "@lexical/history": "^0.15.0",
+        "@lexical/html": "^0.15.0",
+        "@lexical/rich-text": "^0.15.0",
+        "@lexical/utils": "^0.15.0",
         "@lezer/highlight": "^1.2.0",
         "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
         "@ssddanbrown/codemirror-lang-twig": "^1.0.0",
         "codemirror": "^6.0.1",
         "idb-keyval": "^6.2.1",
+        "lexical": "^0.15.0",
         "markdown-it": "^14.1.0",
         "markdown-it-task-lists": "^2.1.1",
         "snabbdom": "^3.5.1",
       "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
       "dev": true
     },
+    "node_modules/@lexical/clipboard": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.15.0.tgz",
+      "integrity": "sha512-binCltK7KiURQJFogvueYfmDNEKynN/lmZrCLFp2xBjEIajqw4WtOVLJZ33engdqNlvj0JqrxrWxbKG+yvUwrg==",
+      "dependencies": {
+        "@lexical/html": "0.15.0",
+        "@lexical/list": "0.15.0",
+        "@lexical/selection": "0.15.0",
+        "@lexical/utils": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
+    "node_modules/@lexical/history": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.15.0.tgz",
+      "integrity": "sha512-r+pzR2k/51AL6l8UfXeVe/GWPIeWY1kEOuKx9nsYB9tmAkTF66tTFz33DJIMWBVtAHWN7Dcdv0/yy6q8R6CAUQ==",
+      "dependencies": {
+        "@lexical/utils": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
+    "node_modules/@lexical/html": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.15.0.tgz",
+      "integrity": "sha512-x/sfGvibwo8b5Vso4ppqNyS/fVve6Rn+TmvP/0eWOaa0I3aOQ57ulfcK6p/GTe+ZaEi8vW64oZPdi8XDgwSRaA==",
+      "dependencies": {
+        "@lexical/selection": "0.15.0",
+        "@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",
+      "integrity": "sha512-JuF4k7uo4rZFOSZGrmkxo1+sUrwTKNBhhJAiCgtM+6TO90jppxzCFNKur81yPzF1+g4GWLC9gbjzKb52QPb6cQ==",
+      "dependencies": {
+        "@lexical/utils": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
+    "node_modules/@lexical/rich-text": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.15.0.tgz",
+      "integrity": "sha512-76tXh/eeEOHl91HpFEXCc/tUiLrsa9RcSyvCzRZahk5zqYvQPXma/AUfRzuSMf2kLwDEoauKAVqNFQcbPhqwpQ==",
+      "dependencies": {
+        "@lexical/clipboard": "0.15.0",
+        "@lexical/selection": "0.15.0",
+        "@lexical/utils": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
+    "node_modules/@lexical/selection": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.15.0.tgz",
+      "integrity": "sha512-S+AQC6eJiQYSa5zOPuecN85prCT0Bcb8miOdJaE17Zh+vgdUH5gk9I0tEBeG5T7tkSpq6lFiEqs2FZSfaHflbQ==",
+      "dependencies": {
+        "lexical": "0.15.0"
+      }
+    },
+    "node_modules/@lexical/table": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.15.0.tgz",
+      "integrity": "sha512-3IRBg8IoIHetqKozRQbJQ2aPyG0ziXZ+lc8TOIAGs6METW/wxntaV+rTNrODanKAgvk2iJTIyfFkYjsqS9+VFg==",
+      "dependencies": {
+        "@lexical/utils": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
+    "node_modules/@lexical/utils": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.15.0.tgz",
+      "integrity": "sha512-/6954LDmTcVFgexhy5WOZDa4TxNQOEZNrf8z7TRAFiAQkihcME/GRoq1en5cbXoVNF8jv5AvNyyc7x0MByRJ6A==",
+      "dependencies": {
+        "@lexical/list": "0.15.0",
+        "@lexical/selection": "0.15.0",
+        "@lexical/table": "0.15.0",
+        "lexical": "0.15.0"
+      }
+    },
     "node_modules/@lezer/common": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/lexical": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.15.0.tgz",
+      "integrity": "sha512-/7HrPAmtgsc1F+qpv5bFwoQZ6CbH/w3mPPL2AW5P75/QYrqKz4bhvJrc2jozIX0GxtuT/YUYT7w+1sZMtUWbOg=="
+    },
     "node_modules/linkify-it": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
index ba2de38ba3c9798e2143caa2b2ad12e57b484b07..706c187382ed9759e0ea6c4874314e29215c7626 100644 (file)
     "@codemirror/state": "^6.3.3",
     "@codemirror/theme-one-dark": "^6.1.2",
     "@codemirror/view": "^6.22.2",
+    "@lexical/history": "^0.15.0",
+    "@lexical/html": "^0.15.0",
+    "@lexical/rich-text": "^0.15.0",
+    "@lexical/utils": "^0.15.0",
     "@lezer/highlight": "^1.2.0",
     "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
     "@ssddanbrown/codemirror-lang-twig": "^1.0.0",
     "codemirror": "^6.0.1",
     "idb-keyval": "^6.2.1",
+    "lexical": "^0.15.0",
     "markdown-it": "^14.1.0",
     "markdown-it-task-lists": "^2.1.1",
     "snabbdom": "^3.5.1",
index 3a66079d7f8345d81cf4cd543fe55ef5c5745472..8ad5e14cb2e41b6c89570c6b11714a819589f181 100644 (file)
@@ -58,4 +58,5 @@ export {TriLayout} from './tri-layout';
 export {UserSelect} from './user-select';
 export {WebhookEvents} from './webhook-events';
 export {WysiwygEditor} from './wysiwyg-editor';
+export {WysiwygEditorTinymce} from './wysiwyg-editor-tinymce';
 export {WysiwygInput} from './wysiwyg-input';
index 79c9d3c2c673cd6b405eca30ba4d6ae83e1cef75..cac20c9fb2c6351ffaccf1cc6a31d486680b4c43 100644 (file)
@@ -1,6 +1,6 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
-import {buildForInput} from '../wysiwyg/config';
+import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class PageComment extends Component {
 
index cfb0634a904c3500a58ce6bc017f355d57f3d75a..bd6dd3c82ff2673f2480904acef7d3e87d7d694f 100644 (file)
@@ -1,6 +1,6 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
-import {buildForInput} from '../wysiwyg/config';
+import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class PageComments extends Component {
 
diff --git a/resources/js/components/wysiwyg-editor-tinymce.js b/resources/js/components/wysiwyg-editor-tinymce.js
new file mode 100644 (file)
index 0000000..093442e
--- /dev/null
@@ -0,0 +1,48 @@
+import {buildForEditor as buildEditorConfig} from '../wysiwyg-tinymce/config';
+import {Component} from './component';
+
+export class WysiwygEditorTinymce extends Component {
+
+    setup() {
+        this.elem = this.$el;
+
+        this.tinyMceConfig = buildEditorConfig({
+            language: this.$opts.language,
+            containerElement: this.elem,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.$opts.textDirection,
+            drawioUrl: this.getDrawIoUrl(),
+            pageId: Number(this.$opts.pageId),
+            translations: {
+                imageUploadErrorText: this.$opts.imageUploadErrorText,
+                serverUploadLimitText: this.$opts.serverUploadLimitText,
+            },
+            translationMap: window.editor_translations,
+        });
+
+        window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
+        window.tinymce.init(this.tinyMceConfig).then(editors => {
+            this.editor = editors[0];
+        });
+    }
+
+    getDrawIoUrl() {
+        const drawioUrlElem = document.querySelector('[drawio-url]');
+        if (drawioUrlElem) {
+            return drawioUrlElem.getAttribute('drawio-url');
+        }
+        return '';
+    }
+
+    /**
+     * Get the content of this editor.
+     * Used by the parent page editor component.
+     * @return {{html: String}}
+     */
+    getContent() {
+        return {
+            html: this.editor.getContent(),
+        };
+    }
+
+}
index 82f60827d7af668637d3624d7bbd38c5658ce311..bcd480ce6abea3b28ed921c1360b1c35cc723926 100644 (file)
@@ -1,28 +1,13 @@
-import {buildForEditor as buildEditorConfig} from '../wysiwyg/config';
 import {Component} from './component';
 
 export class WysiwygEditor extends Component {
 
     setup() {
         this.elem = this.$el;
+        this.editArea = this.$refs.editArea;
 
-        this.tinyMceConfig = buildEditorConfig({
-            language: this.$opts.language,
-            containerElement: this.elem,
-            darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.$opts.textDirection,
-            drawioUrl: this.getDrawIoUrl(),
-            pageId: Number(this.$opts.pageId),
-            translations: {
-                imageUploadErrorText: this.$opts.imageUploadErrorText,
-                serverUploadLimitText: this.$opts.serverUploadLimitText,
-            },
-            translationMap: window.editor_translations,
-        });
-
-        window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
-        window.tinymce.init(this.tinyMceConfig).then(editors => {
-            this.editor = editors[0];
+        window.importVersioned('wysiwyg').then(wysiwyg => {
+            wysiwyg.createPageEditorInstance(this.editArea);
         });
     }
 
index ad964aed2c4656fbca7bd4e6bee57c0e344e127c..aa21a63717f79ed42679d130a1df2c53ac90c236 100644 (file)
@@ -1,5 +1,5 @@
 import {Component} from './component';
-import {buildForInput} from '../wysiwyg/config';
+import {buildForInput} from '../wysiwyg-tinymce/config';
 
 export class WysiwygInput extends Component {
 
diff --git a/resources/js/wysiwyg/index.mjs b/resources/js/wysiwyg/index.mjs
new file mode 100644 (file)
index 0000000..4c4f16c
--- /dev/null
@@ -0,0 +1,109 @@
+import {$getRoot, createEditor, ElementNode} from 'lexical';
+import {createEmptyHistoryState, registerHistory} from '@lexical/history';
+import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text';
+import {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
+
+export function createPageEditorInstance(editArea) {
+    console.log('creating editor', editArea);
+
+    const config = {
+        namespace: 'BookStackPageEditor',
+        nodes: [HeadingNode, QuoteNode, CalloutParagraph],
+        onError: console.error,
+    };
+
+    const startingHtml = editArea.innerHTML;
+    const parser = new DOMParser();
+    const dom = parser.parseFromString(startingHtml, 'text/html');
+
+    const editor = createEditor(config);
+    editor.setRootElement(editArea);
+
+    mergeRegister(
+        registerRichText(editor),
+        registerHistory(editor, createEmptyHistoryState(), 300),
+    );
+
+    editor.update(() => {
+        const startingNodes = $generateNodesFromDOM(editor, dom);
+        const root = $getRoot();
+        root.append(...startingNodes);
+    });
+
+    const debugView = document.getElementById('lexical-debug');
+    editor.registerUpdateListener(({editorState}) => {
+        console.log('editorState', editorState.toJSON());
+        debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
+    });
+}
\ No newline at end of file
index e2c839cd220254de6fce6878b7079d32bc9c92d6..490374e40c4f2f611909778d285b845e82b39751 100644 (file)
                 {{--Editors--}}
                 <div class="edit-area flex-fill flex">
 
-                    {{--WYSIWYG Editor--}}
                     @if($editor === 'wysiwyg')
                         @include('pages.parts.wysiwyg-editor', ['model' => $model])
                     @endif
 
+                    {{--WYSIWYG Editor (TinyMCE - Deprecated)--}}
+                    @if($editor === 'wysiwyg-tinymce')
+                        @include('pages.parts.wysiwyg-editor-tinymce', ['model' => $model])
+                    @endif
+
                     {{--Markdown Editor--}}
                     @if($editor === 'markdown')
                         @include('pages.parts.markdown-editor', ['model' => $model])
diff --git a/resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php b/resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php
new file mode 100644 (file)
index 0000000..33c526a
--- /dev/null
@@ -0,0 +1,21 @@
+@push('head')
+    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
+
+<div component="wysiwyg-editor-tinymce"
+     option:wysiwyg-editor-tinymce:language="{{ $locale->htmlLang() }}"
+     option:wysiwyg-editor-tinymce:page-id="{{ $model->id ?? 0 }}"
+     option:wysiwyg-editor-tinymce:text-direction="{{ $locale->htmlDirection() }}"
+     option:wysiwyg-editor-tinymce:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
+     option:wysiwyg-editor-tinymce:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
+     class="flex-fill flex">
+
+    <textarea id="html-editor"  name="html" rows="5"
+          @if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>
+</div>
+
+@if($errors->has('html'))
+    <div class="text-neg text-small">{{ $errors->first('html') }}</div>
+@endif
+
+@include('form.editor-translations')
\ No newline at end of file
index 84a267b681b9b6a257d664eaaf460669d383ec93..7528b1e02a708b1d8b1f5c3d18b08b77ce621749 100644 (file)
@@ -1,21 +1,31 @@
-@push('head')
-    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
-@endpush
-
 <div component="wysiwyg-editor"
      option:wysiwyg-editor:language="{{ $locale->htmlLang() }}"
      option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
      option:wysiwyg-editor:text-direction="{{ $locale->htmlDirection() }}"
      option:wysiwyg-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
      option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
-     class="flex-fill flex">
+     class="">
+
+    <div refs="wysiwyg-editor@edit-area" contenteditable="true">
+        <p>Some content here</p>
+        <h2>List below this h2 header</h2>
+        <ul>
+            <li>Hello</li>
+        </ul>
+
+        <p class="callout danger">
+            Hello there, this is an info callout
+        </p>
+    </div>
+
+    <div id="lexical-debug" style="white-space: pre-wrap; font-size: 12px; height: 200px; overflow-y: scroll; background-color: #000; padding: 1rem; border-radius: 4px; color: #FFF;"></div>
 
-    <textarea id="html-editor"  name="html" rows="5"
-          @if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>
+{{--    <textarea id="html-editor"  name="html" rows="5"--}}
+{{--          @if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>--}}
 </div>
 
 @if($errors->has('html'))
     <div class="text-neg text-small">{{ $errors->first('html') }}</div>
 @endif
 
-@include('form.editor-translations')
\ No newline at end of file
+{{--TODO - @include('form.editor-translations')--}}
\ No newline at end of file