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.
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
"@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",
"@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",
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';
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 {
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 {
--- /dev/null
+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(),
+ };
+ }
+
+}
-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);
});
}
import {Component} from './component';
-import {buildForInput} from '../wysiwyg/config';
+import {buildForInput} from '../wysiwyg-tinymce/config';
export class WysiwygInput extends Component {
--- /dev/null
+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
{{--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])
--- /dev/null
+@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
-@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