From 5a4f59534124a83b9d7b54abe1d5c8984b2fa04f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 May 2024 15:39:41 +0100 Subject: [PATCH 001/107] Editors: Added lexical editor for testing 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. --- dev/build/esbuild.js | 1 + package-lock.json | 89 ++++++++++++++ package.json | 5 + resources/js/components/index.js | 1 + resources/js/components/page-comment.js | 2 +- resources/js/components/page-comments.js | 2 +- .../js/components/wysiwyg-editor-tinymce.js | 48 ++++++++ resources/js/components/wysiwyg-editor.js | 21 +--- resources/js/components/wysiwyg-input.js | 2 +- .../common-events.js | 0 .../js/{wysiwyg => wysiwyg-tinymce}/config.js | 0 .../drop-paste-handling.js | 0 .../{wysiwyg => wysiwyg-tinymce}/filters.js | 0 .../js/{wysiwyg => wysiwyg-tinymce}/fixes.js | 0 .../js/{wysiwyg => wysiwyg-tinymce}/icons.js | 0 .../plugin-codeeditor.js | 0 .../plugin-drawio.js | 0 .../plugins-about.js | 0 .../plugins-customhr.js | 0 .../plugins-details.js | 0 .../plugins-imagemanager.js | 0 .../plugins-stub.js | 0 .../plugins-table-additions.js | 0 .../plugins-tasklist.js | 0 .../{wysiwyg => wysiwyg-tinymce}/scrolling.js | 0 .../{wysiwyg => wysiwyg-tinymce}/shortcuts.js | 0 .../{wysiwyg => wysiwyg-tinymce}/toolbars.js | 0 .../js/{wysiwyg => wysiwyg-tinymce}/util.js | 0 resources/js/wysiwyg/index.mjs | 109 ++++++++++++++++++ resources/views/pages/parts/form.blade.php | 6 +- .../parts/wysiwyg-editor-tinymce.blade.php | 21 ++++ .../pages/parts/wysiwyg-editor.blade.php | 26 +++-- 32 files changed, 303 insertions(+), 30 deletions(-) create mode 100644 resources/js/components/wysiwyg-editor-tinymce.js rename resources/js/{wysiwyg => wysiwyg-tinymce}/common-events.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/config.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/drop-paste-handling.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/filters.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/fixes.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/icons.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugin-codeeditor.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugin-drawio.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-about.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-customhr.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-details.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-imagemanager.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-stub.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-table-additions.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/plugins-tasklist.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/scrolling.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/shortcuts.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/toolbars.js (100%) rename resources/js/{wysiwyg => wysiwyg-tinymce}/util.js (100%) create mode 100644 resources/js/wysiwyg/index.mjs create mode 100644 resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index c5b3c9ef3..20193db7f 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -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 diff --git a/package-lock.json b/package-lock.json index 63b0d2478..6a992f4d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,16 @@ "@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", @@ -691,6 +696,85 @@ "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", @@ -2709,6 +2793,11 @@ "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", diff --git a/package.json b/package.json index ba2de38ba..706c18738 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,16 @@ "@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", diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 3a66079d7..8ad5e14cb 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -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'; diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js index 79c9d3c2c..cac20c9fb 100644 --- a/resources/js/components/page-comment.js +++ b/resources/js/components/page-comment.js @@ -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 { diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index cfb0634a9..bd6dd3c82 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -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 index 000000000..093442ea2 --- /dev/null +++ b/resources/js/components/wysiwyg-editor-tinymce.js @@ -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(), + }; + } + +} diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 82f60827d..bcd480ce6 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -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); }); } diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js index ad964aed2..aa21a6371 100644 --- a/resources/js/components/wysiwyg-input.js +++ b/resources/js/components/wysiwyg-input.js @@ -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/common-events.js b/resources/js/wysiwyg-tinymce/common-events.js similarity index 100% rename from resources/js/wysiwyg/common-events.js rename to resources/js/wysiwyg-tinymce/common-events.js diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg-tinymce/config.js similarity index 100% rename from resources/js/wysiwyg/config.js rename to resources/js/wysiwyg-tinymce/config.js diff --git a/resources/js/wysiwyg/drop-paste-handling.js b/resources/js/wysiwyg-tinymce/drop-paste-handling.js similarity index 100% rename from resources/js/wysiwyg/drop-paste-handling.js rename to resources/js/wysiwyg-tinymce/drop-paste-handling.js diff --git a/resources/js/wysiwyg/filters.js b/resources/js/wysiwyg-tinymce/filters.js similarity index 100% rename from resources/js/wysiwyg/filters.js rename to resources/js/wysiwyg-tinymce/filters.js diff --git a/resources/js/wysiwyg/fixes.js b/resources/js/wysiwyg-tinymce/fixes.js similarity index 100% rename from resources/js/wysiwyg/fixes.js rename to resources/js/wysiwyg-tinymce/fixes.js diff --git a/resources/js/wysiwyg/icons.js b/resources/js/wysiwyg-tinymce/icons.js similarity index 100% rename from resources/js/wysiwyg/icons.js rename to resources/js/wysiwyg-tinymce/icons.js diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg-tinymce/plugin-codeeditor.js similarity index 100% rename from resources/js/wysiwyg/plugin-codeeditor.js rename to resources/js/wysiwyg-tinymce/plugin-codeeditor.js diff --git a/resources/js/wysiwyg/plugin-drawio.js b/resources/js/wysiwyg-tinymce/plugin-drawio.js similarity index 100% rename from resources/js/wysiwyg/plugin-drawio.js rename to resources/js/wysiwyg-tinymce/plugin-drawio.js diff --git a/resources/js/wysiwyg/plugins-about.js b/resources/js/wysiwyg-tinymce/plugins-about.js similarity index 100% rename from resources/js/wysiwyg/plugins-about.js rename to resources/js/wysiwyg-tinymce/plugins-about.js diff --git a/resources/js/wysiwyg/plugins-customhr.js b/resources/js/wysiwyg-tinymce/plugins-customhr.js similarity index 100% rename from resources/js/wysiwyg/plugins-customhr.js rename to resources/js/wysiwyg-tinymce/plugins-customhr.js diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg-tinymce/plugins-details.js similarity index 100% rename from resources/js/wysiwyg/plugins-details.js rename to resources/js/wysiwyg-tinymce/plugins-details.js diff --git a/resources/js/wysiwyg/plugins-imagemanager.js b/resources/js/wysiwyg-tinymce/plugins-imagemanager.js similarity index 100% rename from resources/js/wysiwyg/plugins-imagemanager.js rename to resources/js/wysiwyg-tinymce/plugins-imagemanager.js diff --git a/resources/js/wysiwyg/plugins-stub.js b/resources/js/wysiwyg-tinymce/plugins-stub.js similarity index 100% rename from resources/js/wysiwyg/plugins-stub.js rename to resources/js/wysiwyg-tinymce/plugins-stub.js diff --git a/resources/js/wysiwyg/plugins-table-additions.js b/resources/js/wysiwyg-tinymce/plugins-table-additions.js similarity index 100% rename from resources/js/wysiwyg/plugins-table-additions.js rename to resources/js/wysiwyg-tinymce/plugins-table-additions.js diff --git a/resources/js/wysiwyg/plugins-tasklist.js b/resources/js/wysiwyg-tinymce/plugins-tasklist.js similarity index 100% rename from resources/js/wysiwyg/plugins-tasklist.js rename to resources/js/wysiwyg-tinymce/plugins-tasklist.js diff --git a/resources/js/wysiwyg/scrolling.js b/resources/js/wysiwyg-tinymce/scrolling.js similarity index 100% rename from resources/js/wysiwyg/scrolling.js rename to resources/js/wysiwyg-tinymce/scrolling.js diff --git a/resources/js/wysiwyg/shortcuts.js b/resources/js/wysiwyg-tinymce/shortcuts.js similarity index 100% rename from resources/js/wysiwyg/shortcuts.js rename to resources/js/wysiwyg-tinymce/shortcuts.js diff --git a/resources/js/wysiwyg/toolbars.js b/resources/js/wysiwyg-tinymce/toolbars.js similarity index 100% rename from resources/js/wysiwyg/toolbars.js rename to resources/js/wysiwyg-tinymce/toolbars.js diff --git a/resources/js/wysiwyg/util.js b/resources/js/wysiwyg-tinymce/util.js similarity index 100% rename from resources/js/wysiwyg/util.js rename to resources/js/wysiwyg-tinymce/util.js diff --git a/resources/js/wysiwyg/index.mjs b/resources/js/wysiwyg/index.mjs new file mode 100644 index 000000000..4c4f16ce3 --- /dev/null +++ b/resources/js/wysiwyg/index.mjs @@ -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 diff --git a/resources/views/pages/parts/form.blade.php b/resources/views/pages/parts/form.blade.php index e2c839cd2..490374e40 100644 --- a/resources/views/pages/parts/form.blade.php +++ b/resources/views/pages/parts/form.blade.php @@ -33,11 +33,15 @@ {{--Editors--}}
- {{--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 index 000000000..33c526a99 --- /dev/null +++ b/resources/views/pages/parts/wysiwyg-editor-tinymce.blade.php @@ -0,0 +1,21 @@ +@push('head') + +@endpush + +
+ + +
+ +@if($errors->has('html')) +
{{ $errors->first('html') }}
+@endif + +@include('form.editor-translations') \ 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 84a267b68..7528b1e02 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -1,21 +1,31 @@ -@push('head') - -@endpush -
+ class=""> - +
+

Some content here

+

List below this h2 header

+
    +
  • Hello
  • +
+ +

+ Hello there, this is an info callout +

+
+ +
+ +{{-- --}}
@if($errors->has('html'))
{{ $errors->first('html') }}
@endif -@include('form.editor-translations') \ No newline at end of file +{{--TODO - @include('form.editor-translations')--}} \ No newline at end of file From 6e852d2e652e881e5f0096efa2b35ae3d712b4a3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 May 2024 20:23:45 +0100 Subject: [PATCH 002/107] 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=""> +
+ +
+

Some content here

List below this h2 header

From 49546cd627f4595cc245cad7e282d6c1f8506fd6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 May 2024 23:50:28 +0100 Subject: [PATCH 003/107] Lexical: Switched to ts for new editor build --- dev/build/esbuild.js | 2 +- package-lock.json | 16 ++++++- package.json | 6 ++- resources/js/wysiwyg/{index.mjs => index.ts} | 16 +++---- .../wysiwyg/nodes/{callout.js => callout.ts} | 48 ++++++++++++------- .../js/wysiwyg/nodes/{index.js => index.ts} | 4 +- 6 files changed, 62 insertions(+), 30 deletions(-) rename resources/js/wysiwyg/{index.mjs => index.ts} (83%) rename resources/js/wysiwyg/nodes/{callout.js => callout.ts} (58%) rename resources/js/wysiwyg/nodes/{index.js => index.ts} (60%) diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index 20193db7f..7f180fc07 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -14,7 +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'), + wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'), }; // Locate our output directory diff --git a/package-lock.json b/package-lock.json index 6cd32760d..2b6b677c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,8 @@ "eslint-plugin-import": "^2.29.0", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", - "sass": "^1.69.5" + "sass": "^1.69.5", + "typescript": "^5.4.5" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4099,6 +4100,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", diff --git a/package.json b/package.json index 42b86fdc7..97a796126 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "eslint-plugin-import": "^2.29.0", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", - "sass": "^1.69.5" + "sass": "^1.69.5", + "typescript": "^5.4.5" }, "dependencies": { "@codemirror/commands": "^6.3.2", @@ -65,7 +66,8 @@ }, "extends": "airbnb-base", "ignorePatterns": [ - "resources/**/*-stub.js" + "resources/**/*-stub.js", + "resources/**/*.ts" ], "overrides": [], "parserOptions": { diff --git a/resources/js/wysiwyg/index.mjs b/resources/js/wysiwyg/index.ts similarity index 83% rename from resources/js/wysiwyg/index.mjs rename to resources/js/wysiwyg/index.ts index decfa4f22..266866c62 100644 --- a/resources/js/wysiwyg/index.mjs +++ b/resources/js/wysiwyg/index.ts @@ -4,18 +4,18 @@ import { $getSelection, COMMAND_PRIORITY_LOW, createCommand, - createEditor + createEditor, CreateEditorArgs, } from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {$getNearestBlockElementAncestorOrThrow, mergeRegister} from '@lexical/utils'; import {$generateNodesFromDOM} from '@lexical/html'; -import {getNodesForPageEditor} from "./nodes/index.js"; -import {$createCalloutNode, $isCalloutNode} from "./nodes/callout.js"; -import {$setBlocksType} from "@lexical/selection"; +import {$setBlocksType} from '@lexical/selection'; +import {getNodesForPageEditor} from './nodes'; +import {$createCalloutNode, $isCalloutNode, CalloutCategory} from './nodes/callout'; -export function createPageEditorInstance(editArea) { - const config = { +export function createPageEditorInstance(editArea: HTMLElement) { + const config: CreateEditorArgs = { namespace: 'BookStackPageEditor', nodes: getNodesForPageEditor(), onError: console.error, @@ -52,7 +52,7 @@ export function createPageEditorInstance(editArea) { // Example of creating, registering and using a custom command const SET_BLOCK_CALLOUT_COMMAND = createCommand(); - editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category = 'info') => { + editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = 'info') => { const selection = $getSelection(); const blockElement = $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]); if ($isCalloutNode(blockElement)) { @@ -67,4 +67,4 @@ export function createPageEditorInstance(editArea) { 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.ts similarity index 58% rename from resources/js/wysiwyg/nodes/callout.js rename to resources/js/wysiwyg/nodes/callout.ts index db90f22a9..4fba5ee5b 100644 --- a/resources/js/wysiwyg/nodes/callout.js +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -1,35 +1,51 @@ -import {$createParagraphNode, ElementNode} from 'lexical'; +import { + $createParagraphNode, + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + ParagraphNode, SerializedElementNode, Spread +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; +import type {RangeSelection} from "lexical/LexicalSelection"; + +export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success'; + +export type SerializedCalloutNode = Spread<{ + category: CalloutCategory; +}, SerializedElementNode> export class Callout extends ElementNode { - __category = 'info'; + __category: CalloutCategory = 'info'; static getType() { return 'callout'; } - static clone(node) { + static clone(node: Callout) { return new Callout(node.__category, node.__key); } - constructor(category, key) { + constructor(category: CalloutCategory, key?: string) { super(key); this.__category = category; } - createDOM(_config, _editor) { + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('p'); element.classList.add('callout', this.__category || ''); return element; } - updateDOM(prevNode, dom) { + updateDOM(prevNode: unknown, dom: HTMLElement) { // 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) { + insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): Callout|ParagraphNode { const anchorOffset = selection ? selection.anchor.offset : 0; const newElement = anchorOffset === this.getTextContentSize() || !selection ? $createParagraphNode() : $createCalloutNode(this.__category); @@ -46,14 +62,14 @@ export class Callout extends ElementNode { return newElement; } - static importDOM() { + static importDOM(): DOMConversionMap|null { return { - p: node => { + p(node: HTMLElement): DOMConversion|null { if (node.classList.contains('callout')) { return { - conversion: element => { - let category = 'info'; - const categories = ['info', 'success', 'warning', 'danger']; + conversion: (element: HTMLElement): DOMConversionOutput|null => { + let category: CalloutCategory = 'info'; + const categories: CalloutCategory[] = ['info', 'success', 'warning', 'danger']; for (const c of categories) { if (element.classList.contains(c)) { @@ -74,7 +90,7 @@ export class Callout extends ElementNode { }; } - exportJSON() { + exportJSON(): SerializedCalloutNode { return { ...super.exportJSON(), type: 'callout', @@ -83,16 +99,16 @@ export class Callout extends ElementNode { }; } - static importJSON(serializedNode) { + static importJSON(serializedNode: SerializedCalloutNode): Callout { return $createCalloutNode(serializedNode.category); } } -export function $createCalloutNode(category = 'info') { +export function $createCalloutNode(category: CalloutCategory = 'info') { return new Callout(category); } -export function $isCalloutNode(node) { +export function $isCalloutNode(node: LexicalNode | null | undefined) { return node instanceof Callout; } diff --git a/resources/js/wysiwyg/nodes/index.js b/resources/js/wysiwyg/nodes/index.ts similarity index 60% rename from resources/js/wysiwyg/nodes/index.js rename to resources/js/wysiwyg/nodes/index.ts index ada229d9e..77f582877 100644 --- a/resources/js/wysiwyg/nodes/index.js +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,11 +1,11 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {Callout} from './callout'; +import {KlassConstructor, LexicalNode} from "lexical"; /** * Load the nodes for lexical. - * @returns {LexicalNode[]} */ -export function getNodesForPageEditor() { +export function getNodesForPageEditor(): KlassConstructor[] { return [ Callout, HeadingNode, From 0f8bd869d8bb0ef3f7318dfe03aa122b87cc9b0d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 May 2024 15:09:50 +0100 Subject: [PATCH 004/107] Lexical: Added custom id-supporting paragraph blocks --- package.json | 2 +- resources/js/wysiwyg/index.ts | 4 - resources/js/wysiwyg/nodes/callout.ts | 16 +-- .../js/wysiwyg/nodes/custom-paragraph.ts | 98 +++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 20 ++-- .../pages/parts/wysiwyg-editor.blade.php | 2 +- 6 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/custom-paragraph.ts diff --git a/package.json b/package.json index 97a796126..ca0f01f17 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources", "build:css:production": "sass ./resources/sass:./public/dist -s compressed", "build:js:dev": "node dev/build/esbuild.js", - "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" -c \"npm run build:js:dev\"", + "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" \"./resources/**/*.ts\" -c \"npm run build:js:dev\"", "build:js:production": "node dev/build/esbuild.js production", "build": "npm-run-all --parallel build:*:dev", "production": "npm-run-all --parallel build:*:production", diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 266866c62..9553fd4dd 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -45,10 +45,6 @@ export function createPageEditorInstance(editArea: HTMLElement) { 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(); diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index 4fba5ee5b..89b9b162e 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -16,7 +16,7 @@ export type SerializedCalloutNode = Spread<{ category: CalloutCategory; }, SerializedElementNode> -export class Callout extends ElementNode { +export class CalloutNode extends ElementNode { __category: CalloutCategory = 'info'; @@ -24,8 +24,8 @@ export class Callout extends ElementNode { return 'callout'; } - static clone(node: Callout) { - return new Callout(node.__category, node.__key); + static clone(node: CalloutNode) { + return new CalloutNode(node.__category, node.__key); } constructor(category: CalloutCategory, key?: string) { @@ -45,7 +45,7 @@ export class Callout extends ElementNode { return false; } - insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): Callout|ParagraphNode { + insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): CalloutNode|ParagraphNode { const anchorOffset = selection ? selection.anchor.offset : 0; const newElement = anchorOffset === this.getTextContentSize() || !selection ? $createParagraphNode() : $createCalloutNode(this.__category); @@ -79,7 +79,7 @@ export class Callout extends ElementNode { } return { - node: new Callout(category), + node: new CalloutNode(category), }; }, priority: 3, @@ -99,16 +99,16 @@ export class Callout extends ElementNode { }; } - static importJSON(serializedNode: SerializedCalloutNode): Callout { + static importJSON(serializedNode: SerializedCalloutNode): CalloutNode { return $createCalloutNode(serializedNode.category); } } export function $createCalloutNode(category: CalloutCategory = 'info') { - return new Callout(category); + return new CalloutNode(category); } export function $isCalloutNode(node: LexicalNode | null | undefined) { - return node instanceof Callout; + return node instanceof CalloutNode; } diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts new file mode 100644 index 000000000..f13cef56f --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-paragraph.ts @@ -0,0 +1,98 @@ +import { + DOMConversion, + DOMConversionMap, + DOMConversionOutput, ElementFormatType, + LexicalNode, + ParagraphNode, + SerializedParagraphNode, + Spread +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; + + +export type SerializedCustomParagraphNode = Spread<{ + id: string; +}, SerializedParagraphNode> + +export class CustomParagraphNode extends ParagraphNode { + __id: string = ''; + + static getType() { + return 'custom-paragraph'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: CustomParagraphNode) { + const newNode = new CustomParagraphNode(node.__key); + newNode.__id = node.__id; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + const id = this.getId(); + if (id) { + dom.setAttribute('id', id); + } + + return dom; + } + + exportJSON(): SerializedCustomParagraphNode { + return { + ...super.exportJSON(), + type: 'custom-paragraph', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedCustomParagraphNode): CustomParagraphNode { + const node = $createCustomParagraphNode(); + node.setId(serializedNode.id); + return node; + } + + static importDOM(): DOMConversionMap|null { + return { + p(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = $createCustomParagraphNode(); + if (element.style) { + node.setFormat(element.style.textAlign as ElementFormatType); + const indent = parseInt(element.style.textIndent, 10) / 20; + if (indent > 0) { + node.setIndent(indent); + } + } + + if (element.id) { + node.setId(element.id); + } + + return {node}; + }, + priority: 1, + }; + }, + }; + } +} + +export function $createCustomParagraphNode() { + return new CustomParagraphNode(); +} + +export function $isCustomParagraphNode(node: LexicalNode | null | undefined) { + return node instanceof CustomParagraphNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 77f582877..7dda30647 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,14 +1,22 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; -import {Callout} from './callout'; -import {KlassConstructor, LexicalNode} from "lexical"; +import {CalloutNode} from './callout'; +import {KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; +import {CustomParagraphNode} from "./custom-paragraph"; /** * Load the nodes for lexical. */ -export function getNodesForPageEditor(): KlassConstructor[] { +export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ - Callout, - HeadingNode, - QuoteNode, + CalloutNode, // Todo - Create custom + HeadingNode, // Todo - Create custom + QuoteNode, // Todo - Create custom + CustomParagraphNode, + { + replace: ParagraphNode, + with: (node: ParagraphNode) => { + return new CustomParagraphNode(); + } + } ]; } diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 30be1a214..bbe76090c 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -11,7 +11,7 @@
-

Some content here

+

Some content here

List below this h2 header

  • Hello
  • From b24d60e98d0c990f2a5d0cdf3a09b6d37fc2f195 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 May 2024 18:04:48 +0100 Subject: [PATCH 005/107] Lexical: Started UI fundementals with basic button --- resources/js/wysiwyg/helpers.ts | 36 ++++++ resources/js/wysiwyg/index.ts | 52 ++++----- resources/js/wysiwyg/nodes/callout.ts | 14 +++ resources/js/wysiwyg/nodes/index.ts | 5 +- resources/js/wysiwyg/ui/editor-button.ts | 45 ++++++++ resources/js/wysiwyg/ui/index.ts | 51 ++++++++ .../pages/parts/wysiwyg-editor.blade.php | 8 +- tsconfig.json | 109 ++++++++++++++++++ 8 files changed, 288 insertions(+), 32 deletions(-) create mode 100644 resources/js/wysiwyg/helpers.ts create mode 100644 resources/js/wysiwyg/ui/editor-button.ts create mode 100644 resources/js/wysiwyg/ui/index.ts create mode 100644 tsconfig.json diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts new file mode 100644 index 000000000..720f3c6d5 --- /dev/null +++ b/resources/js/wysiwyg/helpers.ts @@ -0,0 +1,36 @@ +import {$createParagraphNode, $getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; +import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {$setBlocksType} from "@lexical/selection"; + +export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { + if (!selection) { + return false; + } + + for (const node of selection.getNodes()) { + if (matcher(node)) { + return true; + } + + for (const parent of node.getParents()) { + if (matcher(parent)) { + return true; + } + } + } + + return false; +} + +export function toggleSelectionBlockNodeType(editor: LexicalEditor, matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { + editor.update(() => { + const selection = $getSelection(); + const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + if (selection && matcher(blockElement)) { + $setBlocksType(selection, $createParagraphNode); + } else { + $setBlocksType(selection, creator); + } + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9553fd4dd..0dcbf27f5 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,18 +1,10 @@ -import { - $createParagraphNode, - $getRoot, - $getSelection, - COMMAND_PRIORITY_LOW, - createCommand, - createEditor, CreateEditorArgs, -} from 'lexical'; +import {$getRoot, createEditor, CreateEditorArgs} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; -import {$getNearestBlockElementAncestorOrThrow, mergeRegister} from '@lexical/utils'; +import {mergeRegister} from '@lexical/utils'; import {$generateNodesFromDOM} from '@lexical/html'; -import {$setBlocksType} from '@lexical/selection'; import {getNodesForPageEditor} from './nodes'; -import {$createCalloutNode, $isCalloutNode, CalloutCategory} from './nodes/callout'; +import {buildEditorUI} from "./ui"; export function createPageEditorInstance(editArea: HTMLElement) { const config: CreateEditorArgs = { @@ -42,25 +34,29 @@ export function createPageEditorInstance(editArea: HTMLElement) { const debugView = document.getElementById('lexical-debug'); editor.registerUpdateListener(({editorState}) => { console.log('editorState', editorState.toJSON()); - debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); + if (debugView) { + debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); + } }); + buildEditorUI(editArea, editor); + // Example of creating, registering and using a custom command - const SET_BLOCK_CALLOUT_COMMAND = createCommand(); - editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = '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'); - }); + // const SET_BLOCK_CALLOUT_COMMAND = createCommand(); + // editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = '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'); + // }); } diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index 89b9b162e..e39dcc3ee 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -33,6 +33,16 @@ export class CalloutNode extends ElementNode { this.__category = category; } + setCategory(category: CalloutCategory) { + const self = this.getWritable(); + self.__category = category; + } + + getCategory(): CalloutCategory { + const self = this.getLatest(); + return self.__category; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('p'); element.classList.add('callout', this.__category || ''); @@ -112,3 +122,7 @@ export function $createCalloutNode(category: CalloutCategory = 'info') { export function $isCalloutNode(node: LexicalNode | null | undefined) { return node instanceof CalloutNode; } + +export function $isCalloutNodeOfCategory(node: LexicalNode | null | undefined, category: CalloutCategory = 'info') { + return node instanceof CalloutNode && (node as CalloutNode).getCategory() === category; +} diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 7dda30647..ffe1b027f 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,6 +1,6 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; -import {KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; +import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; import {CustomParagraphNode} from "./custom-paragraph"; /** @@ -20,3 +20,6 @@ export function getNodesForPageEditor(): (KlassConstructor | } ]; } + +export type LexicalNodeMatcher = (node: LexicalNode|null|undefined) => boolean; +export type LexicalElementNodeCreator = () => ElementNode; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/editor-button.ts b/resources/js/wysiwyg/ui/editor-button.ts new file mode 100644 index 000000000..2ce272fce --- /dev/null +++ b/resources/js/wysiwyg/ui/editor-button.ts @@ -0,0 +1,45 @@ +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/index.ts b/resources/js/wysiwyg/ui/index.ts new file mode 100644 index 000000000..10eaa558f --- /dev/null +++ b/resources/js/wysiwyg/ui/index.ts @@ -0,0 +1,51 @@ +import { + $getSelection, + BaseSelection, + COMMAND_PRIORITY_LOW, + LexicalEditor, + SELECTION_CHANGE_COMMAND +} from "lexical"; +import {$createCalloutNode, $isCalloutNodeOfCategory} from "../nodes/callout"; +import {selectionContainsNodeType, toggleSelectionBlockNodeType} from "../helpers"; +import {EditorButton, EditorButtonDefinition} from "./editor-button"; + +const calloutButton: EditorButtonDefinition = { + label: 'Info Callout', + action(editor: LexicalEditor) { + toggleSelectionBlockNodeType( + editor, + (node) => $isCalloutNodeOfCategory(node, 'info'), + () => $createCalloutNode('info'), + ) + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, 'info')); + } +} + +const toolbarButtonDefinitions: EditorButtonDefinition[] = [ + calloutButton, +]; + +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); + + // Update button states on editor selection change + editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { + const selection = $getSelection(); + for (const button of buttons) { + button.updateActiveState(selection); + } + return false; + }, COMMAND_PRIORITY_LOW); +} \ 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 bbe76090c..90e42e576 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -6,9 +6,11 @@ option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}" class=""> -
    - -
    +

    Some content here

    diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..e075f973c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} From 483d9bf26ca5db0de17aa6fbf775874a596e4782 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 May 2024 22:56:58 +0100 Subject: [PATCH 006/107] Lexical: Added a range of format buttons --- resources/js/wysiwyg/helpers.ts | 24 +++++- resources/js/wysiwyg/ui/buttons.ts | 129 +++++++++++++++++++++++++++++ resources/js/wysiwyg/ui/index.ts | 35 ++++---- 3 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 resources/js/wysiwyg/ui/buttons.ts diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 720f3c6d5..737666ffa 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -1,7 +1,15 @@ -import {$createParagraphNode, $getSelection, BaseSelection, LexicalEditor} from "lexical"; +import { + $createParagraphNode, + $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 selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { if (!selection) { @@ -23,6 +31,20 @@ export function selectionContainsNodeType(selection: BaseSelection|null, matcher return false; } +export function selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean { + if (!selection) { + return false; + } + + for (const node of selection.getNodes()) { + if ($isTextNode(node) && node.hasFormat(format)) { + return true; + } + } + + return false; +} + export function toggleSelectionBlockNodeType(editor: LexicalEditor, matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { editor.update(() => { const selection = $getSelection(); diff --git a/resources/js/wysiwyg/ui/buttons.ts b/resources/js/wysiwyg/ui/buttons.ts new file mode 100644 index 000000000..cf5660ef0 --- /dev/null +++ b/resources/js/wysiwyg/ui/buttons.ts @@ -0,0 +1,129 @@ +import {EditorButtonDefinition} from "./editor-button"; +import { + $createParagraphNode, + $isParagraphNode, + BaseSelection, FORMAT_TEXT_COMMAND, + LexicalEditor, + LexicalNode, + REDO_COMMAND, TextFormatType, + UNDO_COMMAND +} from "lexical"; +import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../helpers"; +import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../nodes/callout"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + $isQuoteNode, + HeadingNode, + HeadingTagType +} from "@lexical/rich-text"; + +export const undoButton: EditorButtonDefinition = { + label: 'Undo', + action(editor: LexicalEditor) { + editor.dispatchCommand(UNDO_COMMAND); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + } +} + +export const redoButton: EditorButtonDefinition = { + label: 'Redo', + action(editor: LexicalEditor) { + editor.dispatchCommand(REDO_COMMAND); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + } +} + +function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { + return { + label: `${name} Callout`, + action(editor: LexicalEditor) { + toggleSelectionBlockNodeType( + editor, + (node) => $isCalloutNodeOfCategory(node, category), + () => $createCalloutNode(category), + ) + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category)); + } + }; +} + +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'); + +const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { + return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag; +}; + +function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { + return { + label: name, + action(editor: LexicalEditor) { + toggleSelectionBlockNodeType( + editor, + (node) => isHeaderNodeOfTag(node, tag), + () => $createHeadingNode(tag), + ) + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); + } + }; +} + +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 blockquoteButton: EditorButtonDefinition = { + label: 'Blockquote', + action(editor: LexicalEditor) { + toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isQuoteNode); + } +}; + +export const paragraphButton: EditorButtonDefinition = { + label: 'Paragraph', + action(editor: LexicalEditor) { + toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isParagraphNode); + } +} + +function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition { + return { + label: label, + action(editor: LexicalEditor) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsTextFormat(selection, format); + } + }; +} + +export const boldButton: EditorButtonDefinition = buildFormatButton('Bold', 'bold'); +export const italicButton: EditorButtonDefinition = buildFormatButton('Italic', 'italic'); +export const underlineButton: 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 diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 10eaa558f..d04808fae 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,30 +1,31 @@ import { $getSelection, - BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND } from "lexical"; -import {$createCalloutNode, $isCalloutNodeOfCategory} from "../nodes/callout"; -import {selectionContainsNodeType, toggleSelectionBlockNodeType} from "../helpers"; 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 calloutButton: EditorButtonDefinition = { - label: 'Info Callout', - action(editor: LexicalEditor) { - toggleSelectionBlockNodeType( - editor, - (node) => $isCalloutNodeOfCategory(node, 'info'), - () => $createCalloutNode('info'), - ) - }, - isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, 'info')); - } -} const toolbarButtonDefinitions: EditorButtonDefinition[] = [ - calloutButton, + undoButton, redoButton, + + infoCalloutButton, warningCalloutButton, dangerCalloutButton, successCalloutButton, + h2Button, h3Button, h4Button, h5Button, + blockquoteButton, paragraphButton, + + boldButton, italicButton, underlineButton, strikethroughButton, + superscriptButton, subscriptButton, codeButton, ]; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { From dc1a40ea7465277ae82027e07926e230549c89f0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 May 2024 20:38:31 +0100 Subject: [PATCH 007/107] 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 = {}, 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 | 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 @@

    Some content here

    +

    This has a link in it

    List below this h2 header

    • Hello
    • From 57259aee00206785f371cbaff8ca30711f025172 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 30 May 2024 12:25:25 +0100 Subject: [PATCH 008/107] Lexical: Added format previews to format buttons --- resources/js/wysiwyg/ui/framework/buttons.ts | 58 ++++++++++++++++-- .../js/wysiwyg/ui/framework/containers.ts | 61 +++++++++++++++++-- resources/js/wysiwyg/ui/toolbars.ts | 27 ++++---- resources/sass/_editor.scss | 49 +++++++++++++++ resources/sass/styles.scss | 1 + .../pages/parts/wysiwyg-editor.blade.php | 28 ++++----- 6 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 resources/sass/_editor.scss diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 51c7d294d..2a6f5a976 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -19,22 +19,70 @@ export class EditorButton extends EditorUiElement { protected buildDOM(): HTMLButtonElement { const button = el('button', { type: 'button', - class: 'editor-toolbar-button', + class: 'editor-button', }, [this.definition.label]) as HTMLButtonElement; - button.addEventListener('click', event => { - this.definition.action(this.getContext().editor); - }); + button.addEventListener('click', this.onClick.bind(this)); return button; } + protected onClick() { + this.definition.action(this.getContext().editor); + } + updateActiveState(selection: BaseSelection|null) { const isActive = this.definition.isActive(selection); - this.dom?.classList.toggle('editor-toolbar-button-active', isActive); + this.dom?.classList.toggle('editor-button-active', isActive); } updateState(state: EditorUiStateUpdate): void { this.updateActiveState(state.selection); } +} + +export class FormatPreviewButton extends EditorButton { + protected previewSampleElement: HTMLElement; + + constructor(previewSampleElement: HTMLElement,definition: EditorButtonDefinition) { + super(definition); + this.previewSampleElement = previewSampleElement; + } + + protected buildDOM(): HTMLButtonElement { + const button = super.buildDOM(); + button.innerHTML = ''; + + const preview = el('span', { + class: 'editor-button-format-preview' + }, [this.definition.label]); + + const stylesToApply = this.getStylesFromPreview(); + console.log(stylesToApply); + for (const style of Object.keys(stylesToApply)) { + preview.style.setProperty(style, stylesToApply[style]); + } + + button.append(preview); + return button; + } + + protected getStylesFromPreview(): Record { + const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); + const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; + sampleClone.textContent = this.definition.label; + wrap.append(sampleClone); + document.body.append(wrap); + + const propertiesToFetch = ['color', 'font-size', 'background-color', 'border-inline-start']; + const propertiesToReturn: Record = {}; + + const computed = window.getComputedStyle(sampleClone); + for (const property of propertiesToFetch) { + propertiesToReturn[property] = computed.getPropertyValue(property); + } + wrap.remove(); + + return propertiesToReturn; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts index 9ef59c72f..e58988e7b 100644 --- a/resources/js/wysiwyg/ui/framework/containers.ts +++ b/resources/js/wysiwyg/ui/framework/containers.ts @@ -30,11 +30,62 @@ export class EditorContainerUiElement extends EditorUiElement { } } -export class EditorFormatMenu extends EditorContainerUiElement { - buildDOM(): HTMLElement { - return el('div', { - class: 'editor-format-menu' - }, this.getChildren().map(child => child.getDOMElement())); +export class EditorSimpleClassContainer extends EditorContainerUiElement { + protected className; + + constructor(className: string, children: EditorUiElement[]) { + super(children); + this.className = className; } + protected buildDOM(): HTMLElement { + return el('div', { + class: this.className, + }, this.getChildren().map(child => child.getDOMElement())); + } +} + +export class EditorFormatMenu extends EditorContainerUiElement { + buildDOM(): HTMLElement { + const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); + const menu = el('div', { + class: 'editor-format-menu-dropdown editor-dropdown-menu editor-menu-list', + hidden: 'true', + }, childElements); + + const toggle = el('button', { + class: 'editor-format-menu-toggle', + type: 'button', + }, ['Formats']); + + const wrapper = el('div', { + class: 'editor-format-menu editor-dropdown-menu-container', + }, [toggle, menu]); + + let clickListener: Function|null = null; + + const hide = () => { + menu.hidden = true; + if (clickListener) { + window.removeEventListener('click', clickListener as EventListener); + } + }; + + const show = () => { + menu.hidden = false + clickListener = (event: MouseEvent) => { + if (!wrapper.contains(event.target as HTMLElement)) { + hide(); + } + } + window.addEventListener('click', clickListener as EventListener); + }; + + toggle.addEventListener('click', event => { + menu.hasAttribute('hidden') ? show() : hide(); + }); + menu.addEventListener('mouseleave', hide); + + return wrapper; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 0f46f5b2a..2d5063cf4 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,4 +1,4 @@ -import {EditorButton} from "./framework/buttons"; +import {EditorButton, FormatPreviewButton} from "./framework/buttons"; import { blockquote, bold, code, dangerCallout, @@ -9,25 +9,26 @@ import { undo, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorFormatMenu} from "./framework/containers"; +import {EditorContainerUiElement, EditorFormatMenu, EditorSimpleClassContainer} from "./framework/containers"; +import {el} from "../helpers"; export function getMainEditorFullToolbar(): EditorContainerUiElement { - return new EditorContainerUiElement([ + return new EditorSimpleClassContainer('editor-toolbar-main', [ 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 FormatPreviewButton(el('h2'), h2), + new FormatPreviewButton(el('h3'), h3), + new FormatPreviewButton(el('h4'), h4), + new FormatPreviewButton(el('h5'), h5), + new FormatPreviewButton(el('blockquote'), blockquote), + new FormatPreviewButton(el('p'), paragraph), + new FormatPreviewButton(el('p', {class: 'callout info'}), infoCallout), + new FormatPreviewButton(el('p', {class: 'callout success'}), successCallout), + new FormatPreviewButton(el('p', {class: 'callout warning'}), warningCallout), + new FormatPreviewButton(el('p', {class: 'callout danger'}), dangerCallout), ]), new EditorButton(bold), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss new file mode 100644 index 000000000..48912be8b --- /dev/null +++ b/resources/sass/_editor.scss @@ -0,0 +1,49 @@ +// Main UI elements +.editor-toolbar-main { + display: flex; +} + +// Buttons +.editor-button { + border: 1px solid #DDD; + font-size: 12px; + padding: 4px 6px; + color: #444; +} +.editor-button:hover { + background-color: #EEE; + cursor: pointer; + color: #000; +} +.editor-button-active, .editor-button-active:hover { + background-color: #ceebff; + color: #000; +} +.editor-button-format-preview { + padding: 4px 6px; + display: block; +} + +// Containers +.editor-dropdown-menu-container { + position: relative; +} +.editor-dropdown-menu { + position: absolute; + background-color: #FFF; + box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); + z-index: 99; + min-width: 120px; +} +.editor-menu-list { + display: flex; + flex-direction: column; +} +.editor-menu-list > .editor-button { + border-bottom: 0; + text-align: start; +} + +.editor-format-menu .editor-dropdown-menu { + min-width: 320px; +} \ No newline at end of file diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index f52b61992..636367e3a 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -15,6 +15,7 @@ @import "forms"; @import "animations"; @import "tinymce"; +@import "editor"; @import "codemirror"; @import "components"; @import "header"; diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index b48e10570..940c005b5 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -6,23 +6,19 @@ option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}" class=""> - +
      +
      +

      Some content here

      +

      This has a link in it

      +

      List below this h2 header

      +
        +
      • Hello
      • +
      -
      -

      Some content here

      -

      This has a link in it

      -

      List below this h2 header

      -
        -
      • Hello
      • -
      - -

      - Hello there, this is an info callout -

      +

      + Hello there, this is an info callout +

      +
      From ae987454392a994b350841cbc35f61e0903eebaa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 30 May 2024 16:50:55 +0100 Subject: [PATCH 009/107] Lexical: Started on form UI --- .../wysiwyg/ui/defaults/button-definitions.ts | 34 ++++---- .../wysiwyg/ui/defaults/form-definitions.ts | 43 ++++++++++ resources/js/wysiwyg/ui/framework/buttons.ts | 27 ++++-- .../js/wysiwyg/ui/framework/containers.ts | 28 ++++++- .../framework/{base-elements.ts => core.ts} | 11 ++- resources/js/wysiwyg/ui/framework/forms.ts | 82 +++++++++++++++++++ resources/js/wysiwyg/ui/framework/manager.ts | 11 +++ resources/js/wysiwyg/ui/index.ts | 18 +++- 8 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 resources/js/wysiwyg/ui/defaults/form-definitions.ts rename resources/js/wysiwyg/ui/framework/{base-elements.ts => core.ts} (75%) create mode 100644 resources/js/wysiwyg/ui/framework/forms.ts create mode 100644 resources/js/wysiwyg/ui/framework/manager.ts diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 874f632fe..da0a1e2c5 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -3,7 +3,6 @@ import { $createParagraphNode, $isParagraphNode, BaseSelection, FORMAT_TEXT_COMMAND, - LexicalEditor, LexicalNode, REDO_COMMAND, TextFormatType, UNDO_COMMAND @@ -19,11 +18,12 @@ import { HeadingTagType } from "@lexical/rich-text"; import {$isLinkNode, $toggleLink} from "@lexical/link"; +import {EditorUiContext} from "../framework/core"; export const undo: EditorButtonDefinition = { label: 'Undo', - action(editor: LexicalEditor) { - editor.dispatchCommand(UNDO_COMMAND, undefined); + action(context: EditorUiContext) { + context.editor.dispatchCommand(UNDO_COMMAND, undefined); }, isActive(selection: BaseSelection|null): boolean { return false; @@ -32,8 +32,8 @@ export const undo: EditorButtonDefinition = { export const redo: EditorButtonDefinition = { label: 'Redo', - action(editor: LexicalEditor) { - editor.dispatchCommand(REDO_COMMAND, undefined); + action(context: EditorUiContext) { + context.editor.dispatchCommand(REDO_COMMAND, undefined); }, isActive(selection: BaseSelection|null): boolean { return false; @@ -43,9 +43,9 @@ export const redo: EditorButtonDefinition = { function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { label: `${name} Callout`, - action(editor: LexicalEditor) { + action(context: EditorUiContext) { toggleSelectionBlockNodeType( - editor, + context.editor, (node) => $isCalloutNodeOfCategory(node, category), () => $createCalloutNode(category), ) @@ -68,9 +68,9 @@ const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTag function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { return { label: name, - action(editor: LexicalEditor) { + action(context: EditorUiContext) { toggleSelectionBlockNodeType( - editor, + context.editor, (node) => isHeaderNodeOfTag(node, tag), () => $createHeadingNode(tag), ) @@ -88,8 +88,8 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header') export const blockquote: EditorButtonDefinition = { label: 'Blockquote', - action(editor: LexicalEditor) { - toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode); + action(context: EditorUiContext) { + toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsNodeType(selection, $isQuoteNode); @@ -98,8 +98,8 @@ export const blockquote: EditorButtonDefinition = { export const paragraph: EditorButtonDefinition = { label: 'Paragraph', - action(editor: LexicalEditor) { - toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode); + action(context: EditorUiContext) { + toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsNodeType(selection, $isParagraphNode); @@ -109,8 +109,8 @@ export const paragraph: EditorButtonDefinition = { function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition { return { label: label, - action(editor: LexicalEditor) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + action(context: EditorUiContext) { + context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsTextFormat(selection, format); @@ -132,8 +132,8 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co export const link: EditorButtonDefinition = { label: 'Insert/edit link', - action(editor: LexicalEditor) { - editor.update(() => { + action(context: EditorUiContext) { + context.editor.update(() => { $toggleLink('http://example.com'); }) }, diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts new file mode 100644 index 000000000..c8477d9f2 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -0,0 +1,43 @@ +import {EditorFormDefinition, EditorFormFieldDefinition, EditorSelectFormFieldDefinition} from "../framework/forms"; +import {EditorUiContext} from "../framework/core"; + + +export const link: EditorFormDefinition = { + submitText: 'Apply', + cancelText: 'Cancel', + action(formData, context: EditorUiContext) { + // Todo + console.log('link-form-action', formData); + return true; + }, + cancel() { + // Todo + console.log('link-form-cancel'); + }, + fields: [ + { + label: 'URL', + name: 'url', + type: 'text', + }, + { + label: 'Text to display', + name: 'text', + type: 'text', + }, + { + label: 'Title', + name: 'title', + type: 'text', + }, + { + label: 'Open link in...', + name: 'target', + type: 'select', + valuesByLabel: { + 'Current window': '', + 'New window': '_blank', + } + } as EditorSelectFormFieldDefinition, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 2a6f5a976..48046e9de 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -1,15 +1,16 @@ -import {BaseSelection, LexicalEditor} from "lexical"; -import {EditorUiElement, EditorUiStateUpdate} from "./base-elements"; +import {BaseSelection} from "lexical"; +import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; export interface EditorButtonDefinition { label: string; - action: (editor: LexicalEditor) => void; + action: (context: EditorUiContext) => void; isActive: (selection: BaseSelection|null) => boolean; } export class EditorButton extends EditorUiElement { protected definition: EditorButtonDefinition; + protected active: boolean = false; constructor(definition: EditorButtonDefinition) { super(); @@ -20,7 +21,7 @@ export class EditorButton extends EditorUiElement { const button = el('button', { type: 'button', class: 'editor-button', - }, [this.definition.label]) as HTMLButtonElement; + }, [this.getLabel()]) as HTMLButtonElement; button.addEventListener('click', this.onClick.bind(this)); @@ -28,17 +29,25 @@ export class EditorButton extends EditorUiElement { } protected onClick() { - this.definition.action(this.getContext().editor); + this.definition.action(this.getContext()); } updateActiveState(selection: BaseSelection|null) { - const isActive = this.definition.isActive(selection); - this.dom?.classList.toggle('editor-button-active', isActive); + this.active = this.definition.isActive(selection); + this.dom?.classList.toggle('editor-button-active', this.active); } updateState(state: EditorUiStateUpdate): void { this.updateActiveState(state.selection); } + + isActive(): boolean { + return this.active; + } + + getLabel(): string { + return this.trans(this.definition.label); + } } export class FormatPreviewButton extends EditorButton { @@ -55,7 +64,7 @@ export class FormatPreviewButton extends EditorButton { const preview = el('span', { class: 'editor-button-format-preview' - }, [this.definition.label]); + }, [this.getLabel()]); const stylesToApply = this.getStylesFromPreview(); console.log(stylesToApply); @@ -70,7 +79,7 @@ export class FormatPreviewButton extends EditorButton { protected getStylesFromPreview(): Record { const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; - sampleClone.textContent = this.definition.label; + sampleClone.textContent = this.getLabel(); wrap.append(sampleClone); document.body.append(wrap); diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts index e58988e7b..ed191a882 100644 --- a/resources/js/wysiwyg/ui/framework/containers.ts +++ b/resources/js/wysiwyg/ui/framework/containers.ts @@ -1,5 +1,6 @@ -import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements"; +import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; +import {EditorButton} from "./buttons"; export class EditorContainerUiElement extends EditorUiElement { protected children : EditorUiElement[]; @@ -24,6 +25,7 @@ export class EditorContainerUiElement extends EditorUiElement { } setContext(context: EditorUiContext) { + super.setContext(context); for (const child of this.getChildren()) { child.setContext(context); } @@ -54,9 +56,9 @@ export class EditorFormatMenu extends EditorContainerUiElement { }, childElements); const toggle = el('button', { - class: 'editor-format-menu-toggle', + class: 'editor-format-menu-toggle editor-button', type: 'button', - }, ['Formats']); + }, [this.trans('Formats')]); const wrapper = el('div', { class: 'editor-format-menu editor-dropdown-menu-container', @@ -88,4 +90,24 @@ export class EditorFormatMenu extends EditorContainerUiElement { return wrapper; } + + updateState(state: EditorUiStateUpdate) { + super.updateState(state); + + for (const child of this.children) { + if (child instanceof EditorButton && child.isActive()) { + this.updateToggleLabel(child.getLabel()); + return; + } + } + + this.updateToggleLabel(this.trans('Formats')); + } + + protected updateToggleLabel(text: string): void { + const button = this.getDOMElement().querySelector('button'); + if (button) { + button.innerText = text; + } + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/base-elements.ts b/resources/js/wysiwyg/ui/framework/core.ts similarity index 75% rename from resources/js/wysiwyg/ui/framework/base-elements.ts rename to resources/js/wysiwyg/ui/framework/core.ts index 665011782..68d845b42 100644 --- a/resources/js/wysiwyg/ui/framework/base-elements.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -1,4 +1,5 @@ import {BaseSelection, LexicalEditor} from "lexical"; +import {EditorUIManager} from "./manager"; export type EditorUiStateUpdate = { editor: LexicalEditor, @@ -7,6 +8,8 @@ export type EditorUiStateUpdate = { export type EditorUiContext = { editor: LexicalEditor, + translate: (text: string) => string, + manager: EditorUIManager, }; export abstract class EditorUiElement { @@ -35,5 +38,11 @@ export abstract class EditorUiElement { return this.dom; } - abstract updateState(state: EditorUiStateUpdate): void; + trans(text: string) { + return this.getContext().translate(text); + } + + updateState(state: EditorUiStateUpdate): void { + return; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts new file mode 100644 index 000000000..0fce73c12 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -0,0 +1,82 @@ +import {EditorUiContext, EditorUiElement} from "./core"; +import {EditorContainerUiElement} from "./containers"; +import {el} from "../../helpers"; + +export interface EditorFormFieldDefinition { + label: string; + name: string; + type: 'text' | 'select'; +} + +export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { + type: 'select', + valuesByLabel: Record +} + +export interface EditorFormDefinition { + submitText: string; + cancelText: string; + action: (formData: FormData, context: EditorUiContext) => boolean; + cancel: () => void; + fields: EditorFormFieldDefinition[]; +} + +export class EditorFormField extends EditorUiElement { + protected definition: EditorFormFieldDefinition; + + constructor(definition: EditorFormFieldDefinition) { + super(); + this.definition = definition; + } + + protected buildDOM(): HTMLElement { + const id = `editor-form-field-${this.definition.name}-${Date.now()}`; + let input: HTMLElement; + + if (this.definition.type === 'select') { + const options = (this.definition as EditorSelectFormFieldDefinition).valuesByLabel + const labels = Object.keys(options); + const optionElems = labels.map(label => el('option', {value: options[label]}, [label])); + input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); + } else { + input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); + } + + return el('div', {class: 'editor-form-field-wrapper'}, [ + el('label', {class: 'editor-form-field-label', for: id}, [this.trans(this.definition.label)]), + input, + ]); + } +} + +export class EditorForm extends EditorContainerUiElement { + protected definition: EditorFormDefinition; + + constructor(definition: EditorFormDefinition) { + super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition))); + this.definition = definition; + } + + protected buildDOM(): HTMLElement { + const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans(this.definition.cancelText)]); + const form = el('form', {}, [ + ...this.children.map(child => child.getDOMElement()), + el('div', {class: 'editor-form-actions'}, [ + cancelButton, + el('button', {type: 'submit', class: 'editor-form-action-primary'}, [this.trans(this.definition.submitText)]), + ]) + ]); + + form.addEventListener('submit', (event) => { + event.preventDefault(); + const formData = new FormData(form as HTMLFormElement); + this.definition.action(formData, this.getContext()); + }); + + cancelButton.addEventListener('click', (event) => { + this.definition.cancel(); + }); + + return form; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts new file mode 100644 index 000000000..f1a34c92a --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -0,0 +1,11 @@ + + + + + +export class EditorUIManager { + + // Todo - Register and show modal via this + // (Part of UI context) + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 56ae9354a..5a0d7fd2d 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -5,12 +5,28 @@ import { SELECTION_CHANGE_COMMAND } from "lexical"; import {getMainEditorFullToolbar} from "./toolbars"; +import {EditorUIManager} from "./framework/manager"; +import {EditorForm} from "./framework/forms"; +import {link} from "./defaults/form-definitions"; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { + const manager = new EditorUIManager(); + const context = { + editor, + manager, + translate: (text: string): string => text, + }; + + // Create primary toolbar const toolbar = getMainEditorFullToolbar(); - toolbar.setContext({editor}); + toolbar.setContext(context); element.before(toolbar.getDOMElement()); + // Form test + const linkForm = new EditorForm(link); + linkForm.setContext(context); + element.before(linkForm.getDOMElement()); + // Update button states on editor selection change editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { const selection = $getSelection(); From 7c504a10a8026135bd5237911a91446b3c727f57 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 1 Jun 2024 16:49:47 +0100 Subject: [PATCH 010/107] Lexical: Created core modal functionality --- resources/js/wysiwyg/helpers.ts | 14 +++-- .../wysiwyg/ui/defaults/button-definitions.ts | 40 +++++++++--- .../wysiwyg/ui/defaults/form-definitions.ts | 23 ++++--- resources/js/wysiwyg/ui/framework/forms.ts | 41 ++++++++++-- resources/js/wysiwyg/ui/framework/manager.ts | 37 +++++++++-- resources/js/wysiwyg/ui/framework/modals.ts | 63 +++++++++++++++++++ resources/js/wysiwyg/ui/index.ts | 13 ++-- resources/sass/_editor.scss | 26 ++++++++ 8 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/modals.ts diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 7218f1ae6..40379cc27 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -3,7 +3,7 @@ import { $getSelection, $isTextNode, BaseSelection, - LexicalEditor, TextFormatType + LexicalEditor, LexicalNode, TextFormatType } from "lexical"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; @@ -28,23 +28,27 @@ export function el(tag: string, attrs: Record = {}, children: (s } export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { + return getNodeFromSelection(selection, matcher) !== null; +} + +export function getNodeFromSelection(selection: BaseSelection|null, matcher: LexicalNodeMatcher): LexicalNode|null { if (!selection) { - return false; + return null; } for (const node of selection.getNodes()) { if (matcher(node)) { - return true; + return node; } for (const parent of node.getParents()) { if (matcher(parent)) { - return true; + return parent; } } } - return false; + return null; } export function selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index da0a1e2c5..f5be82519 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,13 +1,19 @@ import {EditorButtonDefinition} from "../framework/buttons"; import { - $createParagraphNode, - $isParagraphNode, + $createNodeSelection, + $createParagraphNode, $getSelection, + $isParagraphNode, $setSelection, BaseSelection, FORMAT_TEXT_COMMAND, LexicalNode, REDO_COMMAND, TextFormatType, UNDO_COMMAND } from "lexical"; -import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../../helpers"; +import { + getNodeFromSelection, + selectionContainsNodeType, + selectionContainsTextFormat, + toggleSelectionBlockNodeType +} from "../../helpers"; import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout"; import { $createHeadingNode, @@ -17,7 +23,7 @@ import { HeadingNode, HeadingTagType } from "@lexical/rich-text"; -import {$isLinkNode, $toggleLink} from "@lexical/link"; +import {$isLinkNode, $toggleLink, LinkNode} from "@lexical/link"; import {EditorUiContext} from "../framework/core"; export const undo: EditorButtonDefinition = { @@ -133,9 +139,29 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co export const link: EditorButtonDefinition = { label: 'Insert/edit link', action(context: EditorUiContext) { - context.editor.update(() => { - $toggleLink('http://example.com'); - }) + const linkModal = context.manager.createModal('link'); + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + + let formDefaults = {}; + if (selectedLink) { + formDefaults = { + url: selectedLink.getURL(), + text: selectedLink.getTextContent(), + title: selectedLink.getTitle(), + target: selectedLink.getTarget(), + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(selectedLink.getKey()); + $setSelection(selection); + }); + } + + linkModal.show(formDefaults); + }); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsNodeType(selection, $isLinkNode); diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index c8477d9f2..457efa421 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -1,19 +1,26 @@ -import {EditorFormDefinition, EditorFormFieldDefinition, EditorSelectFormFieldDefinition} from "../framework/forms"; +import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms"; import {EditorUiContext} from "../framework/core"; +import {$createLinkNode} from "@lexical/link"; +import {$createTextNode, $getSelection} from "lexical"; export const link: EditorFormDefinition = { submitText: 'Apply', - cancelText: 'Cancel', action(formData, context: EditorUiContext) { - // Todo - console.log('link-form-action', formData); + context.editor.update(() => { + + const selection = $getSelection(); + + const linkNode = $createLinkNode(formData.get('url')?.toString() || '', { + title: formData.get('title')?.toString() || '', + target: formData.get('target')?.toString() || '', + }); + linkNode.append($createTextNode(formData.get('text')?.toString() || '')); + + selection?.insertNodes([linkNode]); + }); return true; }, - cancel() { - // Todo - console.log('link-form-cancel'); - }, fields: [ { label: 'URL', diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 0fce73c12..c6338f798 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -15,9 +15,7 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti export interface EditorFormDefinition { submitText: string; - cancelText: string; action: (formData: FormData, context: EditorUiContext) => boolean; - cancel: () => void; fields: EditorFormFieldDefinition[]; } @@ -29,6 +27,15 @@ export class EditorFormField extends EditorUiElement { this.definition = definition; } + setValue(value: string) { + const input = this.getDOMElement().querySelector('input,select') as HTMLInputElement; + input.value = value; + } + + getName(): string { + return this.definition.name; + } + protected buildDOM(): HTMLElement { const id = `editor-form-field-${this.definition.name}-${Date.now()}`; let input: HTMLElement; @@ -51,14 +58,38 @@ export class EditorFormField extends EditorUiElement { export class EditorForm extends EditorContainerUiElement { protected definition: EditorFormDefinition; + protected onCancel: null|(() => void) = null; constructor(definition: EditorFormDefinition) { super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition))); this.definition = definition; } + setValues(values: Record) { + for (const name of Object.keys(values)) { + const field = this.getFieldByName(name); + if (field) { + field.setValue(values[name]); + } + } + } + + setOnCancel(callback: () => void) { + this.onCancel = callback; + } + + protected getFieldByName(name: string): EditorFormField|null { + for (const child of this.children as EditorFormField[]) { + if (child.getName() === name) { + return child; + } + } + + return null; + } + protected buildDOM(): HTMLElement { - const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans(this.definition.cancelText)]); + const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans('Cancel')]); const form = el('form', {}, [ ...this.children.map(child => child.getDOMElement()), el('div', {class: 'editor-form-actions'}, [ @@ -74,7 +105,9 @@ export class EditorForm extends EditorContainerUiElement { }); cancelButton.addEventListener('click', (event) => { - this.definition.cancel(); + if (this.onCancel) { + this.onCancel(); + } }); return form; diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index f1a34c92a..c3fe9ecd8 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,11 +1,38 @@ - - - +import {EditorFormModal, EditorFormModalDefinition} from "./modals"; +import {EditorUiContext} from "./core"; export class EditorUIManager { - // Todo - Register and show modal via this - // (Part of UI context) + protected modalDefinitionsByKey: Record = {}; + protected context: EditorUiContext|null = null; + + setContext(context: EditorUiContext) { + this.context = context; + } + + getContext(): EditorUiContext { + if (this.context === null) { + throw new Error(`Context attempted to be used without being set`); + } + + return this.context; + } + + registerModal(key: string, modalDefinition: EditorFormModalDefinition) { + this.modalDefinitionsByKey[key] = modalDefinition; + } + + createModal(key: string): EditorFormModal { + const modalDefinition = this.modalDefinitionsByKey[key]; + if (!modalDefinition) { + console.error(`Attempted to show modal of key [${key}] but no modal registered for that key`); + } + + const modal = new EditorFormModal(modalDefinition); + modal.setContext(this.getContext()); + + return modal; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts new file mode 100644 index 000000000..e2a6b3f33 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -0,0 +1,63 @@ +import {EditorForm, EditorFormDefinition} from "./forms"; +import {el} from "../../helpers"; +import {EditorContainerUiElement} from "./containers"; + + +export interface EditorModalDefinition { + title: string; +} + +export interface EditorFormModalDefinition extends EditorModalDefinition { + form: EditorFormDefinition; +} + +export class EditorFormModal extends EditorContainerUiElement { + protected definition: EditorFormModalDefinition; + + constructor(definition: EditorFormModalDefinition) { + super([new EditorForm(definition.form)]); + this.definition = definition; + } + + show(defaultValues: Record) { + const dom = this.getDOMElement(); + document.body.append(dom); + + const form = this.getForm(); + form.setValues(defaultValues); + form.setOnCancel(this.hide.bind(this)); + } + + hide() { + this.getDOMElement().remove(); + } + + protected getForm(): EditorForm { + return this.children[0] as EditorForm; + } + + protected buildDOM(): HTMLElement { + const closeButton = el('button', {class: 'editor-modal-close', type: 'button', title: this.trans('Close')}, ['x']); + closeButton.addEventListener('click', this.hide.bind(this)); + + const modal = el('div', {class: 'editor-modal editor-form-modal'}, [ + el('div', {class: 'editor-modal-header'}, [ + el('div', {class: 'editor-modal-title'}, [this.trans(this.definition.title)]), + closeButton, + ]), + el('div', {class: 'editor-modal-body'}, [ + this.getForm().getDOMElement(), + ]), + ]); + + const wrapper = el('div', {class: 'editor-modal-wrapper'}, [modal]); + + wrapper.addEventListener('click', event => { + if (event.target && !modal.contains(event.target as HTMLElement)) { + this.hide(); + } + }); + + return wrapper; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 5a0d7fd2d..7e1f8d981 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,8 +6,7 @@ import { } from "lexical"; import {getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {EditorForm} from "./framework/forms"; -import {link} from "./defaults/form-definitions"; +import {link as linkFormDefinition} from "./defaults/form-definitions"; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); @@ -16,16 +15,18 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { manager, translate: (text: string): string => text, }; + manager.setContext(context); // Create primary toolbar const toolbar = getMainEditorFullToolbar(); toolbar.setContext(context); element.before(toolbar.getDOMElement()); - // Form test - const linkForm = new EditorForm(link); - linkForm.setContext(context); - element.before(linkForm.getDOMElement()); + // Register modals + manager.registerModal('link', { + title: 'Insert/Edit link', + form: linkFormDefinition, + }); // Update button states on editor selection change editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 48912be8b..2633e8539 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -46,4 +46,30 @@ .editor-format-menu .editor-dropdown-menu { min-width: 320px; +} + +// Modals +.editor-modal-wrapper { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + background-color: rgba(0, 0, 0, 0.5); + width: 100%; + height: 100%; +} +.editor-modal { + background-color: #FFF; + border: 1px solid #DDD; + padding: 1rem; + border-radius: 4px; +} +.editor-modal-header { + display: flex; + justify-content: space-between; + margin-bottom: 1rem; +} +.editor-modal-title { + font-weight: 700; } \ No newline at end of file From a74e04141c7eee5daeb6a1a2e58de7c704b5020c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 3 Jun 2024 16:56:31 +0100 Subject: [PATCH 011/107] Lexical: Started build of image node and decoration UI --- resources/js/wysiwyg/nodes/image.ts | 174 ++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 2 + resources/js/wysiwyg/ui/framework/buttons.ts | 1 - resources/js/wysiwyg/ui/index.ts | 17 ++ .../pages/parts/wysiwyg-editor.blade.php | 3 +- 5 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/image.ts diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts new file mode 100644 index 000000000..1e2cbd83c --- /dev/null +++ b/resources/js/wysiwyg/nodes/image.ts @@ -0,0 +1,174 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, + DOMConversionOutput, + LexicalEditor, LexicalNode, + SerializedLexicalNode, + Spread +} from "lexical"; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {el} from "../helpers"; + +export interface ImageNodeOptions { + alt?: string; + width?: number; + height?: number; +} + +export type SerializedImageNode = Spread<{ + src: string; + alt: string; + width: number; + height: number; +}, SerializedLexicalNode> + +export class ImageNode extends DecoratorNode { + __src: string = ''; + __alt: string = ''; + __width: number = 0; + __height: number = 0; + // TODO - Alignment + + static getType(): string { + return 'image'; + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode(node.__src, { + alt: node.__alt, + width: node.__width, + height: node.__height, + }); + } + + constructor(src: string, options: ImageNodeOptions, key?: string) { + super(key); + this.__src = src; + if (options.alt) { + this.__alt = options.alt; + } + if (options.width) { + this.__width = options.width; + } + if (options.height) { + this.__height = options.height; + } + } + + setAltText(altText: string): void { + const self = this.getWritable(); + self.__alt = altText; + } + + getAltText(): string { + const self = this.getLatest(); + return self.__alt; + } + + setHeight(height: number): void { + const self = this.getWritable(); + self.__height = height; + } + + getHeight(): number { + const self = this.getLatest(); + return self.__height; + } + + setWidth(width: number): void { + const self = this.getWritable(); + self.__width = width; + } + + getWidth(): number { + const self = this.getLatest(); + return self.__width; + } + + isInline(): boolean { + return true; + } + + decorate(editor: LexicalEditor, config: EditorConfig): HTMLElement { + console.log('decorate!'); + return el('div', { + class: 'editor-image-decorator', + }, ['decoration!!!']); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const element = document.createElement('img'); + element.setAttribute('src', this.__src); + element.textContent + + if (this.__width) { + element.setAttribute('width', String(this.__width)); + } + if (this.__height) { + element.setAttribute('height', String(this.__height)); + } + if (this.__alt) { + element.setAttribute('alt', this.__alt); + } + return el('span', {class: 'editor-image-wrap'}, [ + element, + ]); + } + + updateDOM(prevNode: unknown, dom: HTMLElement) { + // Returning false tells Lexical that this node does not need its + // DOM element replacing with a new copy from createDOM. + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + img(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + + const src = element.getAttribute('src') || ''; + const options: ImageNodeOptions = { + alt: element.getAttribute('alt') || '', + height: Number.parseInt(element.getAttribute('height') || '0'), + width: Number.parseInt(element.getAttribute('width') || '0'), + } + + return { + node: new ImageNode(src, options), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedImageNode { + return { + type: 'image', + version: 1, + src: this.__src, + alt: this.__alt, + height: this.__height, + width: this.__width + }; + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + return $createImageNode(serializedNode.src, { + alt: serializedNode.alt, + width: serializedNode.width, + height: serializedNode.height, + }); + } +} + +export function $createImageNode(src: string, options: ImageNodeOptions = {}): ImageNode { + return new ImageNode(src, options); +} + +export function $isImageNode(node: LexicalNode | null | undefined) { + return node instanceof ImageNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 9f772df1e..1d492a87a 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -3,6 +3,7 @@ import {CalloutNode} from './callout'; import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; +import {ImageNode} from "./image"; /** * Load the nodes for lexical. @@ -12,6 +13,7 @@ export function getNodesForPageEditor(): (KlassConstructor | CalloutNode, // Todo - Create custom HeadingNode, // Todo - Create custom QuoteNode, // Todo - Create custom + ImageNode, CustomParagraphNode, { replace: ParagraphNode, diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 48046e9de..367a39330 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -67,7 +67,6 @@ export class FormatPreviewButton extends EditorButton { }, [this.getLabel()]); const stylesToApply = this.getStylesFromPreview(); - console.log(stylesToApply); for (const style of Object.keys(stylesToApply)) { preview.style.setProperty(style, stylesToApply[style]); } diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 7e1f8d981..9206f8b40 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -7,6 +7,9 @@ import { import {getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {link as linkFormDefinition} from "./defaults/form-definitions"; +import {DecoratorListener} from "lexical/LexicalEditor"; +import type {NodeKey} from "lexical/LexicalNode"; +import {el} from "../helpers"; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); @@ -28,6 +31,20 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { form: linkFormDefinition, }); + // Register decorator listener + // Maybe move to manager? + const domDecorateListener: DecoratorListener = (decorator: Record) => { + const keys = Object.keys(decorator); + for (const key of keys) { + const decoratedEl = editor.getElementByKey(key); + const decoratorEl = decorator[key]; + if (decoratedEl) { + decoratedEl.append(decoratorEl); + } + } + } + editor.registerDecoratorListener(domDecorateListener); + // Update button states on editor selection change editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { const selection = $getSelection(); diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 940c005b5..c0ceddc45 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -9,13 +9,14 @@

      Some content here

      +

      Content with image in, before text. Sleepy meow After text.

      This has a link in it

      List below this h2 header

      • Hello
      -

      +

      Hello there, this is an info callout

      From ba871ec46a7e363f3fad2032f18ec0612875e1ad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Jun 2024 13:04:49 +0100 Subject: [PATCH 012/107] Lexical: Started image resize controls, Defined thorough decorator model --- resources/js/wysiwyg/nodes/image.ts | 50 ++++++++-- resources/js/wysiwyg/ui/decorators/image.ts | 91 +++++++++++++++++++ .../js/wysiwyg/ui/framework/decorator.ts | 32 +++++++ resources/js/wysiwyg/ui/framework/manager.ts | 24 ++++- resources/js/wysiwyg/ui/index.ts | 13 ++- resources/sass/_editor.scss | 50 +++++++++- 6 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 resources/js/wysiwyg/ui/decorators/image.ts create mode 100644 resources/js/wysiwyg/ui/framework/decorator.ts diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index 1e2cbd83c..289e36c4b 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -9,6 +9,7 @@ import { } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../helpers"; +import {EditorDecoratorAdapter} from "../ui/framework/decorator"; export interface ImageNodeOptions { alt?: string; @@ -23,7 +24,7 @@ export type SerializedImageNode = Spread<{ height: number; }, SerializedLexicalNode> -export class ImageNode extends DecoratorNode { +export class ImageNode extends DecoratorNode { __src: string = ''; __alt: string = ''; __width: number = 0; @@ -79,6 +80,7 @@ export class ImageNode extends DecoratorNode { setWidth(width: number): void { const self = this.getWritable(); self.__width = width; + console.log('widrg', width) } getWidth(): number { @@ -90,17 +92,16 @@ export class ImageNode extends DecoratorNode { return true; } - decorate(editor: LexicalEditor, config: EditorConfig): HTMLElement { - console.log('decorate!'); - return el('div', { - class: 'editor-image-decorator', - }, ['decoration!!!']); + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'image', + getNode: () => this, + }; } createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('img'); element.setAttribute('src', this.__src); - element.textContent if (this.__width) { element.setAttribute('width', String(this.__width)); @@ -116,9 +117,38 @@ export class ImageNode extends DecoratorNode { ]); } - updateDOM(prevNode: unknown, dom: HTMLElement) { - // Returning false tells Lexical that this node does not need its - // DOM element replacing with a new copy from createDOM. + updateDOM(prevNode: ImageNode, dom: HTMLElement) { + const image = dom.querySelector('img'); + if (!image) return false; + + if (prevNode.__src !== this.__src) { + image.setAttribute('src', this.__src); + } + + if (prevNode.__width !== this.__width) { + if (this.__width) { + image.setAttribute('width', String(this.__width)); + } else { + image.removeAttribute('width'); + } + } + + if (prevNode.__height !== this.__height) { + if (this.__height) { + image.setAttribute('height', String(this.__height)); + } else { + image.removeAttribute('height'); + } + } + + if (prevNode.__alt !== this.__alt) { + if (this.__alt) { + image.setAttribute('alt', String(this.__alt)); + } else { + image.removeAttribute('alt'); + } + } + return false; } diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts new file mode 100644 index 000000000..fd333fa54 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -0,0 +1,91 @@ +import {EditorDecorator} from "../framework/decorator"; +import {el} from "../../helpers"; +import {$createNodeSelection, $setSelection} from "lexical"; +import {EditorUiContext} from "../framework/core"; +import {ImageNode} from "../../nodes/image"; + + +export class ImageDecorator extends EditorDecorator { + protected dom: HTMLElement|null = null; + + buildDOM(context: EditorUiContext) { + const handleClasses = ['nw', 'ne', 'se', 'sw']; + const handleEls = handleClasses.map(c => { + return el('div', {class: `editor-image-decorator-handle ${c}`}); + }); + + const decorateEl = el('div', { + class: 'editor-image-decorator', + }, handleEls); + + const windowClick = (event: MouseEvent) => { + if (!decorateEl.contains(event.target as Node)) { + unselect(); + } + }; + + const select = () => { + decorateEl.classList.add('selected'); + window.addEventListener('click', windowClick); + }; + + const unselect = () => { + decorateEl.classList.remove('selected'); + window.removeEventListener('click', windowClick); + }; + + decorateEl.addEventListener('click', (event) => { + context.editor.update(() => { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(this.getNode().getKey()); + $setSelection(nodeSelection); + }); + + select(); + }); + + decorateEl.addEventListener('mousedown', (event: MouseEvent) => { + const handle = (event.target as Element).closest('.editor-image-decorator-handle'); + if (handle) { + this.startHandlingResize(handle, event, context); + } + }); + + return decorateEl; + } + + render(context: EditorUiContext): HTMLElement { + if (this.dom) { + return this.dom; + } + + this.dom = this.buildDOM(context); + return this.dom; + } + + startHandlingResize(element: Node, event: MouseEvent, context: EditorUiContext) { + const startingX = event.screenX; + const startingY = event.screenY; + + const mouseMoveListener = (event: MouseEvent) => { + const xChange = event.screenX - startingX; + const yChange = event.screenY - startingY; + console.log({ xChange, yChange }); + + context.editor.update(() => { + const node = this.getNode() as ImageNode; + node.setWidth(node.getWidth() + xChange); + node.setHeight(node.getHeight() + yChange); + }); + }; + + const mouseUpListener = (event: MouseEvent) => { + window.removeEventListener('mousemove', mouseMoveListener); + window.removeEventListener('mouseup', mouseUpListener); + } + + window.addEventListener('mousemove', mouseMoveListener); + window.addEventListener('mouseup', mouseUpListener); + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts new file mode 100644 index 000000000..890774126 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -0,0 +1,32 @@ +import {EditorUiContext} from "./core"; +import {LexicalNode} from "lexical"; + +export interface EditorDecoratorAdapter { + type: string; + getNode(): LexicalNode; +} + +export abstract class EditorDecorator { + + protected node: LexicalNode | null = null; + protected context: EditorUiContext; + + constructor(context: EditorUiContext) { + this.context = context; + } + + protected getNode(): LexicalNode { + if (!this.node) { + throw new Error('Attempted to get use node without it being set'); + } + + return this.node; + } + + setNode(node: LexicalNode) { + this.node = node; + } + + abstract render(context: EditorUiContext): HTMLElement; + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index c3fe9ecd8..1684b6628 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,10 +1,13 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorUiContext} from "./core"; +import {EditorDecorator} from "./decorator"; export class EditorUIManager { protected modalDefinitionsByKey: Record = {}; + protected decoratorConstructorsByType: Record = {}; + protected decoratorInstancesByNodeKey: Record = {}; protected context: EditorUiContext|null = null; setContext(context: EditorUiContext) { @@ -26,7 +29,7 @@ export class EditorUIManager { createModal(key: string): EditorFormModal { const modalDefinition = this.modalDefinitionsByKey[key]; if (!modalDefinition) { - console.error(`Attempted to show modal of key [${key}] but no modal registered for that key`); + throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`); } const modal = new EditorFormModal(modalDefinition); @@ -35,4 +38,23 @@ export class EditorUIManager { return modal; } + registerDecoratorType(type: string, decorator: typeof EditorDecorator) { + this.decoratorConstructorsByType[type] = decorator; + } + + getDecorator(decoratorType: string, nodeKey: string): EditorDecorator { + if (this.decoratorInstancesByNodeKey[nodeKey]) { + return this.decoratorInstancesByNodeKey[nodeKey]; + } + + const decoratorClass = this.decoratorConstructorsByType[decoratorType]; + if (!decoratorClass) { + throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`); + } + + // @ts-ignore + const decorator = new decoratorClass(nodeKey); + this.decoratorInstancesByNodeKey[nodeKey] = decorator; + return decorator; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 9206f8b40..19320b262 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -9,7 +9,8 @@ import {EditorUIManager} from "./framework/manager"; import {link as linkFormDefinition} from "./defaults/form-definitions"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; -import {el} from "../helpers"; +import {EditorDecoratorAdapter} from "./framework/decorator"; +import {ImageDecorator} from "./decorators/image"; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); @@ -33,11 +34,15 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { // Register decorator listener // Maybe move to manager? - const domDecorateListener: DecoratorListener = (decorator: Record) => { - const keys = Object.keys(decorator); + manager.registerDecoratorType('image', ImageDecorator); + const domDecorateListener: DecoratorListener = (decorators: Record) => { + const keys = Object.keys(decorators); for (const key of keys) { const decoratedEl = editor.getElementByKey(key); - const decoratorEl = decorator[key]; + const adapter = decorators[key]; + const decorator = manager.getDecorator(adapter.type, key); + decorator.setNode(adapter.getNode()); + const decoratorEl = decorator.render(context); if (decoratedEl) { decoratedEl.append(decoratorEl); } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 2633e8539..94fe2c756 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -1,3 +1,8 @@ +// Common variables +:root { + --editor-color-primary: #206ea7; +} + // Main UI elements .editor-toolbar-main { display: flex; @@ -72,4 +77,47 @@ } .editor-modal-title { font-weight: 700; -} \ No newline at end of file +} + +// In-editor elements +.editor-image-wrap { + position: relative; + display: inline-flex; +} +.editor-image-decorator { + display: inline-block; + position: absolute; + border: 1px solid var(--editor-color-primary); + left: 0; + right: 0; + width: 100%; + height: 100%; +} +.editor-image-decorator-handle { + position: absolute; + display: block; + width: 10px; + height: 10px; + background-color: var(--editor-color-primary); + user-select: none; + &.nw { + inset-inline-start: -5px; + inset-block-start: -5px; + cursor: nw-resize; + } + &.ne { + inset-inline-end: -5px; + inset-block-start: -5px; + cursor: ne-resize; + } + &.se { + inset-inline-end: -5px; + inset-block-end: -5px; + cursor: se-resize; + } + &.sw { + inset-inline-start: -5px; + inset-block-end: -5px; + cursor: sw-resize; + } +} From e959c468f664b937925d39d0188dd94db546d2cb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Jun 2024 17:18:58 +0100 Subject: [PATCH 013/107] Lexical: Made image resize handles functional --- resources/js/wysiwyg/nodes/image.ts | 1 - resources/js/wysiwyg/ui/decorators/image.ts | 101 ++++++++++++++------ resources/sass/_editor.scss | 9 +- 3 files changed, 80 insertions(+), 31 deletions(-) diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index 289e36c4b..9f017b5fe 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -80,7 +80,6 @@ export class ImageNode extends DecoratorNode { setWidth(width: number): void { const self = this.getWritable(); self.__width = width; - console.log('widrg', width) } getWidth(): number { diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index fd333fa54..b3ceee65f 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -7,48 +7,65 @@ import {ImageNode} from "../../nodes/image"; export class ImageDecorator extends EditorDecorator { protected dom: HTMLElement|null = null; + protected dragLastMouseUp: number = 0; buildDOM(context: EditorUiContext) { - const handleClasses = ['nw', 'ne', 'se', 'sw']; - const handleEls = handleClasses.map(c => { - return el('div', {class: `editor-image-decorator-handle ${c}`}); - }); - + let handleElems: HTMLElement[] = []; const decorateEl = el('div', { class: 'editor-image-decorator', - }, handleEls); + }, []); + let selected = false; const windowClick = (event: MouseEvent) => { - if (!decorateEl.contains(event.target as Node)) { + if (!decorateEl.contains(event.target as Node) && (Date.now() - this.dragLastMouseUp > 100)) { unselect(); } }; + const mouseDown = (event: MouseEvent) => { + const handle = (event.target as HTMLElement).closest('.editor-image-decorator-handle') as HTMLElement|null; + if (handle) { + // handlingResize = true; + this.startHandlingResize(handle, event, context); + } + }; + const select = () => { + if (selected) { + return; + } + + selected = true; decorateEl.classList.add('selected'); window.addEventListener('click', windowClick); - }; - const unselect = () => { - decorateEl.classList.remove('selected'); - window.removeEventListener('click', windowClick); - }; + const handleClasses = ['nw', 'ne', 'se', 'sw']; + handleElems = handleClasses.map(c => { + return el('div', {class: `editor-image-decorator-handle ${c}`}); + }); + decorateEl.append(...handleElems); + decorateEl.addEventListener('mousedown', mouseDown); - decorateEl.addEventListener('click', (event) => { context.editor.update(() => { const nodeSelection = $createNodeSelection(); nodeSelection.add(this.getNode().getKey()); $setSelection(nodeSelection); }); + }; - select(); - }); - - decorateEl.addEventListener('mousedown', (event: MouseEvent) => { - const handle = (event.target as Element).closest('.editor-image-decorator-handle'); - if (handle) { - this.startHandlingResize(handle, event, context); + const unselect = () => { + selected = false; + // handlingResize = false; + decorateEl.classList.remove('selected'); + window.removeEventListener('click', windowClick); + decorateEl.removeEventListener('mousedown', mouseDown); + for (const el of handleElems) { + el.remove(); } + }; + + decorateEl.addEventListener('click', (event) => { + select(); }); return decorateEl; @@ -63,26 +80,56 @@ export class ImageDecorator extends EditorDecorator { return this.dom; } - startHandlingResize(element: Node, event: MouseEvent, context: EditorUiContext) { + startHandlingResize(element: HTMLElement, event: MouseEvent, context: EditorUiContext) { const startingX = event.screenX; const startingY = event.screenY; + const node = this.getNode() as ImageNode; + let startingWidth = element.clientWidth; + let startingHeight = element.clientHeight; + let startingRatio = startingWidth / startingHeight; + let hasHeight = false; + context.editor.getEditorState().read(() => { + startingWidth = node.getWidth() || startingWidth; + startingHeight = node.getHeight() || startingHeight; + if (node.getHeight()) { + hasHeight = true; + } + startingRatio = startingWidth / startingHeight; + }); + + const flipXChange = element.classList.contains('nw') || element.classList.contains('sw'); + const flipYChange = element.classList.contains('nw') || element.classList.contains('ne'); const mouseMoveListener = (event: MouseEvent) => { - const xChange = event.screenX - startingX; - const yChange = event.screenY - startingY; - console.log({ xChange, yChange }); + let xChange = event.screenX - startingX; + if (flipXChange) { + xChange = 0 - xChange; + } + let yChange = event.screenY - startingY; + if (flipYChange) { + yChange = 0 - yChange; + } + const balancedChange = Math.sqrt(Math.pow(xChange, 2) + Math.pow(yChange, 2)); + const increase = xChange + yChange > 0; + const directedChange = increase ? balancedChange : 0-balancedChange; + const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); + let newHeight = 0; + if (hasHeight) { + newHeight = newWidth * startingRatio; + } context.editor.update(() => { const node = this.getNode() as ImageNode; - node.setWidth(node.getWidth() + xChange); - node.setHeight(node.getHeight() + yChange); + node.setWidth(newWidth); + node.setHeight(newHeight); }); }; const mouseUpListener = (event: MouseEvent) => { window.removeEventListener('mousemove', mouseMoveListener); window.removeEventListener('mouseup', mouseUpListener); - } + this.dragLastMouseUp = Date.now(); + }; window.addEventListener('mousemove', mouseMoveListener); window.addEventListener('mouseup', mouseUpListener); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 94fe2c756..87cc70c9b 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -85,20 +85,23 @@ display: inline-flex; } .editor-image-decorator { - display: inline-block; position: absolute; - border: 1px solid var(--editor-color-primary); left: 0; right: 0; width: 100%; height: 100%; + display: inline-block; + &.selected { + border: 1px dashed var(--editor-color-primary); + } } .editor-image-decorator-handle { position: absolute; display: block; width: 10px; height: 10px; - background-color: var(--editor-color-primary); + border: 2px solid var(--editor-color-primary); + background-color: #FFF; user-select: none; &.nw { inset-inline-start: -5px; From 0722960260d740e7e0c00b9f6da13992688a8752 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Jun 2024 18:43:42 +0100 Subject: [PATCH 014/107] Lexical: Added selection to state for aligned reading Connected up to work with image form --- resources/js/wysiwyg/nodes/image.ts | 10 +++++ resources/js/wysiwyg/ui/decorators/image.ts | 7 +++- .../wysiwyg/ui/defaults/button-definitions.ts | 33 ++++++++++++++++ .../wysiwyg/ui/defaults/form-definitions.ts | 39 +++++++++++++++++++ resources/js/wysiwyg/ui/framework/core.ts | 1 + resources/js/wysiwyg/ui/index.ts | 11 +++++- resources/js/wysiwyg/ui/toolbars.ts | 3 +- 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index 9f017b5fe..92d5518db 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -57,6 +57,16 @@ export class ImageNode extends DecoratorNode { } } + setSrc(src: string): void { + const self = this.getWritable(); + self.__src = src; + } + + getSrc(): string { + const self = this.getLatest(); + return self.__src; + } + setAltText(altText: string): void { const self = this.getWritable(); self.__alt = altText; diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index b3ceee65f..1692d078d 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -88,6 +88,7 @@ export class ImageDecorator extends EditorDecorator { let startingHeight = element.clientHeight; let startingRatio = startingWidth / startingHeight; let hasHeight = false; + let firstChange = true; context.editor.getEditorState().read(() => { startingWidth = node.getWidth() || startingWidth; startingHeight = node.getHeight() || startingHeight; @@ -109,7 +110,7 @@ export class ImageDecorator extends EditorDecorator { if (flipYChange) { yChange = 0 - yChange; } - const balancedChange = Math.sqrt(Math.pow(xChange, 2) + Math.pow(yChange, 2)); + const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); const increase = xChange + yChange > 0; const directedChange = increase ? balancedChange : 0-balancedChange; const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); @@ -118,11 +119,13 @@ export class ImageDecorator extends EditorDecorator { newHeight = newWidth * startingRatio; } + const updateOptions = firstChange ? {} : {tag: 'history-merge'}; context.editor.update(() => { const node = this.getNode() as ImageNode; node.setWidth(newWidth); node.setHeight(newHeight); - }); + }, updateOptions); + firstChange = false; }; const mouseUpListener = (event: MouseEvent) => { diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index f5be82519..92f0cfc81 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -25,6 +25,7 @@ import { } from "@lexical/rich-text"; import {$isLinkNode, $toggleLink, LinkNode} from "@lexical/link"; import {EditorUiContext} from "../framework/core"; +import {$isImageNode, ImageNode} from "../../nodes/image"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -168,3 +169,35 @@ export const link: EditorButtonDefinition = { } }; +export const image: EditorButtonDefinition = { + label: 'Insert/Edit Image', + action(context: EditorUiContext) { + const imageModal = context.manager.createModal('image'); + const selection = context.lastSelection; + const selectedImage = getNodeFromSelection(selection, $isImageNode) as ImageNode|null; + + context.editor.getEditorState().read(() => { + let formDefaults = {}; + if (selectedImage) { + formDefaults = { + src: selectedImage.getSrc(), + alt: selectedImage.getAltText(), + height: selectedImage.getHeight(), + width: selectedImage.getWidth(), + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(selectedImage.getKey()); + $setSelection(selection); + }); + } + + imageModal.show(formDefaults); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isImageNode); + } +}; + diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index 457efa421..13e7a9c9f 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -2,6 +2,7 @@ import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framewor import {EditorUiContext} from "../framework/core"; import {$createLinkNode} from "@lexical/link"; import {$createTextNode, $getSelection} from "lexical"; +import {$createImageNode} from "../../nodes/image"; export const link: EditorFormDefinition = { @@ -47,4 +48,42 @@ export const link: EditorFormDefinition = { } } as EditorSelectFormFieldDefinition, ], +}; + +export const image: EditorFormDefinition = { + submitText: 'Apply', + 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', + }, + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 68d845b42..2fdadcb40 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -10,6 +10,7 @@ export type EditorUiContext = { editor: LexicalEditor, translate: (text: string) => string, manager: EditorUIManager, + lastSelection: BaseSelection|null, }; export abstract class EditorUiElement { diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 19320b262..8227dec68 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,18 +6,20 @@ import { } from "lexical"; import {getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {link as linkFormDefinition} from "./defaults/form-definitions"; +import {image as imageFormDefinition, link as linkFormDefinition} from "./defaults/form-definitions"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorDecoratorAdapter} from "./framework/decorator"; import {ImageDecorator} from "./decorators/image"; +import {EditorUiContext} from "./framework/core"; export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); - const context = { + const context: EditorUiContext = { editor, manager, translate: (text: string): string => text, + lastSelection: null, }; manager.setContext(context); @@ -31,6 +33,10 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { title: 'Insert/Edit link', form: linkFormDefinition, }); + manager.registerModal('image', { + title: 'Insert/Edit Image', + form: imageFormDefinition + }) // Register decorator listener // Maybe move to manager? @@ -54,6 +60,7 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { const selection = $getSelection(); toolbar.updateState({editor, selection}); + context.lastSelection = 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 index 2d5063cf4..2802b1ca7 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -2,7 +2,7 @@ import {EditorButton, FormatPreviewButton} from "./framework/buttons"; import { blockquote, bold, code, dangerCallout, - h2, h3, h4, h5, + h2, h3, h4, h5, image, infoCallout, italic, link, paragraph, redo, strikethrough, subscript, successCallout, superscript, underline, @@ -40,5 +40,6 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(code), new EditorButton(link), + new EditorButton(image), ]); } \ No newline at end of file From 5c343638b67497cc1229aa4acdedff199553a7e4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Jun 2024 14:43:50 +0100 Subject: [PATCH 015/107] Added base node/button for details/summary --- resources/js/wysiwyg/nodes/details.ts | 120 ++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 2 + .../wysiwyg/ui/defaults/button-definitions.ts | 33 ++++- resources/js/wysiwyg/ui/toolbars.ts | 3 +- .../pages/parts/wysiwyg-editor.blade.php | 7 + 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/details.ts diff --git a/resources/js/wysiwyg/nodes/details.ts b/resources/js/wysiwyg/nodes/details.ts new file mode 100644 index 000000000..a18c4d858 --- /dev/null +++ b/resources/js/wysiwyg/nodes/details.ts @@ -0,0 +1,120 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode, +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {el} from "../helpers"; + +export class DetailsNode extends ElementNode { + + static getType() { + return 'details'; + } + + static clone(node: DetailsNode) { + return new DetailsNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + return el('details'); + } + + updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + details(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return { + node: new DetailsNode(), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'details', + version: 1, + }; + } + + static importJSON(serializedNode: SerializedElementNode): DetailsNode { + return $createDetailsNode(); + } + +} + +export function $createDetailsNode() { + return new DetailsNode(); +} + +export function $isDetailsNode(node: LexicalNode | null | undefined) { + return node instanceof DetailsNode; +} + +export class SummaryNode extends ElementNode { + + static getType() { + return 'summary'; + } + + static clone(node: SummaryNode) { + return new SummaryNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + return el('summary'); + } + + updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + summary(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return { + node: new SummaryNode(), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'summary', + version: 1, + }; + } + + static importJSON(serializedNode: SerializedElementNode): DetailsNode { + return $createSummaryNode(); + } + +} + +export function $createSummaryNode() { + return new SummaryNode(); +} + +export function $isSummaryNode(node: LexicalNode | null | undefined) { + return node instanceof SummaryNode; +} diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 1d492a87a..f47575bc5 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -4,6 +4,7 @@ import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, Para import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; +import {DetailsNode, SummaryNode} from "./details"; /** * Load the nodes for lexical. @@ -14,6 +15,7 @@ export function getNodesForPageEditor(): (KlassConstructor | HeadingNode, // Todo - Create custom QuoteNode, // Todo - Create custom ImageNode, + DetailsNode, SummaryNode, CustomParagraphNode, { replace: ParagraphNode, diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 92f0cfc81..e549e69a2 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,9 +1,9 @@ import {EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, - $createParagraphNode, $getSelection, + $createParagraphNode, $getRoot, $getSelection, $insertNodes, $isParagraphNode, $setSelection, - BaseSelection, FORMAT_TEXT_COMMAND, + BaseSelection, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, REDO_COMMAND, TextFormatType, UNDO_COMMAND @@ -26,6 +26,8 @@ import { import {$isLinkNode, $toggleLink, LinkNode} from "@lexical/link"; import {EditorUiContext} from "../framework/core"; import {$isImageNode, ImageNode} from "../../nodes/image"; +import {$createDetailsNode, $isDetailsNode} from "../../nodes/details"; +import {$insertNodeToNearestRoot} from "@lexical/utils"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -201,3 +203,30 @@ export const image: EditorButtonDefinition = { } }; +export const details: EditorButtonDefinition = { + label: 'Insert collapsible block', + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + const detailsNode = $createDetailsNode(); + const selectionNodes = selection?.getNodes() || []; + const topLevels = selectionNodes.map(n => n.getTopLevelElement()) + .filter(n => n !== null) as ElementNode[]; + const uniqueTopLevels = [...new Set(topLevels)]; + + if (uniqueTopLevels.length > 0) { + uniqueTopLevels[0].insertAfter(detailsNode); + } else { + $getRoot().append(detailsNode); + } + + for (const node of uniqueTopLevels) { + detailsNode.append(node); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isDetailsNode); + } +} + diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 2802b1ca7..b5d151fc1 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,7 +1,7 @@ import {EditorButton, FormatPreviewButton} from "./framework/buttons"; import { blockquote, bold, code, - dangerCallout, + dangerCallout, details, h2, h3, h4, h5, image, infoCallout, italic, link, paragraph, redo, strikethrough, subscript, @@ -41,5 +41,6 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(link), new EditorButton(image), + new EditorButton(details), ]); } \ 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 c0ceddc45..641402769 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -16,6 +16,13 @@
    • Hello
    +
    + Collapsible details/summary block +

    Inner text here

    +

    Inner Header

    +

    More text with bold in it

    +
    +

    Hello there, this is an info callout

    From e889bc680b9ff5399a6a3e9fc3c89cd7127d4af2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 12 Jun 2024 14:01:36 +0100 Subject: [PATCH 016/107] Lexical: Added view/edit source code button/form/action --- resources/js/wysiwyg/actions.ts | 26 +++++++++++++++++++ resources/js/wysiwyg/index.ts | 12 +++------ .../wysiwyg/ui/defaults/button-definitions.ts | 12 +++++++++ .../wysiwyg/ui/defaults/form-definitions.ts | 16 ++++++++++++ resources/js/wysiwyg/ui/framework/forms.ts | 6 +++-- resources/js/wysiwyg/ui/index.ts | 8 ++++-- resources/js/wysiwyg/ui/toolbars.ts | 4 ++- 7 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 resources/js/wysiwyg/actions.ts diff --git a/resources/js/wysiwyg/actions.ts b/resources/js/wysiwyg/actions.ts new file mode 100644 index 000000000..bca31e51b --- /dev/null +++ b/resources/js/wysiwyg/actions.ts @@ -0,0 +1,26 @@ +import {$getRoot, LexicalEditor} from "lexical"; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from "@lexical/html"; + + +export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + + editor.update(() => { + const nodes = $generateNodesFromDOM(editor, dom); + const root = $getRoot(); + for (const child of root.getChildren()) { + child.remove(true); + } + root.append(...nodes); + }); +} + +export function getEditorContentAsHtml(editor: LexicalEditor): Promise { + return new Promise((resolve, reject) => { + editor.getEditorState().read(() => { + const html = $generateHtmlFromNodes(editor); + resolve(html); + }); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 0dcbf27f5..41207b706 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,10 +1,10 @@ -import {$getRoot, createEditor, CreateEditorArgs} from 'lexical'; +import {createEditor, CreateEditorArgs} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; -import {$generateNodesFromDOM} from '@lexical/html'; import {getNodesForPageEditor} from './nodes'; import {buildEditorUI} from "./ui"; +import {setEditorContentFromHtml} from "./actions"; export function createPageEditorInstance(editArea: HTMLElement) { const config: CreateEditorArgs = { @@ -14,8 +14,6 @@ export function createPageEditorInstance(editArea: HTMLElement) { }; const startingHtml = editArea.innerHTML; - const parser = new DOMParser(); - const dom = parser.parseFromString(startingHtml, 'text/html'); const editor = createEditor(config); editor.setRootElement(editArea); @@ -25,11 +23,7 @@ export function createPageEditorInstance(editArea: HTMLElement) { registerHistory(editor, createEmptyHistoryState(), 300), ); - editor.update(() => { - const startingNodes = $generateNodesFromDOM(editor, dom); - const root = $getRoot(); - root.append(...startingNodes); - }); + setEditorContentFromHtml(editor, startingHtml); const debugView = document.getElementById('lexical-debug'); editor.registerUpdateListener(({editorState}) => { diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index e549e69a2..077bcae21 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -28,6 +28,7 @@ import {EditorUiContext} from "../framework/core"; import {$isImageNode, ImageNode} from "../../nodes/image"; import {$createDetailsNode, $isDetailsNode} from "../../nodes/details"; import {$insertNodeToNearestRoot} from "@lexical/utils"; +import {getEditorContentAsHtml} from "../../actions"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -230,3 +231,14 @@ export const details: EditorButtonDefinition = { } } +export const source: EditorButtonDefinition = { + label: 'Source code', + async action(context: EditorUiContext) { + const modal = context.manager.createModal('source'); + const source = await getEditorContentAsHtml(context.editor); + modal.show({source}); + }, + isActive() { + return false; + } +}; diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index 13e7a9c9f..04147a4f0 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -3,6 +3,7 @@ import {EditorUiContext} from "../framework/core"; import {$createLinkNode} from "@lexical/link"; import {$createTextNode, $getSelection} from "lexical"; import {$createImageNode} from "../../nodes/image"; +import {setEditorContentFromHtml} from "../../actions"; export const link: EditorFormDefinition = { @@ -86,4 +87,19 @@ export const image: EditorFormDefinition = { type: 'text', }, ], +}; + +export const source: EditorFormDefinition = { + submitText: 'Save', + 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/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index c6338f798..a7fcb45ba 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -5,7 +5,7 @@ import {el} from "../../helpers"; export interface EditorFormFieldDefinition { label: string; name: string; - type: 'text' | 'select'; + type: 'text' | 'select' | 'textarea'; } export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { @@ -28,7 +28,7 @@ export class EditorFormField extends EditorUiElement { } setValue(value: string) { - const input = this.getDOMElement().querySelector('input,select') as HTMLInputElement; + const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; input.value = value; } @@ -45,6 +45,8 @@ export class EditorFormField extends EditorUiElement { const labels = Object.keys(options); const optionElems = labels.map(label => el('option', {value: options[label]}, [label])); input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); + } else if (this.definition.type === 'textarea') { + input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'}); } else { input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); } diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 8227dec68..b2fd7e05a 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,7 +6,7 @@ import { } from "lexical"; import {getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {image as imageFormDefinition, link as linkFormDefinition} from "./defaults/form-definitions"; +import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorDecoratorAdapter} from "./framework/decorator"; @@ -36,7 +36,11 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { manager.registerModal('image', { title: 'Insert/Edit Image', form: imageFormDefinition - }) + }); + manager.registerModal('source', { + title: 'Source code', + form: sourceFormDefinition, + }); // Register decorator listener // Maybe move to manager? diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index b5d151fc1..63ff8a053 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -4,7 +4,7 @@ import { dangerCallout, details, h2, h3, h4, h5, image, infoCallout, italic, link, paragraph, - redo, strikethrough, subscript, + redo, source, strikethrough, subscript, successCallout, superscript, underline, undo, warningCallout @@ -42,5 +42,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(link), new EditorButton(image), new EditorButton(details), + + new EditorButton(source), ]); } \ No newline at end of file From a475cf68bf7670c4cbf76758f5ead4c129586f13 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 12 Jun 2024 14:24:50 +0100 Subject: [PATCH 017/107] Lexical: Added clear formatting button --- .../wysiwyg/ui/defaults/button-definitions.ts | 19 +++++++++++++++++-- resources/js/wysiwyg/ui/toolbars.ts | 8 +++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 077bcae21..2e7cc6821 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -2,7 +2,7 @@ import {EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, $createParagraphNode, $getRoot, $getSelection, $insertNodes, - $isParagraphNode, $setSelection, + $isParagraphNode, $isTextNode, $setSelection, BaseSelection, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, REDO_COMMAND, TextFormatType, @@ -137,7 +137,22 @@ export const strikethrough: EditorButtonDefinition = buildFormatButton('Striketh 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 clearFormating: EditorButtonDefinition = { + label: 'Clear formatting', + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + for (const node of selection?.getNodes() || []) { + if ($isTextNode(node)) { + node.setFormat(0); + } + } + }); + }, + isActive() { + return false; + } +}; export const link: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 63ff8a053..337266617 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,6 +1,6 @@ import {EditorButton, FormatPreviewButton} from "./framework/buttons"; import { - blockquote, bold, code, + blockquote, bold, clearFormating, code, dangerCallout, details, h2, h3, h4, h5, image, infoCallout, italic, link, paragraph, @@ -15,9 +15,11 @@ import {el} from "../helpers"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ + // History state new EditorButton(undo), new EditorButton(redo), + // Block formats new EditorFormatMenu([ new FormatPreviewButton(el('h2'), h2), new FormatPreviewButton(el('h3'), h3), @@ -31,6 +33,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new FormatPreviewButton(el('p', {class: 'callout danger'}), dangerCallout), ]), + // Inline formats new EditorButton(bold), new EditorButton(italic), new EditorButton(underline), @@ -38,11 +41,14 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(superscript), new EditorButton(subscript), new EditorButton(code), + new EditorButton(clearFormating), + // Insert types new EditorButton(link), new EditorButton(image), new EditorButton(details), + // Meta elements new EditorButton(source), ]); } \ No newline at end of file From 9e43e03db4fbbb95f0219124162c9318c9c31531 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 12 Jun 2024 19:51:42 +0100 Subject: [PATCH 018/107] Lexical: Added color picker controls --- .../wysiwyg/ui/defaults/button-definitions.ts | 11 +- .../ui/framework/blocks/color-picker.ts | 90 ++++++++++++++ .../ui/framework/blocks/dropdown-button.ts | 51 ++++++++ .../ui/framework/blocks/format-menu.ts | 47 ++++++++ .../framework/blocks/format-preview-button.ts | 47 ++++++++ resources/js/wysiwyg/ui/framework/buttons.ts | 50 +------- .../js/wysiwyg/ui/framework/containers.ts | 113 ------------------ resources/js/wysiwyg/ui/framework/core.ts | 49 +++++++- resources/js/wysiwyg/ui/framework/forms.ts | 3 +- .../wysiwyg/ui/framework/helpers/dropdowns.ts | 34 ++++++ resources/js/wysiwyg/ui/framework/manager.ts | 9 +- resources/js/wysiwyg/ui/framework/modals.ts | 2 +- resources/js/wysiwyg/ui/toolbars.ts | 18 ++- resources/sass/_editor.scss | 16 +++ 14 files changed, 367 insertions(+), 173 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/color-picker.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/format-menu.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts delete mode 100644 resources/js/wysiwyg/ui/framework/containers.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 2e7cc6821..d8c7f515c 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,11 +1,11 @@ -import {EditorButtonDefinition} from "../framework/buttons"; +import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, $createParagraphNode, $getRoot, $getSelection, $insertNodes, $isParagraphNode, $isTextNode, $setSelection, BaseSelection, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, - REDO_COMMAND, TextFormatType, + REDO_COMMAND, TextFormatType, TextNode, UNDO_COMMAND } from "lexical"; import { @@ -131,8 +131,9 @@ function buildFormatButton(label: string, format: TextFormatType): EditorButtonD 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 textColor: EditorBasicButtonDefinition = {label: 'Text color'}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color'}; + export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough'); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript'); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript'); @@ -256,4 +257,4 @@ export const source: EditorButtonDefinition = { isActive() { return false; } -}; +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts new file mode 100644 index 000000000..6972d7a8e --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -0,0 +1,90 @@ +import {el} from "../../../helpers"; +import {EditorUiElement} from "../core"; +import {$getSelection} from "lexical"; +import {$patchStyleText} from "@lexical/selection"; + +const colorChoices = [ + '#000000', + '#ffffff', + + '#BFEDD2', + '#FBEEB8', + '#F8CAC6', + '#ECCAFA', + '#C2E0F4', + + '#2DC26B', + '#F1C40F', + '#E03E2D', + '#B96AD9', + '#3598DB', + + '#169179', + '#E67E23', + '#BA372A', + '#843FA1', + '#236FA1', + + '#ECF0F1', + '#CED4D9', + '#95A5A6', + '#7E8C8D', + '#34495E', +]; + +export class EditorColorPicker extends EditorUiElement { + + protected styleProperty: string; + + constructor(styleProperty: string) { + super(); + this.styleProperty = styleProperty; + } + + buildDOM(): HTMLElement { + + const colorOptions = colorChoices.map(choice => { + return el('div', { + class: 'editor-color-select-option', + style: `background-color: ${choice}`, + 'data-color': choice, + 'aria-label': choice, + }); + }); + + colorOptions.push(el('div', { + class: 'editor-color-select-option', + 'data-color': '', + title: 'Clear color', + }, ['x'])); + + const colorRows = []; + for (let i = 0; i < colorOptions.length; i+=5) { + const options = colorOptions.slice(i, i + 5); + colorRows.push(el('div', { + class: 'editor-color-select-row', + }, options)); + } + + const wrapper = el('div', { + class: 'editor-color-select', + }, colorRows); + + wrapper.addEventListener('click', this.onClick.bind(this)); + + return wrapper; + } + + onClick(event: MouseEvent) { + const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement; + if (!colorEl) return; + + const color = colorEl.dataset.color as string; + this.getContext().editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[this.styleProperty]: color || null}); + } + }); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts new file mode 100644 index 000000000..199c7728d --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -0,0 +1,51 @@ +import {el} from "../../../helpers"; +import {handleDropdown} from "../helpers/dropdowns"; +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; + +export class EditorDropdownButton extends EditorContainerUiElement { + protected button: EditorButton; + protected childItems: EditorUiElement[]; + protected open: boolean = false; + + constructor(buttonDefinition: EditorBasicButtonDefinition, children: EditorUiElement[]) { + super(children); + this.childItems = children + + this.button = new EditorButton({ + ...buttonDefinition, + action() { + return false; + }, + isActive: () => { + return this.open; + } + }); + + this.children.push(this.button); + } + + protected buildDOM(): HTMLElement { + const button = this.button.getDOMElement(); + + const childElements: HTMLElement[] = this.childItems.map(child => child.getDOMElement()); + const menu = el('div', { + class: 'editor-dropdown-menu', + hidden: 'true', + }, childElements); + + const wrapper = el('div', { + class: 'editor-dropdown-menu-container', + }, [button, menu]); + + handleDropdown(button, menu, () => { + this.open = true; + this.getContext().manager.triggerStateUpdate(this.button); + }, () => { + this.open = false; + this.getContext().manager.triggerStateUpdate(this.button); + }); + + return wrapper; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts new file mode 100644 index 000000000..bcd61e45c --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -0,0 +1,47 @@ +import {el} from "../../../helpers"; +import {EditorUiStateUpdate, EditorContainerUiElement} from "../core"; +import {EditorButton} from "../buttons"; +import {handleDropdown} from "../helpers/dropdowns"; + +export class EditorFormatMenu extends EditorContainerUiElement { + buildDOM(): HTMLElement { + const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); + const menu = el('div', { + class: 'editor-format-menu-dropdown editor-dropdown-menu editor-menu-list', + hidden: 'true', + }, childElements); + + const toggle = el('button', { + class: 'editor-format-menu-toggle editor-button', + type: 'button', + }, [this.trans('Formats')]); + + const wrapper = el('div', { + class: 'editor-format-menu editor-dropdown-menu-container', + }, [toggle, menu]); + + handleDropdown(toggle, menu); + + return wrapper; + } + + updateState(state: EditorUiStateUpdate) { + super.updateState(state); + + for (const child of this.children) { + if (child instanceof EditorButton && child.isActive()) { + this.updateToggleLabel(child.getLabel()); + return; + } + } + + this.updateToggleLabel(this.trans('Formats')); + } + + protected updateToggleLabel(text: string): void { + const button = this.getDOMElement().querySelector('button'); + if (button) { + button.innerText = text; + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts new file mode 100644 index 000000000..f83035aa6 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts @@ -0,0 +1,47 @@ +import {el} from "../../../helpers"; +import {EditorButton, EditorButtonDefinition} from "../buttons"; + +export class FormatPreviewButton extends EditorButton { + protected previewSampleElement: HTMLElement; + + constructor(previewSampleElement: HTMLElement,definition: EditorButtonDefinition) { + super(definition); + this.previewSampleElement = previewSampleElement; + } + + protected buildDOM(): HTMLButtonElement { + const button = super.buildDOM(); + button.innerHTML = ''; + + const preview = el('span', { + class: 'editor-button-format-preview' + }, [this.getLabel()]); + + const stylesToApply = this.getStylesFromPreview(); + for (const style of Object.keys(stylesToApply)) { + preview.style.setProperty(style, stylesToApply[style]); + } + + button.append(preview); + return button; + } + + protected getStylesFromPreview(): Record { + const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); + const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; + sampleClone.textContent = this.getLabel(); + wrap.append(sampleClone); + document.body.append(wrap); + + const propertiesToFetch = ['color', 'font-size', 'background-color', 'border-inline-start']; + const propertiesToReturn: Record = {}; + + const computed = window.getComputedStyle(sampleClone); + for (const property of propertiesToFetch) { + propertiesToReturn[property] = computed.getPropertyValue(property); + } + wrap.remove(); + + return propertiesToReturn; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 367a39330..c3ba533b3 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -2,8 +2,11 @@ import {BaseSelection} from "lexical"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; -export interface EditorButtonDefinition { +export interface EditorBasicButtonDefinition { label: string; +} + +export interface EditorButtonDefinition extends EditorBasicButtonDefinition { action: (context: EditorUiContext) => void; isActive: (selection: BaseSelection|null) => boolean; } @@ -49,48 +52,3 @@ export class EditorButton extends EditorUiElement { return this.trans(this.definition.label); } } - -export class FormatPreviewButton extends EditorButton { - protected previewSampleElement: HTMLElement; - - constructor(previewSampleElement: HTMLElement,definition: EditorButtonDefinition) { - super(definition); - this.previewSampleElement = previewSampleElement; - } - - protected buildDOM(): HTMLButtonElement { - const button = super.buildDOM(); - button.innerHTML = ''; - - const preview = el('span', { - class: 'editor-button-format-preview' - }, [this.getLabel()]); - - const stylesToApply = this.getStylesFromPreview(); - for (const style of Object.keys(stylesToApply)) { - preview.style.setProperty(style, stylesToApply[style]); - } - - button.append(preview); - return button; - } - - protected getStylesFromPreview(): Record { - const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); - const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; - sampleClone.textContent = this.getLabel(); - wrap.append(sampleClone); - document.body.append(wrap); - - const propertiesToFetch = ['color', 'font-size', 'background-color', 'border-inline-start']; - const propertiesToReturn: Record = {}; - - const computed = window.getComputedStyle(sampleClone); - for (const property of propertiesToFetch) { - propertiesToReturn[property] = computed.getPropertyValue(property); - } - wrap.remove(); - - return propertiesToReturn; - } -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts deleted file mode 100644 index ed191a882..000000000 --- a/resources/js/wysiwyg/ui/framework/containers.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; -import {el} from "../../helpers"; -import {EditorButton} from "./buttons"; - -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) { - super.setContext(context); - for (const child of this.getChildren()) { - child.setContext(context); - } - } -} - -export class EditorSimpleClassContainer extends EditorContainerUiElement { - protected className; - - constructor(className: string, children: EditorUiElement[]) { - super(children); - this.className = className; - } - - protected buildDOM(): HTMLElement { - return el('div', { - class: this.className, - }, this.getChildren().map(child => child.getDOMElement())); - } -} - -export class EditorFormatMenu extends EditorContainerUiElement { - buildDOM(): HTMLElement { - const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); - const menu = el('div', { - class: 'editor-format-menu-dropdown editor-dropdown-menu editor-menu-list', - hidden: 'true', - }, childElements); - - const toggle = el('button', { - class: 'editor-format-menu-toggle editor-button', - type: 'button', - }, [this.trans('Formats')]); - - const wrapper = el('div', { - class: 'editor-format-menu editor-dropdown-menu-container', - }, [toggle, menu]); - - let clickListener: Function|null = null; - - const hide = () => { - menu.hidden = true; - if (clickListener) { - window.removeEventListener('click', clickListener as EventListener); - } - }; - - const show = () => { - menu.hidden = false - clickListener = (event: MouseEvent) => { - if (!wrapper.contains(event.target as HTMLElement)) { - hide(); - } - } - window.addEventListener('click', clickListener as EventListener); - }; - - toggle.addEventListener('click', event => { - menu.hasAttribute('hidden') ? show() : hide(); - }); - menu.addEventListener('mouseleave', hide); - - return wrapper; - } - - updateState(state: EditorUiStateUpdate) { - super.updateState(state); - - for (const child of this.children) { - if (child instanceof EditorButton && child.isActive()) { - this.updateToggleLabel(child.getLabel()); - return; - } - } - - this.updateToggleLabel(this.trans('Formats')); - } - - protected updateToggleLabel(text: string): void { - const button = this.getDOMElement().querySelector('button'); - if (button) { - button.innerText = text; - } - } -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 2fdadcb40..d437b36bd 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -1,5 +1,6 @@ import {BaseSelection, LexicalEditor} from "lexical"; import {EditorUIManager} from "./manager"; +import {el} from "../../helpers"; export type EditorUiStateUpdate = { editor: LexicalEditor, @@ -46,4 +47,50 @@ export abstract class EditorUiElement { updateState(state: EditorUiStateUpdate): void { return; } -} \ No newline at end of file +} + +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) { + super.setContext(context); + for (const child of this.getChildren()) { + child.setContext(context); + } + } +} + +export class EditorSimpleClassContainer extends EditorContainerUiElement { + protected className; + + constructor(className: string, children: EditorUiElement[]) { + super(children); + this.className = className; + } + + protected buildDOM(): HTMLElement { + return el('div', { + class: this.className, + }, this.getChildren().map(child => child.getDOMElement())); + } +} + diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index a7fcb45ba..4fee787d3 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -1,5 +1,4 @@ -import {EditorUiContext, EditorUiElement} from "./core"; -import {EditorContainerUiElement} from "./containers"; +import {EditorUiContext, EditorUiElement, EditorContainerUiElement} from "./core"; import {el} from "../../helpers"; export interface EditorFormFieldDefinition { diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts new file mode 100644 index 000000000..35886d2f9 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -0,0 +1,34 @@ + + + +export function handleDropdown(toggle: HTMLElement, menu: HTMLElement, onOpen: Function|undefined = undefined, onClose: Function|undefined = undefined) { + let clickListener: Function|null = null; + + const hide = () => { + menu.hidden = true; + if (clickListener) { + window.removeEventListener('click', clickListener as EventListener); + } + if (onClose) { + onClose(); + } + }; + + const show = () => { + menu.hidden = false + clickListener = (event: MouseEvent) => { + if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { + hide(); + } + } + window.addEventListener('click', clickListener as EventListener); + if (onOpen) { + onOpen(); + } + }; + + toggle.addEventListener('click', event => { + menu.hasAttribute('hidden') ? show() : hide(); + }); + menu.addEventListener('mouseleave', hide); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 1684b6628..78ddc8ce3 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,5 +1,5 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; -import {EditorUiContext} from "./core"; +import {EditorUiContext, EditorUiElement} from "./core"; import {EditorDecorator} from "./decorator"; @@ -22,6 +22,13 @@ export class EditorUIManager { return this.context; } + triggerStateUpdate(element: EditorUiElement) { + element.updateState({ + selection: null, + editor: this.getContext().editor + }); + } + registerModal(key: string, modalDefinition: EditorFormModalDefinition) { this.modalDefinitionsByKey[key] = modalDefinition; } diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index e2a6b3f33..bfc5fc619 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -1,6 +1,6 @@ import {EditorForm, EditorFormDefinition} from "./forms"; import {el} from "../../helpers"; -import {EditorContainerUiElement} from "./containers"; +import {EditorContainerUiElement} from "./core"; export interface EditorModalDefinition { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 337266617..de90a1d70 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,16 +1,20 @@ -import {EditorButton, FormatPreviewButton} from "./framework/buttons"; +import {EditorButton} from "./framework/buttons"; import { blockquote, bold, clearFormating, code, dangerCallout, details, - h2, h3, h4, h5, image, + h2, h3, h4, h5, highlightColor, image, infoCallout, italic, link, paragraph, redo, source, strikethrough, subscript, - successCallout, superscript, underline, + successCallout, superscript, textColor, underline, undo, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorFormatMenu, EditorSimpleClassContainer} from "./framework/containers"; +import {EditorContainerUiElement, EditorSimpleClassContainer} from "./framework/core"; import {el} from "../helpers"; +import {EditorFormatMenu} from "./framework/blocks/format-menu"; +import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; +import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; +import {EditorColorPicker} from "./framework/blocks/color-picker"; export function getMainEditorFullToolbar(): EditorContainerUiElement { @@ -37,6 +41,12 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(bold), new EditorButton(italic), new EditorButton(underline), + new EditorDropdownButton(textColor, [ + new EditorColorPicker('color'), + ]), + new EditorDropdownButton(highlightColor, [ + new EditorColorPicker('background-color'), + ]), new EditorButton(strikethrough), new EditorButton(superscript), new EditorButton(subscript), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 87cc70c9b..b98e624bd 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -79,6 +79,22 @@ font-weight: 700; } +// Specific UI elements +.editor-color-select-row { + display: flex; +} +.editor-color-select-option { + width: 28px; + height: 28px; + cursor: pointer; +} +.editor-color-select-option:hover { + border-radius: 3px; + box-sizing: border-box; + z-index: 3; + box-shadow: 0 0 4px 1px rgba(0, 0, 0, 0.25); +} + // In-editor elements .editor-image-wrap { position: relative; From e2409a5fab3e38e1753adb51ff432b6104c7572b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 19 Jun 2024 16:14:20 +0100 Subject: [PATCH 019/107] Lexical: Added basic list button/support --- package-lock.json | 1 + package.json | 1 + resources/js/wysiwyg/nodes/index.ts | 3 ++ .../wysiwyg/ui/defaults/button-definitions.ts | 33 ++++++++++++++++--- resources/js/wysiwyg/ui/toolbars.ts | 11 +++++-- resources/sass/_editor.scss | 1 + 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2cddccb59..0757e7868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@lexical/history": "^0.15.0", "@lexical/html": "^0.15.0", "@lexical/link": "^0.15.0", + "@lexical/list": "^0.15.0", "@lexical/rich-text": "^0.15.0", "@lexical/selection": "^0.15.0", "@lexical/utils": "^0.15.0", diff --git a/package.json b/package.json index d9fa89c18..732bb1759 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@lexical/history": "^0.15.0", "@lexical/html": "^0.15.0", "@lexical/link": "^0.15.0", + "@lexical/list": "^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/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index f47575bc5..03fcd33a5 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -5,6 +5,7 @@ import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; import {DetailsNode, SummaryNode} from "./details"; +import {ListItemNode, ListNode} from "@lexical/list"; /** * Load the nodes for lexical. @@ -14,6 +15,8 @@ export function getNodesForPageEditor(): (KlassConstructor | CalloutNode, // Todo - Create custom HeadingNode, // Todo - Create custom QuoteNode, // Todo - Create custom + ListNode, // Todo - Create custom + ListItemNode, ImageNode, DetailsNode, SummaryNode, CustomParagraphNode, diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index d8c7f515c..57460ef60 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,11 +1,11 @@ import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, - $createParagraphNode, $getRoot, $getSelection, $insertNodes, + $createParagraphNode, $getRoot, $getSelection, $isParagraphNode, $isTextNode, $setSelection, BaseSelection, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, - REDO_COMMAND, TextFormatType, TextNode, + REDO_COMMAND, TextFormatType, UNDO_COMMAND } from "lexical"; import { @@ -23,12 +23,12 @@ import { HeadingNode, HeadingTagType } from "@lexical/rich-text"; -import {$isLinkNode, $toggleLink, LinkNode} from "@lexical/link"; +import {$isLinkNode, LinkNode} from "@lexical/link"; import {EditorUiContext} from "../framework/core"; import {$isImageNode, ImageNode} from "../../nodes/image"; import {$createDetailsNode, $isDetailsNode} from "../../nodes/details"; -import {$insertNodeToNearestRoot} from "@lexical/utils"; import {getEditorContentAsHtml} from "../../actions"; +import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -155,6 +155,31 @@ export const clearFormating: EditorButtonDefinition = { } }; +function buildListButton(label: string, type: ListType): EditorButtonDefinition { + return { + label, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + if (this.isActive(selection)) { + removeList(context.editor); + } else { + insertList(context.editor, type); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $isListNode(node) && (node as ListNode).getListType() === type; + }); + } + }; +} + +export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet'); +export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number'); +export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check'); + export const link: EditorButtonDefinition = { label: 'Insert/edit link', diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index de90a1d70..fe19b94ed 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,11 +1,11 @@ import {EditorButton} from "./framework/buttons"; import { - blockquote, bold, clearFormating, code, + blockquote, bold, bulletList, clearFormating, code, dangerCallout, details, h2, h3, h4, h5, highlightColor, image, - infoCallout, italic, link, paragraph, + infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, - successCallout, superscript, textColor, underline, + successCallout, superscript, taskList, textColor, underline, undo, warningCallout } from "./defaults/button-definitions"; @@ -53,6 +53,11 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(code), new EditorButton(clearFormating), + // Lists + new EditorButton(bulletList), + new EditorButton(numberList), + new EditorButton(taskList), + // Insert types new EditorButton(link), new EditorButton(image), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index b98e624bd..13d8e96f9 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -6,6 +6,7 @@ // Main UI elements .editor-toolbar-main { display: flex; + flex-wrap: wrap; } // Buttons From 13d970c7ce0bf9b88c3553b561cef11cbba0e71a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 19 Jun 2024 20:00:29 +0100 Subject: [PATCH 020/107] Lexical: Added button icon system With a bunch of default icons --- dev/build/esbuild.js | 7 +++ resources/icons/editor/align-center.svg | 1 + resources/icons/editor/align-justify.svg | 1 + resources/icons/editor/align-left.svg | 1 + resources/icons/editor/align-right.svg | 1 + resources/icons/editor/bold.svg | 1 + resources/icons/editor/code-block.svg | 1 + resources/icons/editor/code.svg | 1 + resources/icons/editor/details.svg | 1 + resources/icons/editor/format-clear.svg | 1 + resources/icons/editor/help.svg | 1 + resources/icons/editor/horizontal-rule.svg | 1 + resources/icons/editor/image.svg | 1 + resources/icons/editor/indent-decrease.svg | 1 + resources/icons/editor/indent-increase.svg | 1 + resources/icons/editor/italic.svg | 1 + resources/icons/editor/link.svg | 1 + resources/icons/editor/list-bullet.svg | 1 + resources/icons/editor/list-check.svg | 1 + resources/icons/editor/list-numbered.svg | 1 + resources/icons/editor/redo.svg | 1 + resources/icons/editor/source-view.svg | 1 + resources/icons/editor/strikethrough.svg | 1 + resources/icons/editor/subscript.svg | 1 + resources/icons/editor/superscript.svg | 1 + resources/icons/editor/underlined.svg | 1 + resources/icons/editor/undo.svg | 1 + resources/js/global.d.ts | 4 ++ resources/js/wysiwyg/helpers.ts | 6 ++- .../wysiwyg/ui/defaults/button-definitions.ts | 50 ++++++++++++++----- resources/js/wysiwyg/ui/framework/buttons.ts | 12 ++++- resources/js/wysiwyg/ui/toolbars.ts | 1 + resources/sass/_editor.scss | 5 ++ tsconfig.json | 4 +- 34 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 resources/icons/editor/align-center.svg create mode 100644 resources/icons/editor/align-justify.svg create mode 100644 resources/icons/editor/align-left.svg create mode 100644 resources/icons/editor/align-right.svg create mode 100644 resources/icons/editor/bold.svg create mode 100644 resources/icons/editor/code-block.svg create mode 100644 resources/icons/editor/code.svg create mode 100644 resources/icons/editor/details.svg create mode 100644 resources/icons/editor/format-clear.svg create mode 100644 resources/icons/editor/help.svg create mode 100644 resources/icons/editor/horizontal-rule.svg create mode 100644 resources/icons/editor/image.svg create mode 100644 resources/icons/editor/indent-decrease.svg create mode 100644 resources/icons/editor/indent-increase.svg create mode 100644 resources/icons/editor/italic.svg create mode 100644 resources/icons/editor/link.svg create mode 100644 resources/icons/editor/list-bullet.svg create mode 100644 resources/icons/editor/list-check.svg create mode 100644 resources/icons/editor/list-numbered.svg create mode 100644 resources/icons/editor/redo.svg create mode 100644 resources/icons/editor/source-view.svg create mode 100644 resources/icons/editor/strikethrough.svg create mode 100644 resources/icons/editor/subscript.svg create mode 100644 resources/icons/editor/superscript.svg create mode 100644 resources/icons/editor/underlined.svg create mode 100644 resources/icons/editor/undo.svg create mode 100644 resources/js/global.d.ts diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index 7f180fc07..0680f4ac3 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -32,6 +32,13 @@ esbuild.build({ format: 'esm', minify: isProd, logLevel: 'info', + loader: { + '.svg': 'text', + }, + absWorkingDir: path.join(__dirname, '../..'), + alias: { + '@icons': './resources/icons', + }, banner: { js: '// See the "/licenses" URI for full package license details', css: '/* See the "/licenses" URI for full package license details */', diff --git a/resources/icons/editor/align-center.svg b/resources/icons/editor/align-center.svg new file mode 100644 index 000000000..495ae000c --- /dev/null +++ b/resources/icons/editor/align-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/align-justify.svg b/resources/icons/editor/align-justify.svg new file mode 100644 index 000000000..bf8f61abb --- /dev/null +++ b/resources/icons/editor/align-justify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/align-left.svg b/resources/icons/editor/align-left.svg new file mode 100644 index 000000000..811212755 --- /dev/null +++ b/resources/icons/editor/align-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/align-right.svg b/resources/icons/editor/align-right.svg new file mode 100644 index 000000000..839110c42 --- /dev/null +++ b/resources/icons/editor/align-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/bold.svg b/resources/icons/editor/bold.svg new file mode 100644 index 000000000..93cc44a3f --- /dev/null +++ b/resources/icons/editor/bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/code-block.svg b/resources/icons/editor/code-block.svg new file mode 100644 index 000000000..308db53b4 --- /dev/null +++ b/resources/icons/editor/code-block.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/code.svg b/resources/icons/editor/code.svg new file mode 100644 index 000000000..d8434b761 --- /dev/null +++ b/resources/icons/editor/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/details.svg b/resources/icons/editor/details.svg new file mode 100644 index 000000000..d86e8c423 --- /dev/null +++ b/resources/icons/editor/details.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/format-clear.svg b/resources/icons/editor/format-clear.svg new file mode 100644 index 000000000..b6483fb56 --- /dev/null +++ b/resources/icons/editor/format-clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/help.svg b/resources/icons/editor/help.svg new file mode 100644 index 000000000..8c3410b84 --- /dev/null +++ b/resources/icons/editor/help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/horizontal-rule.svg b/resources/icons/editor/horizontal-rule.svg new file mode 100644 index 000000000..c70df0d6e --- /dev/null +++ b/resources/icons/editor/horizontal-rule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/image.svg b/resources/icons/editor/image.svg new file mode 100644 index 000000000..81d04cea7 --- /dev/null +++ b/resources/icons/editor/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/indent-decrease.svg b/resources/icons/editor/indent-decrease.svg new file mode 100644 index 000000000..af0caa862 --- /dev/null +++ b/resources/icons/editor/indent-decrease.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/indent-increase.svg b/resources/icons/editor/indent-increase.svg new file mode 100644 index 000000000..aa6b4cb36 --- /dev/null +++ b/resources/icons/editor/indent-increase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/italic.svg b/resources/icons/editor/italic.svg new file mode 100644 index 000000000..a98819427 --- /dev/null +++ b/resources/icons/editor/italic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/link.svg b/resources/icons/editor/link.svg new file mode 100644 index 000000000..b29800dc3 --- /dev/null +++ b/resources/icons/editor/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-bullet.svg b/resources/icons/editor/list-bullet.svg new file mode 100644 index 000000000..c073c6ff0 --- /dev/null +++ b/resources/icons/editor/list-bullet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-check.svg b/resources/icons/editor/list-check.svg new file mode 100644 index 000000000..f30266b27 --- /dev/null +++ b/resources/icons/editor/list-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-numbered.svg b/resources/icons/editor/list-numbered.svg new file mode 100644 index 000000000..92cdbf0ae --- /dev/null +++ b/resources/icons/editor/list-numbered.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/redo.svg b/resources/icons/editor/redo.svg new file mode 100644 index 000000000..d542296c5 --- /dev/null +++ b/resources/icons/editor/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/source-view.svg b/resources/icons/editor/source-view.svg new file mode 100644 index 000000000..5314c39da --- /dev/null +++ b/resources/icons/editor/source-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/strikethrough.svg b/resources/icons/editor/strikethrough.svg new file mode 100644 index 000000000..92d14aa76 --- /dev/null +++ b/resources/icons/editor/strikethrough.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/subscript.svg b/resources/icons/editor/subscript.svg new file mode 100644 index 000000000..e877b3359 --- /dev/null +++ b/resources/icons/editor/subscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/superscript.svg b/resources/icons/editor/superscript.svg new file mode 100644 index 000000000..897ceddc2 --- /dev/null +++ b/resources/icons/editor/superscript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/underlined.svg b/resources/icons/editor/underlined.svg new file mode 100644 index 000000000..5d17ef6ef --- /dev/null +++ b/resources/icons/editor/underlined.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/undo.svg b/resources/icons/editor/undo.svg new file mode 100644 index 000000000..4b9f22675 --- /dev/null +++ b/resources/icons/editor/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts new file mode 100644 index 000000000..c5aba8ee2 --- /dev/null +++ b/resources/js/global.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 40379cc27..d7cd23a35 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -9,11 +9,13 @@ import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; -export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { +export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { const el = document.createElement(tag); const attrKeys = Object.keys(attrs); for (const attr of attrKeys) { - el.setAttribute(attr, attrs[attr]); + if (attrs[attr] !== null) { + el.setAttribute(attr, attrs[attr] as string); + } } for (const child of children) { diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 57460ef60..7fa1fb5f8 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -29,9 +29,27 @@ import {$isImageNode, ImageNode} from "../../nodes/image"; import {$createDetailsNode, $isDetailsNode} from "../../nodes/details"; import {getEditorContentAsHtml} from "../../actions"; import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; +import undoIcon from "@icons/editor/undo.svg" +import redoIcon from "@icons/editor/redo.svg" +import boldIcon from "@icons/editor/bold.svg" +import italicIcon from "@icons/editor/italic.svg" +import underlinedIcon from "@icons/editor/underlined.svg" +import strikethroughIcon from "@icons/editor/strikethrough.svg" +import superscriptIcon from "@icons/editor/superscript.svg" +import subscriptIcon from "@icons/editor/subscript.svg" +import codeIcon from "@icons/editor/code.svg" +import formatClearIcon from "@icons/editor/format-clear.svg" +import listBulletIcon from "@icons/editor/list-bullet.svg" +import listNumberedIcon from "@icons/editor/list-numbered.svg" +import listCheckIcon from "@icons/editor/list-check.svg" +import linkIcon from "@icons/editor/link.svg" +import imageIcon from "@icons/editor/image.svg" +import detailsIcon from "@icons/editor/details.svg" +import sourceIcon from "@icons/editor/source-view.svg" export const undo: EditorButtonDefinition = { label: 'Undo', + icon: undoIcon, action(context: EditorUiContext) { context.editor.dispatchCommand(UNDO_COMMAND, undefined); }, @@ -42,6 +60,7 @@ export const undo: EditorButtonDefinition = { export const redo: EditorButtonDefinition = { label: 'Redo', + icon: redoIcon, action(context: EditorUiContext) { context.editor.dispatchCommand(REDO_COMMAND, undefined); }, @@ -116,9 +135,10 @@ export const paragraph: EditorButtonDefinition = { } } -function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition { +function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { label: label, + icon, action(context: EditorUiContext) { context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); }, @@ -128,18 +148,19 @@ function buildFormatButton(label: string, format: TextFormatType): EditorButtonD }; } -export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold'); -export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic'); -export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline'); +export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon); +export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); +export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); export const textColor: EditorBasicButtonDefinition = {label: 'Text color'}; export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color'}; -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'); +export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); +export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); +export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); +export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code', codeIcon); export const clearFormating: EditorButtonDefinition = { label: 'Clear formatting', + icon: formatClearIcon, action(context: EditorUiContext) { context.editor.update(() => { const selection = $getSelection(); @@ -155,9 +176,10 @@ export const clearFormating: EditorButtonDefinition = { } }; -function buildListButton(label: string, type: ListType): EditorButtonDefinition { +function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { return { label, + icon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); @@ -176,13 +198,14 @@ function buildListButton(label: string, type: ListType): EditorButtonDefinition }; } -export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet'); -export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number'); -export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check'); +export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon); +export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); +export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); export const link: EditorButtonDefinition = { label: 'Insert/edit link', + icon: linkIcon, action(context: EditorUiContext) { const linkModal = context.manager.createModal('link'); context.editor.getEditorState().read(() => { @@ -215,6 +238,7 @@ export const link: EditorButtonDefinition = { export const image: EditorButtonDefinition = { label: 'Insert/Edit Image', + icon: imageIcon, action(context: EditorUiContext) { const imageModal = context.manager.createModal('image'); const selection = context.lastSelection; @@ -247,6 +271,7 @@ export const image: EditorButtonDefinition = { export const details: EditorButtonDefinition = { label: 'Insert collapsible block', + icon: detailsIcon, action(context: EditorUiContext) { context.editor.update(() => { const selection = $getSelection(); @@ -274,6 +299,7 @@ export const details: EditorButtonDefinition = { export const source: EditorButtonDefinition = { label: 'Source code', + icon: sourceIcon, async action(context: EditorUiContext) { const modal = context.manager.createModal('source'); const source = await getEditorContentAsHtml(context.editor); diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index c3ba533b3..332b35099 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -4,6 +4,7 @@ import {el} from "../../helpers"; export interface EditorBasicButtonDefinition { label: string; + icon?: string|undefined; } export interface EditorButtonDefinition extends EditorBasicButtonDefinition { @@ -21,10 +22,19 @@ export class EditorButton extends EditorUiElement { } protected buildDOM(): HTMLButtonElement { + + const label = this.getLabel(); + let child: string|HTMLElement = label; + if (this.definition.icon) { + child = el('span', {class: 'editor-button-icon'}); + child.innerHTML = this.definition.icon; + } + const button = el('button', { type: 'button', class: 'editor-button', - }, [this.getLabel()]) as HTMLButtonElement; + title: this.definition.icon ? label : null, + }, [child]) as HTMLButtonElement; button.addEventListener('click', this.onClick.bind(this)); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index fe19b94ed..559e9a87c 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -16,6 +16,7 @@ import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; import {EditorColorPicker} from "./framework/blocks/color-picker"; +console.log(undo); export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 13d8e96f9..f8c895afd 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -29,6 +29,11 @@ padding: 4px 6px; display: block; } +.editor-button-icon svg { + width: 24px; + height: 24px; + fill: #000; +} // Containers .editor-dropdown-menu-container { diff --git a/tsconfig.json b/tsconfig.json index e075f973c..40d930149 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,9 @@ // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ + "@icons/*": ["./resources/icons/*"] + }, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ From f47f7dd9d255db85ff1254d51feb8d47476c784d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 21 Jun 2024 13:47:47 +0100 Subject: [PATCH 021/107] Lexical: Added base table support and started resize handling --- package-lock.json | 1 + package.json | 1 + resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/nodes/index.ts | 4 ++ .../ui/framework/helpers/table-resizer.ts | 68 +++++++++++++++++++ resources/sass/_editor.scss | 20 ++++++ .../pages/parts/wysiwyg-editor.blade.php | 19 ++++++ 7 files changed, 115 insertions(+) create mode 100644 resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts diff --git a/package-lock.json b/package-lock.json index 0757e7868..646750df4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@lexical/list": "^0.15.0", "@lexical/rich-text": "^0.15.0", "@lexical/selection": "^0.15.0", + "@lexical/table": "^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 732bb1759..d649b54e2 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@lexical/list": "^0.15.0", "@lexical/rich-text": "^0.15.0", "@lexical/selection": "^0.15.0", + "@lexical/table": "^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/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 41207b706..b910b2eb6 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -5,6 +5,7 @@ import {mergeRegister} from '@lexical/utils'; import {getNodesForPageEditor} from './nodes'; import {buildEditorUI} from "./ui"; import {setEditorContentFromHtml} from "./actions"; +import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; export function createPageEditorInstance(editArea: HTMLElement) { const config: CreateEditorArgs = { @@ -21,6 +22,7 @@ export function createPageEditorInstance(editArea: HTMLElement) { mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), + registerTableResizer(editor, editArea), ); setEditorContentFromHtml(editor, startingHtml); diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 03fcd33a5..ea6206ac2 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -6,6 +6,7 @@ import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; import {DetailsNode, SummaryNode} from "./details"; import {ListItemNode, ListNode} from "@lexical/list"; +import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; /** * Load the nodes for lexical. @@ -17,6 +18,9 @@ export function getNodesForPageEditor(): (KlassConstructor | QuoteNode, // Todo - Create custom ListNode, // Todo - Create custom ListItemNode, + TableNode, // Todo - Create custom, + TableRowNode, + TableCellNode, ImageNode, DetailsNode, SummaryNode, CustomParagraphNode, diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts new file mode 100644 index 000000000..53017e93b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -0,0 +1,68 @@ +import {LexicalEditor} from "lexical"; +import {el} from "../../../helpers"; + +type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; + +class TableResizer { + protected editor: LexicalEditor; + protected editArea: HTMLElement; + protected markerDom: MarkerDomRecord|null = null; + + constructor(editor: LexicalEditor, editArea: HTMLElement) { + this.editor = editor; + this.editArea = editArea; + this.setupListeners(); + } + + setupListeners() { + this.editArea.addEventListener('mousemove', event => { + const cell = (event.target as HTMLElement).closest('td,th'); + if (cell) { + this.onCellMouseMove(cell as HTMLElement, event); + } + }); + } + + onCellMouseMove(cell: HTMLElement, event: MouseEvent) { + const rect = cell.getBoundingClientRect(); + const midX = rect.left + (rect.width / 2); + const midY = rect.top + (rect.height / 2); + const xMarkerPos = event.clientX <= midX ? rect.left : rect.right; + const yMarkerPos = event.clientY <= midY ? rect.top : rect.bottom; + this.updateMarkersTo(cell, xMarkerPos, yMarkerPos); + } + + updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) { + const markers: MarkerDomRecord = this.getMarkers(); + const table = cell.closest('table') as HTMLElement; + const tableRect = table.getBoundingClientRect(); + + markers.x.style.left = xPos + 'px'; + markers.x.style.height = tableRect.height + 'px'; + markers.x.style.top = tableRect.top + 'px'; + + markers.y.style.top = yPos + 'px'; + markers.y.style.left = tableRect.left + 'px'; + markers.y.style.width = tableRect.width + 'px'; + } + + getMarkers(): MarkerDomRecord { + if (!this.markerDom) { + this.markerDom = { + x: el('div', {class: 'editor-table-marker-column'}), + y: el('div', {class: 'editor-table-marker-row'}), + } + this.editArea.after(this.markerDom.x, this.markerDom.y); + } + + return this.markerDom; + } +} + + +export function registerTableResizer(editor: LexicalEditor, editorArea: HTMLElement): (() => void) { + const resizer = new TableResizer(editor, editorArea); + + // TODO - Strip/close down resizer + return () => {}; +} \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index f8c895afd..ad1f5a339 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -146,3 +146,23 @@ cursor: sw-resize; } } + +.editor-table-marker-row, +.editor-table-marker-column { + position: fixed; + background-color: var(--editor-color-primary); + z-index: 99; + user-select: none; + opacity: 0; + &:hover { + opacity: 0.4; + } +} +.editor-table-marker-column { + width: 4px; + cursor: col-resize; +} +.editor-table-marker-row { + height: 4px; + cursor: row-resize; +} \ 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 641402769..5cd60bbc6 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -26,6 +26,25 @@

    Hello there, this is an info callout

    + +

    Table

    + + + + + + + + + + + + + + + + +
    Cell ACell BCell C
    Cell DCell ECell F
    From ac01c62e6e393d17228005d5bb3074960a54714c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 21 Jun 2024 16:18:44 +0100 Subject: [PATCH 022/107] Lexical: Added table creator UI --- resources/js/wysiwyg/helpers.ts | 16 +++- .../wysiwyg/ui/defaults/button-definitions.ts | 4 + .../ui/framework/blocks/table-creator.ts | 80 +++++++++++++++++++ resources/js/wysiwyg/ui/toolbars.ts | 8 +- resources/sass/_editor.scss | 16 ++++ 5 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/table-creator.ts diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index d7cd23a35..62e945721 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -1,13 +1,14 @@ import { - $createParagraphNode, + $createParagraphNode, $getRoot, $getSelection, $isTextNode, - BaseSelection, + BaseSelection, ElementNode, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; +import {$createDetailsNode} from "./nodes/details"; export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { const el = document.createElement(tag); @@ -77,4 +78,15 @@ export function toggleSelectionBlockNodeType(editor: LexicalEditor, matcher: Lex $setBlocksType(selection, creator); } }); +} + +export function insertNewBlockNodeAtSelection(node: LexicalNode) { + const selection = $getSelection(); + const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + + if (blockElement) { + blockElement.insertAfter(node); + } else { + $getRoot().append(node); + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 7fa1fb5f8..bf1846b8f 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -236,6 +236,10 @@ export const link: EditorButtonDefinition = { } }; +export const table: EditorBasicButtonDefinition = { + label: 'Table', +}; + export const image: EditorButtonDefinition = { label: 'Insert/Edit Image', icon: imageIcon, diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts new file mode 100644 index 000000000..c54645856 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -0,0 +1,80 @@ +import {el, insertNewBlockNodeAtSelection} from "../../../helpers"; +import {EditorUiElement} from "../core"; +import {$createTableNodeWithDimensions} from "@lexical/table"; + + +export class EditorTableCreator extends EditorUiElement { + + buildDOM(): HTMLElement { + const size = 10; + const rows: HTMLElement[] = []; + const cells: HTMLElement[] = []; + + for (let row = 1; row < size + 1; row++) { + const rowCells = []; + for (let column = 1; column < size + 1; column++) { + const cell = el('div', { + class: 'editor-table-creator-cell', + 'data-rows': String(row), + 'data-columns': String(column), + }); + rowCells.push(cell); + cells.push(cell); + } + rows.push(el('div', { + class: 'editor-table-creator-row' + }, rowCells)); + } + + const display = el('div', {class: 'editor-table-creator-display'}, ['0 x 0']); + const grid = el('div', {class: 'editor-table-creator-grid'}, rows); + grid.addEventListener('mousemove', event => { + const cell = (event.target as HTMLElement).closest('.editor-table-creator-cell') as HTMLElement|null; + if (cell) { + const row = Number(cell.dataset.rows || 0); + const column = Number(cell.dataset.columns || 0); + this.updateGridSelection(row, column, cells, display) + } + }); + + grid.addEventListener('click', event => { + const cell = (event.target as HTMLElement).closest('.editor-table-creator-cell'); + if (cell) { + this.onCellClick(cell as HTMLElement); + } + }); + + grid.addEventListener('mouseleave', event => { + this.updateGridSelection(0, 0, cells, display); + }); + + return el('div', { + class: 'editor-table-creator', + }, [ + grid, + display, + ]); + } + + updateGridSelection(rows: number, columns: number, cells: HTMLElement[], display: HTMLElement) { + for (const cell of cells) { + const active = Number(cell.dataset.rows) <= rows && Number(cell.dataset.columns) <= columns; + cell.classList.toggle('active', active); + } + + display.textContent = `${rows} x ${columns}`; + } + + onCellClick(cell: HTMLElement) { + const rows = Number(cell.dataset.rows || 0); + const columns = Number(cell.dataset.columns || 0); + if (rows < 1 || columns < 1) { + return; + } + + this.getContext().editor.update(() => { + const table = $createTableNodeWithDimensions(rows, columns, false); + insertNewBlockNodeAtSelection(table); + }); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 559e9a87c..4dbf9bb7e 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -5,7 +5,7 @@ import { h2, h3, h4, h5, highlightColor, image, infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, - successCallout, superscript, taskList, textColor, underline, + successCallout, superscript, table, taskList, textColor, underline, undo, warningCallout } from "./defaults/button-definitions"; @@ -15,8 +15,7 @@ import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; import {EditorColorPicker} from "./framework/blocks/color-picker"; - -console.log(undo); +import {EditorTableCreator} from "./framework/blocks/table-creator"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -61,6 +60,9 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { // Insert types new EditorButton(link), + new EditorDropdownButton(table, [ + new EditorTableCreator(), + ]), new EditorButton(image), new EditorButton(details), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index ad1f5a339..69027ea69 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -100,6 +100,22 @@ z-index: 3; box-shadow: 0 0 4px 1px rgba(0, 0, 0, 0.25); } +.editor-table-creator-row { + display: flex; +} +.editor-table-creator-cell { + border: 1px solid #DDD; + width: 15px; + height: 15px; + cursor: pointer; + &.active { + background-color: var(--editor-color-primary); + } +} +.editor-table-creator-display { + text-align: center; + padding: 0.2em; +} // In-editor elements .editor-image-wrap { From a07092b7e65d8e2aafa3b866724b995ae4431cc6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 23 Jun 2024 11:36:48 +0100 Subject: [PATCH 023/107] Lexical: Updated lexical, added undo state tracking, format styles --- package-lock.json | 320 +++++++++--------- package.json | 18 +- resources/js/wysiwyg/index.ts | 12 + .../wysiwyg/ui/defaults/button-definitions.ts | 20 +- resources/js/wysiwyg/ui/framework/buttons.ts | 23 ++ resources/sass/_editor.scss | 23 ++ 6 files changed, 251 insertions(+), 165 deletions(-) diff --git a/package-lock.json b/package-lock.json index 646750df4..3867a1d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,20 +18,20 @@ "@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/link": "^0.15.0", - "@lexical/list": "^0.15.0", - "@lexical/rich-text": "^0.15.0", - "@lexical/selection": "^0.15.0", - "@lexical/table": "^0.15.0", - "@lexical/utils": "^0.15.0", + "@lexical/history": "^0.16.0", + "@lexical/html": "^0.16.0", + "@lexical/link": "^0.16.0", + "@lexical/list": "^0.16.0", + "@lexical/rich-text": "^0.16.0", + "@lexical/selection": "^0.16.0", + "@lexical/table": "^0.16.0", + "@lexical/utils": "^0.16.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", + "lexical": "^0.16.0", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", @@ -50,19 +50,10 @@ "typescript": "^5.4.5" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@codemirror/autocomplete": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.15.0.tgz", - "integrity": "sha512-G2Zm0mXznxz97JhaaOdoEG2cVupn4JjPaS4AcNvZzhOsnnG9YVN68VzfoUw6dYTsIxT6a/cmoFEN47KAWhXaOg==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.3.tgz", + "integrity": "sha512-Vl/tIeRVVUCRDuOG48lttBasNQu8usGgXQawBXI7WJAiUDSFOfzflmEsZFZo48mAvAaa4FZ/4/yLLxFtdJaKYA==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -77,13 +68,13 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.3.3.tgz", - "integrity": "sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", + "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, @@ -100,9 +91,9 @@ } }, "node_modules/@codemirror/lang-html": { - "version": "6.4.8", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.8.tgz", - "integrity": "sha512-tE2YK7wDlb9ZpAH6mpTPiYm6rhfdQKVDa5r9IwIFlwwgvVaKsCfuKKZoJGWsmMZIf3FQAuJ5CHMPLymOtg1hXw==", + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -139,9 +130,9 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.4.tgz", - "integrity": "sha512-UghkA1vSMs8bT7RSZM6vsIocigyah2bV00eRQuZy76401UmFZdsTsbQNBGdyxRQDOLeEvF5iFwap0BM8LKyd+g==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz", + "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==", "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", @@ -178,9 +169,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz", - "integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz", + "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -199,9 +190,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.5.0.tgz", - "integrity": "sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz", + "integrity": "sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -235,9 +226,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.26.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.1.tgz", - "integrity": "sha512-wLw0t3R9AwOSQThdZ5Onw8QQtem5asE7+bPlnzc57eubPqiuJKIzwjMZ+C42vQett+iva+J8VgFV4RYWDBh5FA==", + "version": "6.28.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.2.tgz", + "integrity": "sha512-A3DmyVfjgPsGIjiJqM/zvODUAPQdQl3ci0ghehYNnbt5x+o76xq+dL5+mMBuysDXnI3kapgOkoeJ0sbtL/3qPw==", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -628,9 +619,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz", + "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -672,6 +663,7 @@ "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", @@ -699,94 +691,95 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.16.0.tgz", + "integrity": "sha512-eYMJ6jCXpWBVC05Mu9HLMysrBbfi++xFfsm+Yo7A6kYGrqYUhpXqjJkYnw1xdZYL3bV73Oe4ByVJuq42GU+Mqw==", "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" + "@lexical/html": "0.16.0", + "@lexical/list": "0.16.0", + "@lexical/selection": "0.16.0", + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.16.0.tgz", + "integrity": "sha512-xwFxgDZGviyGEqHmgt6A6gPhsyU/yzlKRk9TBUVByba3khuTknlJ1a80H5jb+OYcrpiElml7iVuGYt+oC7atCA==", "dependencies": { - "@lexical/utils": "0.15.0", - "lexical": "0.15.0" + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.16.0.tgz", + "integrity": "sha512-okxn3q/1qkUpCZNEFRI39XeJj4YRjb6prm3WqZgP4d39DI1W24feeTZJjYRCW+dc3NInwFaolU3pNA2MGkjRtg==", "dependencies": { - "@lexical/selection": "0.15.0", - "@lexical/utils": "0.15.0", - "lexical": "0.15.0" + "@lexical/selection": "0.16.0", + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.16.0.tgz", + "integrity": "sha512-ppvJSh/XGqlzbeymOiwcXJcUcrqgQqTK2QXTBAZq7JThtb0WsJxYd2CSLSN+Ycu23prnwqOqILcU0+34+gAVFw==", "dependencies": { - "@lexical/utils": "0.15.0", - "lexical": "0.15.0" + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.16.0.tgz", + "integrity": "sha512-nBx/DMM7nCgnOzo1JyNnVaIrk/Xi5wIPNi8jixrEV6w9Om2K6dHutn/79Xzp2dQlNGSLHEDjky6N2RyFgmXh0g==", "dependencies": { - "@lexical/utils": "0.15.0", - "lexical": "0.15.0" + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.16.0.tgz", + "integrity": "sha512-AGTD6yJZ+kj2TNah1r7/6vyufs6fZANeSvv9x5eG+WjV4uyUJYkd1qR8C5gFZHdkyr+bhAcsAXvS039VzAxRrQ==", "dependencies": { - "@lexical/clipboard": "0.15.0", - "@lexical/selection": "0.15.0", - "@lexical/utils": "0.15.0", - "lexical": "0.15.0" + "@lexical/clipboard": "0.16.0", + "@lexical/selection": "0.16.0", + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.16.0.tgz", + "integrity": "sha512-trT9gQVJ2j6AwAe7tHJ30SRuxCpV6yR9LFtggxphHsXSvJYnoHC0CXh1TF2jHl8Gd5OsdWseexGLBE4Y0V3gwQ==", "dependencies": { - "lexical": "0.15.0" + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.16.0.tgz", + "integrity": "sha512-A66K779kxdr0yH2RwT2itsMnkzyFLFNPXyiWGLobCH8ON4QPuBouZvjbRHBe8Pe64yJ0c1bRDxSbTqUi9Wt3Gg==", "dependencies": { - "@lexical/utils": "0.15.0", - "lexical": "0.15.0" + "@lexical/utils": "0.16.0", + "lexical": "0.16.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==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.16.0.tgz", + "integrity": "sha512-GWmFEmd7o3GHqJBaEwzuZQbfTNI3Gg8ReGuHMHABgrkhZ8j2NggoRBlxsQLG0f7BewfTMVwbye22yBPq78775w==", "dependencies": { - "@lexical/list": "0.15.0", - "@lexical/selection": "0.15.0", - "@lexical/table": "0.15.0", - "lexical": "0.15.0" + "@lexical/list": "0.16.0", + "@lexical/selection": "0.16.0", + "@lexical/table": "0.16.0", + "lexical": "0.16.0" } }, "node_modules/@lezer/common": { @@ -805,9 +798,9 @@ } }, "node_modules/@lezer/generator": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.0.tgz", - "integrity": "sha512-IJ16tx3biLKlCXUzcK4v8S10AVa2BSM2rB12rtAL6f1hL2TS/HQQlGCoWRvanlL2J4mCYEEIv9uG7n4kVMkVDA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.1.tgz", + "integrity": "sha512-MgPJN9Si+ccxzXl3OAmCeZuUKw4XiPl4y664FX/hnnyG9CTqUPq65N3/VGPA2jD23D7QgMTtNqflta+cPN+5mQ==", "dev": true, "dependencies": { "@lezer/common": "^1.1.0", @@ -826,9 +819,9 @@ } }, "node_modules/@lezer/html": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.9.tgz", - "integrity": "sha512-MXxeCMPyrcemSLGaTQEZx0dBUH0i+RPl8RN5GwMAzo53nTsd/Unc/t5ZxACeQoyPUM5/GkPLRUs2WliOImzkRA==", + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -836,9 +829,9 @@ } }, "node_modules/@lezer/javascript": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.14.tgz", - "integrity": "sha512-GEdUyspTRgc5dwIGebUk+f3BekvqEWVIYsIuAC3pA8e8wcikGwBZRWRa450L0s8noGWuULwnmi4yjxTnYz9PpA==", + "version": "1.4.17", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz", + "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -856,9 +849,9 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz", - "integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.1.tgz", + "integrity": "sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==", "dependencies": { "@lezer/common": "^1.0.0" } @@ -955,9 +948,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", + "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -1191,12 +1184,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1444,9 +1437,9 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -2041,9 +2034,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -2202,6 +2195,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -2246,12 +2240,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -2386,9 +2381,9 @@ } }, "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", + "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", "dev": true }, "node_modules/import-fresh": { @@ -2420,6 +2415,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "dependencies": { "once": "^1.3.0", @@ -2521,12 +2517,15 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", + "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", "dev": true, "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2808,9 +2807,9 @@ } }, "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==" + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.16.0.tgz", + "integrity": "sha512-Skn45Qhriazq4fpAtwnAB11U//GKc4vjzx54xsV3TkDLDvWpbL4Z9TNRwRoN3g7w8AkWnqjeOSODKkrjgfRSrg==" }, "node_modules/linkify-it": { "version": "5.0.0", @@ -3160,10 +3159,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "dev": true, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3268,17 +3270,17 @@ } }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -3586,6 +3588,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -3656,9 +3659,9 @@ } }, "node_modules/sass": { - "version": "1.74.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz", - "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==", + "version": "1.77.6", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", + "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -3816,9 +3819,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", - "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", "dev": true }, "node_modules/string-width": { @@ -4113,9 +4116,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", + "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -4225,6 +4228,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", @@ -4294,9 +4306,9 @@ "dev": true }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, "engines": { "node": ">=8.3.0" diff --git a/package.json b/package.json index d649b54e2..439eaa5a1 100644 --- a/package.json +++ b/package.json @@ -42,20 +42,20 @@ "@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/link": "^0.15.0", - "@lexical/list": "^0.15.0", - "@lexical/rich-text": "^0.15.0", - "@lexical/selection": "^0.15.0", - "@lexical/table": "^0.15.0", - "@lexical/utils": "^0.15.0", + "@lexical/history": "^0.16.0", + "@lexical/html": "^0.16.0", + "@lexical/link": "^0.16.0", + "@lexical/list": "^0.16.0", + "@lexical/rich-text": "^0.16.0", + "@lexical/selection": "^0.16.0", + "@lexical/table": "^0.16.0", + "@lexical/utils": "^0.16.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", + "lexical": "^0.16.0", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index b910b2eb6..9f2f1645a 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -12,6 +12,18 @@ export function createPageEditorInstance(editArea: HTMLElement) { namespace: 'BookStackPageEditor', nodes: getNodesForPageEditor(), onError: console.error, + theme: { + text: { + bold: 'editor-theme-bold', + code: 'editor-theme-code', + italic: 'editor-theme-italic', + strikethrough: 'editor-theme-strikethrough', + subscript: 'editor-theme-subscript', + superscript: 'editor-theme-superscript', + underline: 'editor-theme-underline', + underlineStrikethrough: 'editor-theme-underline-strikethrough', + } + } }; const startingHtml = editArea.innerHTML; diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index bf1846b8f..589567c03 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,9 +1,9 @@ -import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../framework/buttons"; +import {EditorBasicButtonDefinition, EditorButton, EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, $createParagraphNode, $getRoot, $getSelection, $isParagraphNode, $isTextNode, $setSelection, - BaseSelection, ElementNode, FORMAT_TEXT_COMMAND, + BaseSelection, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, REDO_COMMAND, TextFormatType, UNDO_COMMAND @@ -55,6 +55,14 @@ export const undo: EditorButtonDefinition = { }, isActive(selection: BaseSelection|null): boolean { return false; + }, + setup(context: EditorUiContext, button: EditorButton) { + button.toggleDisabled(true); + + context.editor.registerCommand(CAN_UNDO_COMMAND, (payload: boolean): boolean => { + button.toggleDisabled(!payload) + return false; + }, COMMAND_PRIORITY_LOW); } } @@ -66,6 +74,14 @@ export const redo: EditorButtonDefinition = { }, isActive(selection: BaseSelection|null): boolean { return false; + }, + setup(context: EditorUiContext, button: EditorButton) { + button.toggleDisabled(true); + + context.editor.registerCommand(CAN_REDO_COMMAND, (payload: boolean): boolean => { + button.toggleDisabled(!payload) + return false; + }, COMMAND_PRIORITY_LOW); } } diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 332b35099..02f88dac8 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -1,6 +1,7 @@ import {BaseSelection} from "lexical"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; +import {context} from "esbuild"; export interface EditorBasicButtonDefinition { label: string; @@ -10,17 +11,29 @@ export interface EditorBasicButtonDefinition { export interface EditorButtonDefinition extends EditorBasicButtonDefinition { action: (context: EditorUiContext) => void; isActive: (selection: BaseSelection|null) => boolean; + setup?: (context: EditorUiContext, button: EditorButton) => void; } export class EditorButton extends EditorUiElement { protected definition: EditorButtonDefinition; protected active: boolean = false; + protected completedSetup: boolean = false; + protected disabled: boolean = false; constructor(definition: EditorButtonDefinition) { super(); this.definition = definition; } + setContext(context: EditorUiContext) { + super.setContext(context); + + if (this.definition.setup && !this.completedSetup) { + this.definition.setup(context, this); + this.completedSetup = true; + } + } + protected buildDOM(): HTMLButtonElement { const label = this.getLabel(); @@ -34,6 +47,7 @@ export class EditorButton extends EditorUiElement { type: 'button', class: 'editor-button', title: this.definition.icon ? label : null, + disabled: this.disabled ? 'true' : null, }, [child]) as HTMLButtonElement; button.addEventListener('click', this.onClick.bind(this)); @@ -61,4 +75,13 @@ export class EditorButton extends EditorUiElement { getLabel(): string { return this.trans(this.definition.label); } + + toggleDisabled(disabled: boolean) { + this.disabled = disabled; + if (disabled) { + this.dom?.setAttribute('disabled', 'true'); + } else { + this.dom?.removeAttribute('disabled'); + } + } } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 69027ea69..b5ee69d98 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -21,6 +21,12 @@ cursor: pointer; color: #000; } +.editor-button[disabled] { + pointer-events: none; + cursor: not-allowed; + background-color: #EEE; + opacity: .6; +} .editor-button-active, .editor-button-active:hover { background-color: #ceebff; color: #000; @@ -181,4 +187,21 @@ .editor-table-marker-row { height: 4px; cursor: row-resize; +} + +// Editor theme styles +.editor-theme-bold { + font-weight: bold; +} +.editor-theme-italic { + font-style: italic; +} +.editor-theme-strikethrough { + text-decoration-line: line-through; +} +.editor-theme-underline { + text-decoration-line: underline; +} +.editor-theme-underline-strikethrough { + text-decoration: underline line-through; } \ No newline at end of file From 5546b8ff435b268369855106116968e704335e92 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 23 Jun 2024 15:50:41 +0100 Subject: [PATCH 024/107] Lexical: Added more icons, made reflective text/bg color buttons --- resources/icons/editor/highlighter.svg | 1 + resources/icons/editor/list-check.svg | 2 +- resources/icons/editor/list-numbered.svg | 2 +- resources/icons/editor/table.svg | 1 + resources/icons/editor/text-color.svg | 1 + .../wysiwyg/ui/defaults/button-definitions.ts | 9 +++-- .../ui/framework/blocks/color-button.ts | 35 +++++++++++++++++++ .../ui/framework/blocks/dropdown-button.ts | 24 +++++++------ resources/js/wysiwyg/ui/framework/buttons.ts | 18 ++++++++-- resources/js/wysiwyg/ui/toolbars.ts | 5 +-- 10 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 resources/icons/editor/highlighter.svg create mode 100644 resources/icons/editor/table.svg create mode 100644 resources/icons/editor/text-color.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/color-button.ts diff --git a/resources/icons/editor/highlighter.svg b/resources/icons/editor/highlighter.svg new file mode 100644 index 000000000..b2eaacdb2 --- /dev/null +++ b/resources/icons/editor/highlighter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/list-check.svg b/resources/icons/editor/list-check.svg index f30266b27..4517d0d53 100644 --- a/resources/icons/editor/list-check.svg +++ b/resources/icons/editor/list-check.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/icons/editor/list-numbered.svg b/resources/icons/editor/list-numbered.svg index 92cdbf0ae..4bc0fc1ba 100644 --- a/resources/icons/editor/list-numbered.svg +++ b/resources/icons/editor/list-numbered.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/icons/editor/table.svg b/resources/icons/editor/table.svg new file mode 100644 index 000000000..15425063c --- /dev/null +++ b/resources/icons/editor/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/text-color.svg b/resources/icons/editor/text-color.svg new file mode 100644 index 000000000..a862e1962 --- /dev/null +++ b/resources/icons/editor/text-color.svg @@ -0,0 +1 @@ + diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 589567c03..5e5f0d409 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -34,6 +34,8 @@ import redoIcon from "@icons/editor/redo.svg" import boldIcon from "@icons/editor/bold.svg" import italicIcon from "@icons/editor/italic.svg" import underlinedIcon from "@icons/editor/underlined.svg" +import textColorIcon from "@icons/editor/text-color.svg"; +import highlightIcon from "@icons/editor/highlighter.svg"; import strikethroughIcon from "@icons/editor/strikethrough.svg" import superscriptIcon from "@icons/editor/superscript.svg" import subscriptIcon from "@icons/editor/subscript.svg" @@ -43,6 +45,7 @@ import listBulletIcon from "@icons/editor/list-bullet.svg" import listNumberedIcon from "@icons/editor/list-numbered.svg" import listCheckIcon from "@icons/editor/list-check.svg" import linkIcon from "@icons/editor/link.svg" +import tableIcon from "@icons/editor/table.svg" import imageIcon from "@icons/editor/image.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" @@ -167,8 +170,8 @@ function buildFormatButton(label: string, format: TextFormatType, icon: string): export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon); export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); -export const textColor: EditorBasicButtonDefinition = {label: 'Text color'}; -export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color'}; +export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon}; export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); @@ -183,6 +186,7 @@ export const clearFormating: EditorButtonDefinition = { for (const node of selection?.getNodes() || []) { if ($isTextNode(node)) { node.setFormat(0); + node.setStyle(''); } } }); @@ -254,6 +258,7 @@ export const link: EditorButtonDefinition = { export const table: EditorBasicButtonDefinition = { label: 'Table', + icon: tableIcon, }; export const image: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-button.ts b/resources/js/wysiwyg/ui/framework/blocks/color-button.ts new file mode 100644 index 000000000..e81521a26 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-button.ts @@ -0,0 +1,35 @@ +import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; +import {EditorUiStateUpdate} from "../core"; +import {$isRangeSelection} from "lexical"; +import {$getSelectionStyleValueForProperty} from "@lexical/selection"; + +export class EditorColorButton extends EditorButton { + protected style: string; + + constructor(definition: EditorBasicButtonDefinition, style: string) { + super(definition); + + this.style = style; + } + + getColorBar(): HTMLElement { + const colorBar = this.getDOMElement().querySelector('svg .editor-icon-color-bar'); + + if (!colorBar) { + throw new Error(`Could not find expected color bar in the icon for this ${this.definition.label} button`); + } + + return (colorBar as HTMLElement); + } + + updateState(state: EditorUiStateUpdate): void { + super.updateState(state); + + if ($isRangeSelection(state.selection)) { + const value = $getSelectionStyleValueForProperty(state.selection, this.style); + const colorBar = this.getColorBar(); + colorBar.setAttribute('fill', value); + } + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index 199c7728d..a419b92b2 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -8,19 +8,23 @@ export class EditorDropdownButton extends EditorContainerUiElement { protected childItems: EditorUiElement[]; protected open: boolean = false; - constructor(buttonDefinition: EditorBasicButtonDefinition, children: EditorUiElement[]) { + constructor(button: EditorBasicButtonDefinition|EditorButton, children: EditorUiElement[]) { super(children); this.childItems = children - this.button = new EditorButton({ - ...buttonDefinition, - action() { - return false; - }, - isActive: () => { - return this.open; - } - }); + if (button instanceof EditorButton) { + this.button = button; + } else { + this.button = new EditorButton({ + ...button, + action() { + return false; + }, + isActive: () => { + return this.open; + } + }); + } this.children.push(this.button); } diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 02f88dac8..7e8df076a 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -1,7 +1,6 @@ import {BaseSelection} from "lexical"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; -import {context} from "esbuild"; export interface EditorBasicButtonDefinition { label: string; @@ -20,9 +19,22 @@ export class EditorButton extends EditorUiElement { protected completedSetup: boolean = false; protected disabled: boolean = false; - constructor(definition: EditorButtonDefinition) { + constructor(definition: EditorButtonDefinition|EditorBasicButtonDefinition) { super(); - this.definition = definition; + + if ((definition as EditorButtonDefinition).action !== undefined) { + this.definition = definition as EditorButtonDefinition; + } else { + this.definition = { + ...definition, + action() { + return false; + }, + isActive: () => { + return false; + } + }; + } } setContext(context: EditorUiContext) { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 4dbf9bb7e..821c9f9cf 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -16,6 +16,7 @@ import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; import {EditorColorPicker} from "./framework/blocks/color-picker"; import {EditorTableCreator} from "./framework/blocks/table-creator"; +import {EditorColorButton} from "./framework/blocks/color-button"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -41,10 +42,10 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(bold), new EditorButton(italic), new EditorButton(underline), - new EditorDropdownButton(textColor, [ + new EditorDropdownButton(new EditorColorButton(textColor, 'color'), [ new EditorColorPicker('color'), ]), - new EditorDropdownButton(highlightColor, [ + new EditorDropdownButton(new EditorColorButton(highlightColor, 'background-color'), [ new EditorColorPicker('background-color'), ]), new EditorButton(strikethrough), From 3af22ce754db4f656a45833951b746e7db65f432 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 24 Jun 2024 20:50:17 +0100 Subject: [PATCH 025/107] Lexical: Created custom table node with col width handling --- resources/js/wysiwyg/nodes/custom-table.ts | 180 ++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 11 +- .../ui/framework/blocks/table-creator.ts | 3 +- resources/js/wysiwyg/ui/toolbars.ts | 28 ++- 4 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/custom-table.ts diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts new file mode 100644 index 000000000..c070e06b5 --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -0,0 +1,180 @@ +import {SerializedTableNode, TableNode, TableRowNode} from "@lexical/table"; +import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {el} from "../helpers"; + +export type SerializedCustomTableNode = Spread<{ + id: string; + colWidths: string[]; +}, SerializedTableNode> + +export class CustomTableNode extends TableNode { + __id: string = ''; + __colWidths: string[] = []; + + static getType() { + return 'custom-table'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setColWidths(widths: string[]) { + const self = this.getWritable(); + self.__colWidths = widths; + } + + getColWidths(): string[] { + const self = this.getLatest(); + return self.__colWidths; + } + + static clone(node: CustomTableNode) { + const newNode = new CustomTableNode(node.__key); + newNode.__id = node.__id; + newNode.__colWidths = node.__colWidths; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + const id = this.getId(); + if (id) { + dom.setAttribute('id', id); + } + + const colWidths = this.getColWidths(); + if (colWidths.length > 0) { + const colgroup = el('colgroup'); + for (const width of colWidths) { + const col = el('col'); + if (width) { + col.style.width = width; + } + colgroup.append(col); + } + dom.append(colgroup); + } + + return dom; + } + + updateDOM(): boolean { + return true; + } + + exportJSON(): SerializedCustomTableNode { + return { + ...super.exportJSON(), + type: 'custom-table', + version: 1, + id: this.__id, + colWidths: this.__colWidths, + }; + } + + static importJSON(serializedNode: SerializedCustomTableNode): CustomTableNode { + const node = $createCustomTableNode(); + node.setId(serializedNode.id); + node.setColWidths(serializedNode.colWidths); + return node; + } + + static importDOM(): DOMConversionMap|null { + return { + table(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + const node = $createCustomTableNode(); + + if (element.id) { + node.setId(element.id); + } + + const colWidths = getTableColumnWidths(element as HTMLTableElement); + node.setColWidths(colWidths); + + return {node}; + }, + priority: 1, + }; + }, + }; + } +} + +function getTableColumnWidths(table: HTMLTableElement): string[] { + const rows = table.querySelectorAll('tr'); + let maxColCount: number = 0; + let maxColRow: HTMLTableRowElement|null = null; + + for (const row of rows) { + if (row.childElementCount > maxColCount) { + maxColRow = row; + maxColCount = row.childElementCount; + } + } + + const colGroup = table.querySelector('colgroup'); + let widths: string[] = []; + if (colGroup && colGroup.childElementCount === maxColCount) { + widths = extractWidthsFromRow(colGroup); + } + if (widths.filter(Boolean).length === 0 && maxColRow) { + widths = extractWidthsFromRow(maxColRow); + } + + return widths; +} + +function extractWidthsFromRow(row: HTMLTableRowElement|HTMLTableColElement) { + return [...row.children].map(child => extractWidthFromElement(child as HTMLElement)) +} + +function extractWidthFromElement(element: HTMLElement): string { + let width = element.style.width || element.getAttribute('width'); + if (!Number.isNaN(Number(width))) { + width = width + 'px'; + } + + return width || ''; +} + +export function $createCustomTableNode(): CustomTableNode { + return new CustomTableNode(); +} + +export function $isCustomTableNode(node: LexicalNode | null | undefined): boolean { + return node instanceof CustomTableNode; +} + +export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number): void { + const rows = node.getChildren() as TableRowNode[]; + let maxCols = 0; + for (const row of rows) { + const cellCount = row.getChildren().length; + if (cellCount > maxCols) { + maxCols = cellCount; + } + } + + let colWidths = node.getColWidths(); + if (colWidths.length === 0 || colWidths.length < maxCols) { + colWidths = Array(maxCols).fill(''); + } + + if (columnIndex + 1 > colWidths.length) { + console.error(`Attempted to set table column width for column [${columnIndex}] but only ${colWidths.length} columns found`); + } + + colWidths[columnIndex] = width + 'px'; + node.setColWidths(colWidths); + console.log('setting col widths', node, colWidths); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index ea6206ac2..6b1b66e66 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -7,6 +7,7 @@ import {ImageNode} from "./image"; import {DetailsNode, SummaryNode} from "./details"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; +import {CustomTableNode} from "./custom-table"; /** * Load the nodes for lexical. @@ -18,19 +19,25 @@ export function getNodesForPageEditor(): (KlassConstructor | QuoteNode, // Todo - Create custom ListNode, // Todo - Create custom ListItemNode, - TableNode, // Todo - Create custom, + CustomTableNode, TableRowNode, TableCellNode, ImageNode, DetailsNode, SummaryNode, CustomParagraphNode, + LinkNode, { replace: ParagraphNode, with: (node: ParagraphNode) => { return new CustomParagraphNode(); } }, - LinkNode, + { + replace: TableNode, + with(node: TableNode) { + return new CustomTableNode(); + } + }, ]; } diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts index c54645856..8c28953d5 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -1,6 +1,7 @@ import {el, insertNewBlockNodeAtSelection} from "../../../helpers"; import {EditorUiElement} from "../core"; import {$createTableNodeWithDimensions} from "@lexical/table"; +import {CustomTableNode} from "../../../nodes/custom-table"; export class EditorTableCreator extends EditorUiElement { @@ -73,7 +74,7 @@ export class EditorTableCreator extends EditorUiElement { } this.getContext().editor.update(() => { - const table = $createTableNodeWithDimensions(rows, columns, false); + const table = $createTableNodeWithDimensions(rows, columns, false) as CustomTableNode; insertNewBlockNodeAtSelection(table); }); } diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 821c9f9cf..7f7e99a78 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -9,7 +9,7 @@ import { undo, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorSimpleClassContainer} from "./framework/core"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext} from "./framework/core"; import {el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; @@ -17,6 +17,8 @@ import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; import {EditorColorPicker} from "./framework/blocks/color-picker"; import {EditorTableCreator} from "./framework/blocks/table-creator"; import {EditorColorButton} from "./framework/blocks/color-button"; +import {$isCustomTableNode, $setTableColumnWidth, CustomTableNode} from "../nodes/custom-table"; +import {$getRoot} from "lexical"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -69,5 +71,29 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { // Meta elements new EditorButton(source), + + // Test + new EditorButton({ + label: 'Expand table col 2', + action(context: EditorUiContext) { + context.editor.update(() => { + const root = $getRoot(); + let table: CustomTableNode|null = null; + for (const child of root.getChildren()) { + if ($isCustomTableNode(child)) { + table = child as CustomTableNode; + break; + } + } + + if (table) { + $setTableColumnWidth(table, 1, 500); + } + }); + }, + isActive() { + return false; + } + }) ]); } \ No newline at end of file From 59936631ecfe1cfd1a987a91f6b550124df35396 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 25 Jun 2024 18:33:29 +0100 Subject: [PATCH 026/107] Lexical: Extracted mouse drag tracking to new helper --- resources/js/wysiwyg/ui/decorators/image.ts | 118 ++++++++---------- .../framework/helpers/mouse-drag-tracker.ts | 76 +++++++++++ .../ui/framework/helpers/table-resizer.ts | 21 +++- 3 files changed, 149 insertions(+), 66 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index 1692d078d..1e8bfd165 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -3,6 +3,7 @@ import {el} from "../../helpers"; import {$createNodeSelection, $setSelection} from "lexical"; import {EditorUiContext} from "../framework/core"; import {ImageNode} from "../../nodes/image"; +import {MouseDragTracker, MouseDragTrackerDistance} from "../framework/helpers/mouse-drag-tracker"; export class ImageDecorator extends EditorDecorator { @@ -15,6 +16,7 @@ export class ImageDecorator extends EditorDecorator { class: 'editor-image-decorator', }, []); let selected = false; + let tracker: MouseDragTracker|null = null; const windowClick = (event: MouseEvent) => { if (!decorateEl.contains(event.target as Node) && (Date.now() - this.dragLastMouseUp > 100)) { @@ -22,14 +24,6 @@ export class ImageDecorator extends EditorDecorator { } }; - const mouseDown = (event: MouseEvent) => { - const handle = (event.target as HTMLElement).closest('.editor-image-decorator-handle') as HTMLElement|null; - if (handle) { - // handlingResize = true; - this.startHandlingResize(handle, event, context); - } - }; - const select = () => { if (selected) { return; @@ -44,7 +38,7 @@ export class ImageDecorator extends EditorDecorator { return el('div', {class: `editor-image-decorator-handle ${c}`}); }); decorateEl.append(...handleElems); - decorateEl.addEventListener('mousedown', mouseDown); + tracker = this.setupTracker(decorateEl, context); context.editor.update(() => { const nodeSelection = $createNodeSelection(); @@ -55,10 +49,9 @@ export class ImageDecorator extends EditorDecorator { const unselect = () => { selected = false; - // handlingResize = false; decorateEl.classList.remove('selected'); window.removeEventListener('click', windowClick); - decorateEl.removeEventListener('mousedown', mouseDown); + tracker?.teardown(); for (const el of handleElems) { el.remove(); } @@ -80,62 +73,61 @@ export class ImageDecorator extends EditorDecorator { return this.dom; } - startHandlingResize(element: HTMLElement, event: MouseEvent, context: EditorUiContext) { - const startingX = event.screenX; - const startingY = event.screenY; - const node = this.getNode() as ImageNode; - let startingWidth = element.clientWidth; - let startingHeight = element.clientHeight; - let startingRatio = startingWidth / startingHeight; + setupTracker(container: HTMLElement, context: EditorUiContext): MouseDragTracker { + let startingWidth: number = 0; + let startingHeight: number = 0; + let startingRatio: number = 0; let hasHeight = false; let firstChange = true; - context.editor.getEditorState().read(() => { - startingWidth = node.getWidth() || startingWidth; - startingHeight = node.getHeight() || startingHeight; - if (node.getHeight()) { - hasHeight = true; + let node: ImageNode = this.getNode() as ImageNode; + let _this = this; + let flipXChange: boolean = false; + let flipYChange: boolean = false; + + return new MouseDragTracker(container, '.editor-image-decorator-handle', { + down(event: MouseEvent, handle: HTMLElement) { + context.editor.getEditorState().read(() => { + startingWidth = node.getWidth() || startingWidth; + startingHeight = node.getHeight() || startingHeight; + if (node.getHeight()) { + hasHeight = true; + } + startingRatio = startingWidth / startingHeight; + }); + + flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); + flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne'); + }, + move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + let xChange = distance.x; + if (flipXChange) { + xChange = 0 - xChange; + } + let yChange = distance.y; + if (flipYChange) { + yChange = 0 - yChange; + } + const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); + const increase = xChange + yChange > 0; + const directedChange = increase ? balancedChange : 0-balancedChange; + const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); + let newHeight = 0; + if (hasHeight) { + newHeight = newWidth * startingRatio; + } + + const updateOptions = firstChange ? {} : {tag: 'history-merge'}; + context.editor.update(() => { + const node = _this.getNode() as ImageNode; + node.setWidth(newWidth); + node.setHeight(newHeight); + }, updateOptions); + firstChange = false; + }, + up() { + _this.dragLastMouseUp = Date.now(); } - startingRatio = startingWidth / startingHeight; }); - - const flipXChange = element.classList.contains('nw') || element.classList.contains('sw'); - const flipYChange = element.classList.contains('nw') || element.classList.contains('ne'); - - const mouseMoveListener = (event: MouseEvent) => { - let xChange = event.screenX - startingX; - if (flipXChange) { - xChange = 0 - xChange; - } - let yChange = event.screenY - startingY; - if (flipYChange) { - yChange = 0 - yChange; - } - const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); - const increase = xChange + yChange > 0; - const directedChange = increase ? balancedChange : 0-balancedChange; - const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); - let newHeight = 0; - if (hasHeight) { - newHeight = newWidth * startingRatio; - } - - const updateOptions = firstChange ? {} : {tag: 'history-merge'}; - context.editor.update(() => { - const node = this.getNode() as ImageNode; - node.setWidth(newWidth); - node.setHeight(newHeight); - }, updateOptions); - firstChange = false; - }; - - const mouseUpListener = (event: MouseEvent) => { - window.removeEventListener('mousemove', mouseMoveListener); - window.removeEventListener('mouseup', mouseUpListener); - this.dragLastMouseUp = Date.now(); - }; - - window.addEventListener('mousemove', mouseMoveListener); - window.addEventListener('mouseup', mouseUpListener); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts b/resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts new file mode 100644 index 000000000..141f9b20f --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/mouse-drag-tracker.ts @@ -0,0 +1,76 @@ + +export type MouseDragTrackerDistance = { + x: number; + y: number; +} + +export type MouseDragTrackerOptions = { + down?: (event: MouseEvent, element: HTMLElement) => any; + move?: (event: MouseEvent, element: HTMLElement, distance: MouseDragTrackerDistance) => any; + up?: (event: MouseEvent, element: HTMLElement, distance: MouseDragTrackerDistance) => any; +} + +export class MouseDragTracker { + protected container: HTMLElement; + protected dragTargetSelector: string; + protected options: MouseDragTrackerOptions; + + protected startX: number = 0; + protected startY: number = 0; + protected target: HTMLElement|null = null; + + constructor(container: HTMLElement, dragTargetSelector: string, options: MouseDragTrackerOptions) { + this.container = container; + this.dragTargetSelector = dragTargetSelector; + this.options = options; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.container.addEventListener('mousedown', this.onMouseDown); + } + + teardown() { + this.container.removeEventListener('mousedown', this.onMouseDown); + this.container.removeEventListener('mouseup', this.onMouseUp); + this.container.removeEventListener('mousemove', this.onMouseMove); + } + + protected onMouseDown(event: MouseEvent) { + this.target = (event.target as HTMLElement).closest(this.dragTargetSelector); + if (!this.target) { + return; + } + + this.startX = event.screenX; + this.startY = event.screenY; + + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + if (this.options.down) { + this.options.down(event, this.target); + } + } + + protected onMouseMove(event: MouseEvent) { + if (this.options.move && this.target) { + this.options.move(event, this.target, { + x: event.screenX - this.startX, + y: event.screenY - this.startY, + }); + } + } + + protected onMouseUp(event: MouseEvent) { + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); + + if (this.options.up && this.target) { + this.options.up(event, this.target, { + x: event.screenX - this.startX, + y: event.screenY - this.startY, + }); + } + } + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index 53017e93b..ccf269daa 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -1,5 +1,6 @@ import {LexicalEditor} from "lexical"; import {el} from "../../../helpers"; +import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; @@ -7,6 +8,7 @@ class TableResizer { protected editor: LexicalEditor; protected editArea: HTMLElement; protected markerDom: MarkerDomRecord|null = null; + protected mouseTracker: MouseDragTracker|null = null; constructor(editor: LexicalEditor, editArea: HTMLElement) { this.editor = editor; @@ -49,14 +51,27 @@ class TableResizer { getMarkers(): MarkerDomRecord { if (!this.markerDom) { this.markerDom = { - x: el('div', {class: 'editor-table-marker-column'}), - y: el('div', {class: 'editor-table-marker-row'}), + x: el('div', {class: 'editor-table-marker editor-table-marker-column'}), + y: el('div', {class: 'editor-table-marker editor-table-marker-row'}), } - this.editArea.after(this.markerDom.x, this.markerDom.y); + const wrapper = el('div', { + class: 'editor-table-marker-wrap', + }, [this.markerDom.x, this.markerDom.y]); + this.editArea.after(wrapper); + this.watchMarkerMouseDrags(wrapper); } return this.markerDom; } + + watchMarkerMouseDrags(wrapper: HTMLElement) { + this.mouseTracker = new MouseDragTracker(wrapper, '.editor-table-marker', { + up(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) { + console.log('up', distance, marker); + // TODO - Update row/column for distance + } + }); + } } From b1130cb1c32f5993bf40a763186793ec0824b8a9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 26 Jun 2024 13:52:00 +0100 Subject: [PATCH 027/107] Lexical: Linked up table resize handler (unfinished) --- resources/js/wysiwyg/nodes/custom-table.ts | 50 ++++++++---- .../ui/framework/helpers/table-resizer.ts | 77 +++++++++++++++++-- resources/sass/_editor.scss | 5 +- 3 files changed, 110 insertions(+), 22 deletions(-) diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index c070e06b5..1107f0a90 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -1,5 +1,5 @@ import {SerializedTableNode, TableNode, TableRowNode} from "@lexical/table"; -import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; +import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalEditor, LexicalNode, Spread} from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../helpers"; @@ -111,6 +111,21 @@ export class CustomTableNode extends TableNode { } function getTableColumnWidths(table: HTMLTableElement): string[] { + const maxColRow = getMaxColRowFromTable(table); + + const colGroup = table.querySelector('colgroup'); + let widths: string[] = []; + if (colGroup && (colGroup.childElementCount === maxColRow?.childElementCount || !maxColRow)) { + widths = extractWidthsFromRow(colGroup); + } + if (widths.filter(Boolean).length === 0 && maxColRow) { + widths = extractWidthsFromRow(maxColRow); + } + + return widths; +} + +function getMaxColRowFromTable(table: HTMLTableElement): HTMLTableRowElement|null { const rows = table.querySelectorAll('tr'); let maxColCount: number = 0; let maxColRow: HTMLTableRowElement|null = null; @@ -122,16 +137,7 @@ function getTableColumnWidths(table: HTMLTableElement): string[] { } } - const colGroup = table.querySelector('colgroup'); - let widths: string[] = []; - if (colGroup && colGroup.childElementCount === maxColCount) { - widths = extractWidthsFromRow(colGroup); - } - if (widths.filter(Boolean).length === 0 && maxColRow) { - widths = extractWidthsFromRow(maxColRow); - } - - return widths; + return maxColRow; } function extractWidthsFromRow(row: HTMLTableRowElement|HTMLTableColElement) { @@ -140,7 +146,7 @@ function extractWidthsFromRow(row: HTMLTableRowElement|HTMLTableColElement) { function extractWidthFromElement(element: HTMLElement): string { let width = element.style.width || element.getAttribute('width'); - if (!Number.isNaN(Number(width))) { + if (width && !Number.isNaN(Number(width))) { width = width + 'px'; } @@ -176,5 +182,23 @@ export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, colWidths[columnIndex] = width + 'px'; node.setColWidths(colWidths); - console.log('setting col widths', node, colWidths); +} + +export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number { + const colWidths = node.getColWidths(); + if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) { + return Number(colWidths[columnIndex].replace('px', '')); + } + + // Otherwise, get from table element + const table = editor.getElementByKey(node.__key) as HTMLTableElement|null; + if (table) { + const maxColRow = getMaxColRowFromTable(table); + if (maxColRow && maxColRow.children.length > columnIndex) { + const cell = maxColRow.children[columnIndex]; + return cell.clientWidth; + } + } + + return 0; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index ccf269daa..869de8460 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -1,6 +1,7 @@ -import {LexicalEditor} from "lexical"; +import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; import {el} from "../../../helpers"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; +import {$getTableColumnWidth, $setTableColumnWidth, CustomTableNode} from "../../../nodes/custom-table"; type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; @@ -9,6 +10,10 @@ class TableResizer { protected editArea: HTMLElement; protected markerDom: MarkerDomRecord|null = null; protected mouseTracker: MouseDragTracker|null = null; + protected dragging: boolean = false; + protected targetCell: HTMLElement|null = null; + protected xMarkerAtStart : boolean = false; + protected yMarkerAtStart : boolean = false; constructor(editor: LexicalEditor, editArea: HTMLElement) { this.editor = editor; @@ -19,7 +24,7 @@ class TableResizer { setupListeners() { this.editArea.addEventListener('mousemove', event => { const cell = (event.target as HTMLElement).closest('td,th'); - if (cell) { + if (cell && !this.dragging) { this.onCellMouseMove(cell as HTMLElement, event); } }); @@ -29,8 +34,13 @@ class TableResizer { const rect = cell.getBoundingClientRect(); const midX = rect.left + (rect.width / 2); const midY = rect.top + (rect.height / 2); - const xMarkerPos = event.clientX <= midX ? rect.left : rect.right; - const yMarkerPos = event.clientY <= midY ? rect.top : rect.bottom; + + this.targetCell = cell; + this.xMarkerAtStart = event.clientX <= midX; + this.yMarkerAtStart = event.clientY <= midY; + + const xMarkerPos = this.xMarkerAtStart ? rect.left : rect.right; + const yMarkerPos = this.yMarkerAtStart ? rect.top : rect.bottom; this.updateMarkersTo(cell, xMarkerPos, yMarkerPos); } @@ -65,13 +75,68 @@ class TableResizer { } watchMarkerMouseDrags(wrapper: HTMLElement) { + const _this = this; + let markerStart: number = 0; + let markerProp: 'left' | 'top' = 'left'; + this.mouseTracker = new MouseDragTracker(wrapper, '.editor-table-marker', { + down(event: MouseEvent, marker: HTMLElement) { + marker.classList.add('active'); + _this.dragging = true; + + markerProp = marker.classList.contains('editor-table-marker-column') ? 'left' : 'top'; + markerStart = Number(marker.style[markerProp].replace('px', '')); + }, + move(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) { + marker.style[markerProp] = (markerStart + distance[markerProp === 'left' ? 'x' : 'y']) + 'px'; + }, up(event: MouseEvent, marker: HTMLElement, distance: MouseDragTrackerDistance) { - console.log('up', distance, marker); - // TODO - Update row/column for distance + marker.classList.remove('active'); + marker.style.left = '0'; + marker.style.top = '0'; + + _this.dragging = false; + console.log('up', distance, marker, markerProp, _this.targetCell); + const parentTable = _this.targetCell?.closest('table'); + + if (markerProp === 'left' && _this.targetCell && parentTable) { + const cellIndex = _this.getTargetCellColumnIndex(); + _this.editor.update(() => { + const table = $getNearestNodeFromDOMNode(parentTable); + if (table instanceof CustomTableNode) { + const originalWidth = $getTableColumnWidth(_this.editor, table, cellIndex); + const newWidth = Math.max(originalWidth + distance.x, 10); + $setTableColumnWidth(table, cellIndex, newWidth); + } + }); + } } }); } + + getTargetCellColumnIndex(): number { + const cell = this.targetCell; + if (cell === null) { + return -1; + } + + let index = 0; + const row = cell.parentElement; + for (const rowCell of row?.children || []) { + let size = Number(rowCell.getAttribute('colspan')); + if (Number.isNaN(size) || size < 1) { + size = 1; + } + + index += size; + + if (rowCell === cell) { + return index - 1; + } + } + + return -1; + } } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index b5ee69d98..a4b0e632f 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -169,14 +169,13 @@ } } -.editor-table-marker-row, -.editor-table-marker-column { +.editor-table-marker { position: fixed; background-color: var(--editor-color-primary); z-index: 99; user-select: none; opacity: 0; - &:hover { + &:hover, &.active { opacity: 0.4; } } From 72a0e081ca29657e9960c8a234aff262c90f8d9f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 26 Jun 2024 17:22:00 +0100 Subject: [PATCH 028/107] Lexical: Completed initial table cell resize handle logic --- .../ui/framework/helpers/table-resizer.ts | 75 ++++++++++++++----- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index 869de8460..8f1e978e9 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -2,6 +2,7 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; import {el} from "../../../helpers"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {$getTableColumnWidth, $setTableColumnWidth, CustomTableNode} from "../../../nodes/custom-table"; +import {TableRowNode} from "@lexical/table"; type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; @@ -18,19 +19,28 @@ class TableResizer { constructor(editor: LexicalEditor, editArea: HTMLElement) { this.editor = editor; this.editArea = editArea; + this.setupListeners(); } - setupListeners() { - this.editArea.addEventListener('mousemove', event => { - const cell = (event.target as HTMLElement).closest('td,th'); - if (cell && !this.dragging) { - this.onCellMouseMove(cell as HTMLElement, event); - } - }); + teardown() { + this.editArea.removeEventListener('mousemove', this.onCellMouseMove); + if (this.mouseTracker) { + this.mouseTracker.teardown(); + } } - onCellMouseMove(cell: HTMLElement, event: MouseEvent) { + protected setupListeners() { + this.onCellMouseMove = this.onCellMouseMove.bind(this); + this.editArea.addEventListener('mousemove', this.onCellMouseMove); + } + + protected onCellMouseMove(event: MouseEvent) { + const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement; + if (!cell || this.dragging) { + return; + } + const rect = cell.getBoundingClientRect(); const midX = rect.left + (rect.width / 2); const midY = rect.top + (rect.height / 2); @@ -44,7 +54,7 @@ class TableResizer { this.updateMarkersTo(cell, xMarkerPos, yMarkerPos); } - updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) { + protected updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) { const markers: MarkerDomRecord = this.getMarkers(); const table = cell.closest('table') as HTMLElement; const tableRect = table.getBoundingClientRect(); @@ -58,7 +68,7 @@ class TableResizer { markers.y.style.width = tableRect.width + 'px'; } - getMarkers(): MarkerDomRecord { + protected getMarkers(): MarkerDomRecord { if (!this.markerDom) { this.markerDom = { x: el('div', {class: 'editor-table-marker editor-table-marker-column'}), @@ -74,7 +84,7 @@ class TableResizer { return this.markerDom; } - watchMarkerMouseDrags(wrapper: HTMLElement) { + protected watchMarkerMouseDrags(wrapper: HTMLElement) { const _this = this; let markerStart: number = 0; let markerProp: 'left' | 'top' = 'left'; @@ -96,25 +106,55 @@ class TableResizer { marker.style.top = '0'; _this.dragging = false; - console.log('up', distance, marker, markerProp, _this.targetCell); const parentTable = _this.targetCell?.closest('table'); if (markerProp === 'left' && _this.targetCell && parentTable) { - const cellIndex = _this.getTargetCellColumnIndex(); + let cellIndex = _this.getTargetCellColumnIndex(); + let change = distance.x; + if (_this.xMarkerAtStart && cellIndex > 0) { + cellIndex -= 1; + } else if (_this.xMarkerAtStart && cellIndex === 0) { + change = -change; + } + _this.editor.update(() => { const table = $getNearestNodeFromDOMNode(parentTable); if (table instanceof CustomTableNode) { const originalWidth = $getTableColumnWidth(_this.editor, table, cellIndex); - const newWidth = Math.max(originalWidth + distance.x, 10); + const newWidth = Math.max(originalWidth + change, 10); $setTableColumnWidth(table, cellIndex, newWidth); } }); } + + if (markerProp === 'top' && _this.targetCell) { + const cellElement = _this.targetCell; + + _this.editor.update(() => { + const cellNode = $getNearestNodeFromDOMNode(cellElement); + const rowNode = cellNode?.getParent(); + let rowIndex = rowNode?.getIndexWithinParent() || 0; + + let change = distance.y; + if (_this.yMarkerAtStart && rowIndex > 0) { + rowIndex -= 1; + } else if (_this.yMarkerAtStart && rowIndex === 0) { + change = -change; + } + + const targetRow = rowNode?.getParent()?.getChildren()[rowIndex]; + if (targetRow instanceof TableRowNode) { + const height = targetRow.getHeight() || 0; + const newHeight = Math.max(height + change, 10); + targetRow.setHeight(newHeight); + } + }); + } } }); } - getTargetCellColumnIndex(): number { + protected getTargetCellColumnIndex(): number { const cell = this.targetCell; if (cell === null) { return -1; @@ -143,6 +183,7 @@ class TableResizer { export function registerTableResizer(editor: LexicalEditor, editorArea: HTMLElement): (() => void) { const resizer = new TableResizer(editor, editorArea); - // TODO - Strip/close down resizer - return () => {}; + return () => { + resizer.teardown(); + }; } \ No newline at end of file From 4e2820d6e3be9d2eb1290d5e4e11ed583354a2ad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 27 Jun 2024 15:48:06 +0100 Subject: [PATCH 029/107] Lexical: Added horizontal rule node --- resources/js/wysiwyg/helpers.ts | 8 ++- resources/js/wysiwyg/nodes/horizontal-rule.ts | 64 +++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 2 + .../wysiwyg/ui/defaults/button-definitions.ts | 17 ++++- resources/js/wysiwyg/ui/toolbars.ts | 18 ++---- 5 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/horizontal-rule.ts diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 62e945721..64bcb6490 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -80,12 +80,16 @@ export function toggleSelectionBlockNodeType(editor: LexicalEditor, matcher: Lex }); } -export function insertNewBlockNodeAtSelection(node: LexicalNode) { +export function insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: boolean = true) { const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; if (blockElement) { - blockElement.insertAfter(node); + if (insertAfter) { + blockElement.insertAfter(node); + } else { + blockElement.insertBefore(node); + } } else { $getRoot().append(node); } diff --git a/resources/js/wysiwyg/nodes/horizontal-rule.ts b/resources/js/wysiwyg/nodes/horizontal-rule.ts new file mode 100644 index 000000000..fbd019e72 --- /dev/null +++ b/resources/js/wysiwyg/nodes/horizontal-rule.ts @@ -0,0 +1,64 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode, +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; + +export class HorizontalRuleNode extends ElementNode { + + static getType() { + return 'horizontal-rule'; + } + + static clone(node: HorizontalRuleNode): HorizontalRuleNode { + return new HorizontalRuleNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + return document.createElement('hr'); + } + + updateDOM(prevNode: unknown, dom: HTMLElement) { + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + hr(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return { + node: new HorizontalRuleNode(), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'horizontal-rule', + version: 1, + }; + } + + static importJSON(serializedNode: SerializedElementNode): HorizontalRuleNode { + return $createHorizontalRuleNode(); + } + +} + +export function $createHorizontalRuleNode() { + return new HorizontalRuleNode(); +} + +export function $isHorizontalRuleNode(node: LexicalNode | null | undefined) { + return node instanceof HorizontalRuleNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 6b1b66e66..befc2ab2e 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -8,6 +8,7 @@ import {DetailsNode, SummaryNode} from "./details"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CustomTableNode} from "./custom-table"; +import {HorizontalRuleNode} from "./horizontal-rule"; /** * Load the nodes for lexical. @@ -23,6 +24,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableRowNode, TableCellNode, ImageNode, + HorizontalRuleNode, DetailsNode, SummaryNode, CustomParagraphNode, LinkNode, diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 5e5f0d409..aa8b27ec5 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -9,7 +9,7 @@ import { UNDO_COMMAND } from "lexical"; import { - getNodeFromSelection, + getNodeFromSelection, insertNewBlockNodeAtSelection, selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType @@ -47,8 +47,10 @@ import listCheckIcon from "@icons/editor/list-check.svg" import linkIcon from "@icons/editor/link.svg" import tableIcon from "@icons/editor/table.svg" import imageIcon from "@icons/editor/image.svg" +import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" +import {$createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode} from "../../nodes/horizontal-rule"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -294,6 +296,19 @@ export const image: EditorButtonDefinition = { } }; +export const horizontalRule: EditorButtonDefinition = { + label: 'Insert horizontal line', + icon: horizontalRuleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isHorizontalRuleNode); + } +}; + export const details: EditorButtonDefinition = { label: 'Insert collapsible block', icon: detailsIcon, diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 7f7e99a78..a8ba52c5f 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -2,7 +2,7 @@ import {EditorButton} from "./framework/buttons"; import { blockquote, bold, bulletList, clearFormating, code, dangerCallout, details, - h2, h3, h4, h5, highlightColor, image, + h2, h3, h4, h5, highlightColor, horizontalRule, image, infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, successCallout, superscript, table, taskList, textColor, underline, @@ -67,6 +67,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorTableCreator(), ]), new EditorButton(image), + new EditorButton(horizontalRule), new EditorButton(details), // Meta elements @@ -74,21 +75,10 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { // Test new EditorButton({ - label: 'Expand table col 2', + label: 'Test button', action(context: EditorUiContext) { context.editor.update(() => { - const root = $getRoot(); - let table: CustomTableNode|null = null; - for (const child of root.getChildren()) { - if ($isCustomTableNode(child)) { - table = child as CustomTableNode; - break; - } - } - - if (table) { - $setTableColumnWidth(table, 1, 500); - } + // Do stuff }); }, isActive() { From f10ec3271a632bf70ddaff93f792956b08a7236a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 27 Jun 2024 16:28:06 +0100 Subject: [PATCH 030/107] Lexical: Added overflow container --- resources/icons/editor/more-horizontal.svg | 1 + .../ui/framework/blocks/dropdown-button.ts | 7 +++- .../ui/framework/blocks/overflow-container.ts | 41 +++++++++++++++++++ resources/js/wysiwyg/ui/framework/core.ts | 21 +++++++++- resources/js/wysiwyg/ui/toolbars.ts | 17 ++++---- resources/sass/_editor.scss | 4 ++ 6 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 resources/icons/editor/more-horizontal.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts diff --git a/resources/icons/editor/more-horizontal.svg b/resources/icons/editor/more-horizontal.svg new file mode 100644 index 000000000..ce09d9855 --- /dev/null +++ b/resources/icons/editor/more-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index a419b92b2..b24b4c16c 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -26,7 +26,12 @@ export class EditorDropdownButton extends EditorContainerUiElement { }); } - this.children.push(this.button); + this.addChildren(this.button); + } + + insertItems(...items: EditorUiElement[]) { + this.addChildren(...items); + this.childItems.push(...items); } protected buildDOM(): HTMLElement { diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts new file mode 100644 index 000000000..2c188471e --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -0,0 +1,41 @@ +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {el} from "../../../helpers"; +import {EditorDropdownButton} from "./dropdown-button"; +import moreHorizontal from "@icons/editor/more-horizontal.svg" + + +export class EditorOverflowContainer extends EditorContainerUiElement { + + protected size: number; + protected overflowButton: EditorDropdownButton; + protected content: EditorUiElement[]; + + constructor(size: number, children: EditorUiElement[]) { + super(children); + this.size = size; + this.content = children; + this.overflowButton = new EditorDropdownButton({ + label: 'More', + icon: moreHorizontal, + }, []); + this.addChildren(this.overflowButton); + } + + protected buildDOM(): HTMLElement { + const visibleChildren = this.content.slice(0, this.size); + const invisibleChildren = this.content.slice(this.size); + + const visibleElements = visibleChildren.map(child => child.getDOMElement()); + if (invisibleChildren.length > 0) { + this.removeChildren(...invisibleChildren); + this.overflowButton.insertItems(...invisibleChildren); + visibleElements.push(this.overflowButton.getDOMElement()); + } + + return el('div', { + class: 'editor-overflow-container', + }, visibleElements); + } + + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index d437b36bd..5e29a0f9e 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -50,11 +50,11 @@ export abstract class EditorUiElement { } export class EditorContainerUiElement extends EditorUiElement { - protected children : EditorUiElement[]; + protected children : EditorUiElement[] = []; constructor(children: EditorUiElement[]) { super(); - this.children = children; + this.children.push(...children); } protected buildDOM(): HTMLElement { @@ -65,6 +65,23 @@ export class EditorContainerUiElement extends EditorUiElement { return this.children; } + protected addChildren(...children: EditorUiElement[]): void { + this.children.push(...children); + } + + protected removeChildren(...children: EditorUiElement[]): void { + for (const child of children) { + this.removeChild(child); + } + } + + protected removeChild(child: EditorUiElement) { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + } + } + updateState(state: EditorUiStateUpdate): void { for (const child of this.children) { child.updateState(state); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index a8ba52c5f..02e46549e 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -17,8 +17,7 @@ import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; import {EditorColorPicker} from "./framework/blocks/color-picker"; import {EditorTableCreator} from "./framework/blocks/table-creator"; import {EditorColorButton} from "./framework/blocks/color-button"; -import {$isCustomTableNode, $setTableColumnWidth, CustomTableNode} from "../nodes/custom-table"; -import {$getRoot} from "lexical"; +import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -62,13 +61,15 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(taskList), // Insert types - new EditorButton(link), - new EditorDropdownButton(table, [ - new EditorTableCreator(), + new EditorOverflowContainer(6, [ + new EditorButton(link), + new EditorDropdownButton(table, [ + new EditorTableCreator(), + ]), + new EditorButton(image), + new EditorButton(horizontalRule), + new EditorButton(details), ]), - new EditorButton(image), - new EditorButton(horizontalRule), - new EditorButton(details), // Meta elements new EditorButton(source), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index a4b0e632f..e0a2bac90 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -65,6 +65,10 @@ min-width: 320px; } +.editor-overflow-container { + display: flex; +} + // Modals .editor-modal-wrapper { position: fixed; From 517c578a5f3c7718db9dc54603e553c0a935a89f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 30 Jun 2024 10:31:39 +0100 Subject: [PATCH 031/107] Lexical: Reorganised some logic into manager --- .../ui/framework/blocks/dropdown-button.ts | 4 +- resources/js/wysiwyg/ui/framework/core.ts | 1 + resources/js/wysiwyg/ui/framework/manager.ts | 56 +++++++++++++++++-- resources/js/wysiwyg/ui/index.ts | 40 ++----------- 4 files changed, 59 insertions(+), 42 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index b24b4c16c..70e1a9ffc 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -49,10 +49,10 @@ export class EditorDropdownButton extends EditorContainerUiElement { handleDropdown(button, menu, () => { this.open = true; - this.getContext().manager.triggerStateUpdate(this.button); + this.getContext().manager.triggerStateUpdateForElement(this.button); }, () => { this.open = false; - this.getContext().manager.triggerStateUpdate(this.button); + this.getContext().manager.triggerStateUpdateForElement(this.button); }); return wrapper; diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 5e29a0f9e..2972c9821 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -9,6 +9,7 @@ export type EditorUiStateUpdate = { export type EditorUiContext = { editor: LexicalEditor, + editorDOM: HTMLElement, translate: (text: string) => string, manager: EditorUIManager, lastSelection: BaseSelection|null, diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 78ddc8ce3..2ef117b88 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,6 +1,9 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; -import {EditorUiContext, EditorUiElement} from "./core"; -import {EditorDecorator} from "./decorator"; +import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; +import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; +import {$getSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; +import {DecoratorListener} from "lexical/LexicalEditor"; +import type {NodeKey} from "lexical/LexicalNode"; export class EditorUIManager { @@ -9,9 +12,11 @@ export class EditorUIManager { protected decoratorConstructorsByType: Record = {}; protected decoratorInstancesByNodeKey: Record = {}; protected context: EditorUiContext|null = null; + protected toolbar: EditorContainerUiElement|null = null; setContext(context: EditorUiContext) { this.context = context; + this.setupEditor(context.editor); } getContext(): EditorUiContext { @@ -22,7 +27,7 @@ export class EditorUIManager { return this.context; } - triggerStateUpdate(element: EditorUiElement) { + triggerStateUpdateForElement(element: EditorUiElement) { element.updateState({ selection: null, editor: this.getContext().editor @@ -49,7 +54,7 @@ export class EditorUIManager { this.decoratorConstructorsByType[type] = decorator; } - getDecorator(decoratorType: string, nodeKey: string): EditorDecorator { + protected getDecorator(decoratorType: string, nodeKey: string): EditorDecorator { if (this.decoratorInstancesByNodeKey[nodeKey]) { return this.decoratorInstancesByNodeKey[nodeKey]; } @@ -64,4 +69,47 @@ export class EditorUIManager { this.decoratorInstancesByNodeKey[nodeKey] = decorator; return decorator; } + + setToolbar(toolbar: EditorContainerUiElement) { + if (this.toolbar) { + this.toolbar.getDOMElement().remove(); + } + + this.toolbar = toolbar; + toolbar.setContext(this.getContext()); + this.getContext().editorDOM.before(toolbar.getDOMElement()); + } + + protected triggerStateUpdate(state: EditorUiStateUpdate): void { + const context = this.getContext(); + context.lastSelection = state.selection; + this.toolbar?.updateState(state); + } + + protected setupEditor(editor: LexicalEditor) { + // Update button states on editor selection change + editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { + this.triggerStateUpdate({ + editor: editor, + selection: $getSelection(), + }); + return false; + }, COMMAND_PRIORITY_LOW); + + // Register our DOM decorate listener with the editor + const domDecorateListener: DecoratorListener = (decorators: Record) => { + const keys = Object.keys(decorators); + for (const key of keys) { + const decoratedEl = editor.getElementByKey(key); + const adapter = decorators[key]; + const decorator = this.getDecorator(adapter.type, key); + decorator.setNode(adapter.getNode()); + const decoratorEl = decorator.render(this.getContext()); + if (decoratedEl) { + decoratedEl.append(decoratorEl); + } + } + } + editor.registerDecoratorListener(domDecorateListener); + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index b2fd7e05a..c823f9f95 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,15 +1,7 @@ -import { - $getSelection, - COMMAND_PRIORITY_LOW, - LexicalEditor, - SELECTION_CHANGE_COMMAND -} from "lexical"; +import {LexicalEditor} from "lexical"; import {getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; -import {DecoratorListener} from "lexical/LexicalEditor"; -import type {NodeKey} from "lexical/LexicalNode"; -import {EditorDecoratorAdapter} from "./framework/decorator"; import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; @@ -17,6 +9,7 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, + editorDOM: element, manager, translate: (text: string): string => text, lastSelection: null, @@ -24,9 +17,7 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { manager.setContext(context); // Create primary toolbar - const toolbar = getMainEditorFullToolbar(); - toolbar.setContext(context); - element.before(toolbar.getDOMElement()); + manager.setToolbar(getMainEditorFullToolbar()); // Register modals manager.registerModal('link', { @@ -42,29 +33,6 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { form: sourceFormDefinition, }); - // Register decorator listener - // Maybe move to manager? + // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); - const domDecorateListener: DecoratorListener = (decorators: Record) => { - const keys = Object.keys(decorators); - for (const key of keys) { - const decoratedEl = editor.getElementByKey(key); - const adapter = decorators[key]; - const decorator = manager.getDecorator(adapter.type, key); - decorator.setNode(adapter.getNode()); - const decoratorEl = decorator.render(context); - if (decoratedEl) { - decoratedEl.append(decoratorEl); - } - } - } - editor.registerDecoratorListener(domDecorateListener); - - // Update button states on editor selection change - editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { - const selection = $getSelection(); - toolbar.updateState({editor, selection}); - context.lastSelection = selection; - return false; - }, COMMAND_PRIORITY_LOW); } \ No newline at end of file From c9a03c5b01dd0eb5fdd6a1eba75e77f761cd6aee Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 30 Jun 2024 12:13:13 +0100 Subject: [PATCH 032/107] Lexical: Added base context toolbar logic --- resources/js/wysiwyg/ui/framework/manager.ts | 59 +++++++++++++++++-- resources/js/wysiwyg/ui/framework/toolbars.ts | 36 +++++++++++ resources/js/wysiwyg/ui/index.ts | 11 +++- resources/js/wysiwyg/ui/toolbars.ts | 6 +- 4 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/toolbars.ts diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 2ef117b88..685031ff8 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -4,7 +4,7 @@ import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; import {$getSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; - +import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; export class EditorUIManager { @@ -13,6 +13,8 @@ export class EditorUIManager { protected decoratorInstancesByNodeKey: Record = {}; protected context: EditorUiContext|null = null; protected toolbar: EditorContainerUiElement|null = null; + protected contextToolbarDefinitionsByKey: Record = {}; + protected activeContextToolbars: EditorContextToolbar[] = []; setContext(context: EditorUiContext) { this.context = context; @@ -80,10 +82,59 @@ export class EditorUIManager { this.getContext().editorDOM.before(toolbar.getDOMElement()); } - protected triggerStateUpdate(state: EditorUiStateUpdate): void { + registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) { + this.contextToolbarDefinitionsByKey[key] = definition; + } + + protected triggerStateUpdate(update: EditorUiStateUpdate): void { const context = this.getContext(); - context.lastSelection = state.selection; - this.toolbar?.updateState(state); + context.lastSelection = update.selection; + this.toolbar?.updateState(update); + this.updateContextToolbars(update); + for (const toolbar of this.activeContextToolbars) { + toolbar.updateState(update); + } + } + + protected updateContextToolbars(update: EditorUiStateUpdate): void { + for (const toolbar of this.activeContextToolbars) { + toolbar.empty(); + toolbar.getDOMElement().remove(); + } + + const node = (update.selection?.getNodes() || [])[0] || null; + if (!node) { + return; + } + + const element = update.editor.getElementByKey(node.getKey()); + if (!element) { + return; + } + + const toolbarKeys = Object.keys(this.contextToolbarDefinitionsByKey); + const contentByTarget = new Map(); + for (const key of toolbarKeys) { + const definition = this.contextToolbarDefinitionsByKey[key]; + const matchingElem = ((element.closest(definition.selector)) || (element.querySelector(definition.selector))) as HTMLElement|null; + if (matchingElem) { + const targetEl = definition.displayTargetLocator ? definition.displayTargetLocator(matchingElem) : matchingElem; + if (!contentByTarget.has(targetEl)) { + contentByTarget.set(targetEl, []) + } + // @ts-ignore + contentByTarget.get(targetEl).push(...definition.content); + } + } + + for (const [target, contents] of contentByTarget) { + const toolbar = new EditorContextToolbar(contents); + toolbar.setContext(this.getContext()); + this.activeContextToolbars.push(toolbar); + + this.getContext().editorDOM.after(toolbar.getDOMElement()); + toolbar.attachTo(target); + } } protected setupEditor(editor: LexicalEditor) { diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts new file mode 100644 index 000000000..a844161f4 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -0,0 +1,36 @@ +import {EditorContainerUiElement, EditorUiElement} from "./core"; +import {el} from "../../helpers"; + +export type EditorContextToolbarDefinition = { + selector: string; + content: EditorUiElement[], + displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement; +}; + +export class EditorContextToolbar extends EditorContainerUiElement { + + protected buildDOM(): HTMLElement { + return el('div', { + class: 'editor-context-toolbar', + }, this.getChildren().map(child => child.getDOMElement())); + } + + attachTo(target: HTMLElement) { + // Todo - attach to target position + console.log('attaching context toolbar to', target); + } + + insert(children: EditorUiElement[]) { + this.addChildren(...children); + const dom = this.getDOMElement(); + dom.append(...children.map(child => child.getDOMElement())); + } + + empty() { + const children = this.getChildren(); + for (const child of children) { + child.getDOMElement().remove(); + } + this.removeChildren(...children); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index c823f9f95..9f05e211e 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,5 +1,5 @@ import {LexicalEditor} from "lexical"; -import {getMainEditorFullToolbar} from "./toolbars"; +import {getImageToolbarContent, getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {ImageDecorator} from "./decorators/image"; @@ -33,6 +33,15 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { form: sourceFormDefinition, }); + // Register context toolbars + manager.registerContextToolbar('image', { + selector: 'img', + content: getImageToolbarContent(), + displayTargetLocator(originalTarget: HTMLElement) { + return originalTarget.closest('a') || originalTarget; + } + }); + // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 02e46549e..6e822e9c1 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -9,7 +9,7 @@ import { undo, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext} from "./framework/core"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; import {el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; @@ -87,4 +87,8 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { } }) ]); +} + +export function getImageToolbarContent(): EditorUiElement[] { + return [new EditorButton(image)]; } \ No newline at end of file From b1c489090ecfdbde705de14825e0bd69ce2b5d7d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 30 Jun 2024 19:52:09 +0100 Subject: [PATCH 033/107] Lexical: Added context toolbar placement, added link toolbar Also added some basic context toolbar styling --- resources/icons/editor/unlink.svg | 1 + .../wysiwyg/ui/defaults/button-definitions.ts | 30 +++++++++++++++++-- resources/js/wysiwyg/ui/framework/manager.ts | 1 + resources/js/wysiwyg/ui/framework/toolbars.ts | 10 +++++-- resources/js/wysiwyg/ui/index.ts | 6 +++- resources/js/wysiwyg/ui/toolbars.ts | 9 +++++- resources/sass/_editor.scss | 24 +++++++++++++++ 7 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 resources/icons/editor/unlink.svg diff --git a/resources/icons/editor/unlink.svg b/resources/icons/editor/unlink.svg new file mode 100644 index 000000000..28f47fd24 --- /dev/null +++ b/resources/icons/editor/unlink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index aa8b27ec5..cebf25807 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,7 +1,7 @@ import {EditorBasicButtonDefinition, EditorButton, EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, - $createParagraphNode, $getRoot, $getSelection, + $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isParagraphNode, $isTextNode, $setSelection, BaseSelection, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, ElementNode, FORMAT_TEXT_COMMAND, LexicalNode, @@ -45,12 +45,13 @@ import listBulletIcon from "@icons/editor/list-bullet.svg" import listNumberedIcon from "@icons/editor/list-numbered.svg" import listCheckIcon from "@icons/editor/list-check.svg" import linkIcon from "@icons/editor/link.svg" +import unlinkIcon from "@icons/editor/unlink.svg" import tableIcon from "@icons/editor/table.svg" import imageIcon from "@icons/editor/image.svg" import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" -import {$createHorizontalRuleNode, $isHorizontalRuleNode, HorizontalRuleNode} from "../../nodes/horizontal-rule"; +import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -258,6 +259,31 @@ export const link: EditorButtonDefinition = { } }; +export const unlink: EditorButtonDefinition = { + label: 'Remove link', + icon: unlinkIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = context.lastSelection; + const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + const selectionPoints = selection?.getStartEndPoints(); + + if (selectedLink) { + const newNode = $createTextNode(selectedLink.getTextContent()); + selectedLink.replace(newNode); + if (selectionPoints?.length === 2) { + newNode.select(selectionPoints[0].offset, selectionPoints[1].offset); + } else { + newNode.select(); + } + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + } +}; + export const table: EditorBasicButtonDefinition = { label: 'Table', icon: tableIcon, diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 685031ff8..4ca90a12a 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -94,6 +94,7 @@ export class EditorUIManager { for (const toolbar of this.activeContextToolbars) { toolbar.updateState(update); } + // console.log('selection update', update.selection); } protected updateContextToolbars(update: EditorUiStateUpdate): void { diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index a844161f4..c9db0d6bd 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -16,8 +16,14 @@ export class EditorContextToolbar extends EditorContainerUiElement { } attachTo(target: HTMLElement) { - // Todo - attach to target position - console.log('attaching context toolbar to', target); + const targetBounds = target.getBoundingClientRect(); + const dom = this.getDOMElement(); + const domBounds = dom.getBoundingClientRect(); + + const targetMid = targetBounds.left + (targetBounds.width / 2); + const targetLeft = targetMid - (domBounds.width / 2); + dom.style.top = (targetBounds.bottom + 6) + 'px'; + dom.style.left = targetLeft + 'px'; } insert(children: EditorUiElement[]) { diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 9f05e211e..1f07fe710 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,5 +1,5 @@ import {LexicalEditor} from "lexical"; -import {getImageToolbarContent, getMainEditorFullToolbar} from "./toolbars"; +import {getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {ImageDecorator} from "./decorators/image"; @@ -41,6 +41,10 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { return originalTarget.closest('a') || originalTarget; } }); + manager.registerContextToolbar('link', { + selector: 'a', + content: getLinkToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 6e822e9c1..bb3b436b9 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -6,7 +6,7 @@ import { infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, successCallout, superscript, table, taskList, textColor, underline, - undo, + undo, unlink, warningCallout } from "./defaults/button-definitions"; import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; @@ -91,4 +91,11 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { export function getImageToolbarContent(): EditorUiElement[] { return [new EditorButton(image)]; +} + +export function getLinkToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(link), + new EditorButton(unlink), + ]; } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e0a2bac90..2fcf4edb3 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -69,6 +69,30 @@ display: flex; } +.editor-context-toolbar { + position: fixed; + background-color: #FFF; + border: 1px solid #DDD; + padding: .2rem; + border-radius: 4px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); + &:before { + content: ''; + z-index: -1; + display: block; + width: 8px; + height: 8px; + position: absolute; + background-color: #FFF; + border-top: 1px solid #DDD; + border-left: 1px solid #DDD; + transform: rotate(45deg); + left: 50%; + margin-left: -4px; + top: -5px; + } +} + // Modals .editor-modal-wrapper { position: fixed; From c2ecbf071ffddb3f23dd535495ea361954a5ee57 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 1 Jul 2024 10:44:23 +0100 Subject: [PATCH 034/107] Lexical: Added tracked container, added fullscreen action Changed how the editor is loaded in, so it now creates its own DOM, and content is passed via creation function, to be better self-contained. --- resources/icons/editor/fullscreen.svg | 1 + resources/js/components/wysiwyg-editor.js | 6 +- resources/js/wysiwyg/index.ts | 32 +++----- .../wysiwyg/ui/defaults/button-definitions.ts | 17 +++- resources/js/wysiwyg/ui/framework/buttons.ts | 13 ++- resources/js/wysiwyg/ui/framework/core.ts | 1 + resources/js/wysiwyg/ui/framework/manager.ts | 11 ++- resources/js/wysiwyg/ui/index.ts | 3 +- resources/js/wysiwyg/ui/toolbars.ts | 3 +- resources/sass/_editor.scss | 14 ++++ .../pages/parts/wysiwyg-editor.blade.php | 81 ++++++++++--------- 11 files changed, 108 insertions(+), 74 deletions(-) create mode 100644 resources/icons/editor/fullscreen.svg diff --git a/resources/icons/editor/fullscreen.svg b/resources/icons/editor/fullscreen.svg new file mode 100644 index 000000000..3cca3097a --- /dev/null +++ b/resources/icons/editor/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 98732dab7..bdcdd5c51 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -4,10 +4,12 @@ export class WysiwygEditor extends Component { setup() { this.elem = this.$el; - this.editArea = this.$refs.editArea; + this.editContainer = this.$refs.editContainer; + this.editContent = this.$refs.editContent; window.importVersioned('wysiwyg').then(wysiwyg => { - wysiwyg.createPageEditorInstance(this.editArea); + const editorContent = this.editContent.textContent; + wysiwyg.createPageEditorInstance(this.editContainer, editorContent); }); } diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9f2f1645a..3ca01835f 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -6,8 +6,9 @@ import {getNodesForPageEditor} from './nodes'; import {buildEditorUI} from "./ui"; import {setEditorContentFromHtml} from "./actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; +import {el} from "./helpers"; -export function createPageEditorInstance(editArea: HTMLElement) { +export function createPageEditorInstance(container: HTMLElement, htmlContent: string) { const config: CreateEditorArgs = { namespace: 'BookStackPageEditor', nodes: getNodesForPageEditor(), @@ -26,7 +27,11 @@ export function createPageEditorInstance(editArea: HTMLElement) { } }; - const startingHtml = editArea.innerHTML; + const editArea = el('div', { + contenteditable: 'true', + }); + container.append(editArea); + container.classList.add('editor-container'); const editor = createEditor(config); editor.setRootElement(editArea); @@ -37,7 +42,7 @@ export function createPageEditorInstance(editArea: HTMLElement) { registerTableResizer(editor, editArea), ); - setEditorContentFromHtml(editor, startingHtml); + setEditorContentFromHtml(editor, htmlContent); const debugView = document.getElementById('lexical-debug'); editor.registerUpdateListener(({editorState}) => { @@ -47,24 +52,5 @@ export function createPageEditorInstance(editArea: HTMLElement) { } }); - buildEditorUI(editArea, editor); - - // Example of creating, registering and using a custom command - - // const SET_BLOCK_CALLOUT_COMMAND = createCommand(); - // editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = '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'); - // }); + buildEditorUI(container, editArea, editor); } diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index cebf25807..4a45ef75d 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -51,6 +51,7 @@ import imageIcon from "@icons/editor/image.svg" import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" +import fullscreenIcon from "@icons/editor/fullscreen.svg" import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; export const undo: EditorButtonDefinition = { @@ -206,7 +207,7 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); - if (this.isActive(selection)) { + if (this.isActive(selection, context)) { removeList(context.editor); } else { insertList(context.editor, type); @@ -374,4 +375,18 @@ export const source: EditorButtonDefinition = { isActive() { return false; } +}; + +export const fullscreen: EditorButtonDefinition = { + label: 'Fullscreen', + icon: fullscreenIcon, + async action(context: EditorUiContext, button: EditorButton) { + const isFullScreen = context.containerDOM.classList.contains('fullscreen'); + context.containerDOM.classList.toggle('fullscreen', !isFullScreen); + (context.containerDOM.closest('body') as HTMLElement).classList.toggle('editor-is-fullscreen', !isFullScreen); + button.setActiveState(!isFullScreen); + }, + isActive(selection, context: EditorUiContext) { + return context.containerDOM.classList.contains('fullscreen'); + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 7e8df076a..f74201ff7 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -8,8 +8,8 @@ export interface EditorBasicButtonDefinition { } export interface EditorButtonDefinition extends EditorBasicButtonDefinition { - action: (context: EditorUiContext) => void; - isActive: (selection: BaseSelection|null) => boolean; + action: (context: EditorUiContext, button: EditorButton) => void; + isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean; setup?: (context: EditorUiContext, button: EditorButton) => void; } @@ -68,11 +68,16 @@ export class EditorButton extends EditorUiElement { } protected onClick() { - this.definition.action(this.getContext()); + this.definition.action(this.getContext(), this); } updateActiveState(selection: BaseSelection|null) { - this.active = this.definition.isActive(selection); + const isActive = this.definition.isActive(selection, this.getContext()); + this.setActiveState(isActive); + } + + setActiveState(active: boolean) { + this.active = active; this.dom?.classList.toggle('editor-button-active', this.active); } diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 2972c9821..465765caa 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -10,6 +10,7 @@ export type EditorUiStateUpdate = { export type EditorUiContext = { editor: LexicalEditor, editorDOM: HTMLElement, + containerDOM: HTMLElement, translate: (text: string) => string, manager: EditorUIManager, lastSelection: BaseSelection|null, diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 4ca90a12a..3c2ad8926 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -79,7 +79,7 @@ export class EditorUIManager { this.toolbar = toolbar; toolbar.setContext(this.getContext()); - this.getContext().editorDOM.before(toolbar.getDOMElement()); + this.getContext().containerDOM.prepend(toolbar.getDOMElement()); } registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) { @@ -97,6 +97,13 @@ export class EditorUIManager { // console.log('selection update', update.selection); } + triggerStateRefresh(): void { + this.triggerStateUpdate({ + editor: this.getContext().editor, + selection: this.getContext().lastSelection, + }); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (const toolbar of this.activeContextToolbars) { toolbar.empty(); @@ -133,7 +140,7 @@ export class EditorUIManager { toolbar.setContext(this.getContext()); this.activeContextToolbars.push(toolbar); - this.getContext().editorDOM.after(toolbar.getDOMElement()); + this.getContext().containerDOM.append(toolbar.getDOMElement()); toolbar.attachTo(target); } } diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 1f07fe710..3501ed557 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -5,10 +5,11 @@ import {image as imageFormDefinition, link as linkFormDefinition, source as sour import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; -export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { +export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, + containerDOM: container, editorDOM: element, manager, translate: (text: string): string => text, diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index bb3b436b9..550c798c2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,7 +1,7 @@ import {EditorButton} from "./framework/buttons"; import { blockquote, bold, bulletList, clearFormating, code, - dangerCallout, details, + dangerCallout, details, fullscreen, h2, h3, h4, h5, highlightColor, horizontalRule, image, infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, @@ -73,6 +73,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { // Meta elements new EditorButton(source), + new EditorButton(fullscreen), // Test new EditorButton({ diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 2fcf4edb3..79cbc73e8 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -4,11 +4,25 @@ } // Main UI elements +.editor-container { + background-color: #FFF; + position: relative; + &.fullscreen { + z-index: 500; + } +} .editor-toolbar-main { display: flex; flex-wrap: wrap; } +body.editor-is-fullscreen { + overflow: hidden; + .edit-area { + z-index: 20; + } +} + // Buttons .editor-button { border: 1px solid #DDD; diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 5cd60bbc6..8fc0dc55a 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -6,48 +6,49 @@ option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}" class=""> -
    -
    -

    Some content here

    -

    Content with image in, before text. Sleepy meow After text.

    -

    This has a link in it

    -

    List below this h2 header

    -
      -
    • Hello
    • -
    - -
    - Collapsible details/summary block -

    Inner text here

    -

    Inner Header

    -

    More text with bold in it

    -
    - -

    - Hello there, this is an info callout -

    - -

    Table

    - - - - - - - - - - - - - - - - -
    Cell ACell BCell C
    Cell DCell ECell F
    -
    +
    + +
    {{-- --}} +
    @if($errors->has('html')) From 97f570a4ee1e275951a6a10a50df1351505e1974 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 2 Jul 2024 14:46:30 +0100 Subject: [PATCH 036/107] Lexical: Started code block node implementation --- resources/js/wysiwyg/nodes/code-block.ts | 168 ++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 2 + .../js/wysiwyg/ui/decorators/code-block.ts | 42 +++++ .../js/wysiwyg/ui/framework/decorator.ts | 7 +- resources/js/wysiwyg/ui/framework/manager.ts | 8 +- resources/js/wysiwyg/ui/index.ts | 2 + 6 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/code-block.ts create mode 100644 resources/js/wysiwyg/ui/decorators/code-block.ts diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts new file mode 100644 index 000000000..7184334a0 --- /dev/null +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -0,0 +1,168 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, + DOMConversionOutput, + LexicalEditor, LexicalNode, + SerializedLexicalNode, + Spread +} from "lexical"; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {el} from "../helpers"; +import {EditorDecoratorAdapter} from "../ui/framework/decorator"; +import {code} from "../ui/defaults/button-definitions"; + +export type SerializedCodeBlockNode = Spread<{ + language: string; + id: string; + code: string; +}, SerializedLexicalNode> + +const getLanguageFromClassList = (classes: string) => { + const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-')); + return (langClasses[0] || '').replace('language-', ''); +}; + +export class CodeBlockNode extends DecoratorNode { + __id: string = ''; + __language: string = ''; + __code: string = ''; + + static getType(): string { + return 'code-block'; + } + + static clone(node: CodeBlockNode): CodeBlockNode { + return new CodeBlockNode(node.__language, node.__code); + } + + constructor(language: string = '', code: string = '', key?: string) { + super(key); + this.__language = language; + this.__code = code; + } + + setLanguage(language: string): void { + const self = this.getWritable(); + self.__language = language; + } + + getLanguage(): string { + const self = this.getLatest(); + return self.__language; + } + + setCode(code: string): void { + const self = this.getWritable(); + self.__code = code; + } + + getCode(): string { + const self = this.getLatest(); + return self.__code; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + // TODO + return { + type: 'code', + getNode: () => this, + }; + } + + isInline(): boolean { + return false; + } + + isIsolated() { + return true; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const codeBlock = el('pre', { + id: this.__id || null, + }, [ + el('code', { + class: this.__language ? `language-${this.__language}` : null, + }, [this.__code]), + ]); + + return el('div', {class: 'editor-code-block-wrap'}, [codeBlock]); + } + + updateDOM(prevNode: CodeBlockNode, dom: HTMLElement) { + const code = dom.querySelector('code'); + if (!code) return false; + + if (prevNode.__language !== this.__language) { + code.className = this.__language ? `language-${this.__language}` : ''; + } + + if (prevNode.__id !== this.__id) { + dom.setAttribute('id', this.__id); + } + + if (prevNode.__code !== this.__code) { + code.textContent = this.__code; + } + + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + pre(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + + const codeEl = element.querySelector('code'); + const language = getLanguageFromClassList(element.className) + || (codeEl && getLanguageFromClassList(codeEl.className)) + || ''; + + const code = codeEl ? (codeEl.textContent || '').trim() : (element.textContent || '').trim(); + + return { + node: $createCodeBlockNode(language, code), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedCodeBlockNode { + return { + type: 'code-block', + version: 1, + id: this.__id, + language: this.__language, + code: this.__code, + }; + } + + static importJSON(serializedNode: SerializedCodeBlockNode): CodeBlockNode { + const node = $createCodeBlockNode(serializedNode.language, serializedNode.code); + node.setId(serializedNode.id || ''); + return node; + } +} + +export function $createCodeBlockNode(language: string = '', code: string = ''): CodeBlockNode { + return new CodeBlockNode(language, code); +} + +export function $isCodeBlockNode(node: LexicalNode | null | undefined) { + return node instanceof CodeBlockNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index befc2ab2e..4cc6bd08b 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -9,6 +9,7 @@ import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CustomTableNode} from "./custom-table"; import {HorizontalRuleNode} from "./horizontal-rule"; +import {CodeBlockNode} from "./code-block"; /** * Load the nodes for lexical. @@ -26,6 +27,7 @@ export function getNodesForPageEditor(): (KlassConstructor | ImageNode, HorizontalRuleNode, DetailsNode, SummaryNode, + CodeBlockNode, CustomParagraphNode, LinkNode, { diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts new file mode 100644 index 000000000..f1fd8c199 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -0,0 +1,42 @@ +import {EditorDecorator} from "../framework/decorator"; +import {el} from "../../helpers"; +import {EditorUiContext} from "../framework/core"; +import {CodeBlockNode} from "../../nodes/code-block"; + + +export class CodeBlockDecorator extends EditorDecorator { + + render(context: EditorUiContext, element: HTMLElement): void { + const codeNode = this.getNode() as CodeBlockNode; + const preEl = element.querySelector('pre'); + if (preEl) { + preEl.hidden = true; + } + + const code = codeNode.__code; + const language = codeNode.__language; + const lines = code.split('\n').length; + const height = (lines * 19.2) + 18 + 24; + element.style.height = `${height}px`; + + let editor = null; + const startTime = Date.now(); + + // Todo - Handling click/edit control + // Todo - Add toolbar button for code + + // @ts-ignore + const renderEditor = (Code) => { + editor = Code.wysiwygView(element, document, code, language); + setTimeout(() => { + element.style.height = ''; + }, 12); + }; + + // @ts-ignore + window.importVersioned('code').then((Code) => { + const timeout = (Date.now() - startTime < 20) ? 20 : 0; + setTimeout(() => renderEditor(Code), timeout); + }); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index 890774126..b0d2392fd 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -27,6 +27,11 @@ export abstract class EditorDecorator { this.node = node; } - abstract render(context: EditorUiContext): HTMLElement; + /** + * Render the decorator. + * If an element is returned, this will be appended to the element + * that is being decorated. + */ + abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 3c2ad8926..a75d24786 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -160,11 +160,15 @@ export class EditorUIManager { const keys = Object.keys(decorators); for (const key of keys) { const decoratedEl = editor.getElementByKey(key); + if (!decoratedEl) { + continue; + } + const adapter = decorators[key]; const decorator = this.getDecorator(adapter.type, key); decorator.setNode(adapter.getNode()); - const decoratorEl = decorator.render(this.getContext()); - if (decoratedEl) { + const decoratorEl = decorator.render(this.getContext(), decoratedEl); + if (decoratorEl) { decoratedEl.append(decoratorEl); } } diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3501ed557..1ad1395dc 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -4,6 +4,7 @@ import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; +import {CodeBlockDecorator} from "./decorators/code-block"; export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); @@ -49,4 +50,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); + manager.registerDecoratorType('code', CodeBlockDecorator); } \ No newline at end of file From d0a5a5ef371512a20f6445fd543dbe95a9b42fac Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 2 Jul 2024 17:34:03 +0100 Subject: [PATCH 037/107] Lexical: Linked code block to editor, added button --- resources/js/global.d.ts | 8 +++ resources/js/wysiwyg/nodes/code-block.ts | 19 +++++- .../js/wysiwyg/ui/decorators/code-block.ts | 60 +++++++++++++++---- .../wysiwyg/ui/defaults/button-definitions.ts | 27 +++++++++ .../js/wysiwyg/ui/framework/decorator.ts | 1 + resources/js/wysiwyg/ui/framework/manager.ts | 28 +++++---- resources/js/wysiwyg/ui/toolbars.ts | 3 +- resources/sass/_editor.scss | 7 +++ 8 files changed, 128 insertions(+), 25 deletions(-) diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index c5aba8ee2..537da6368 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -1,4 +1,12 @@ declare module '*.svg' { const content: string; export default content; +} + +declare global { + interface Window { + $components: { + first: (string) => Object, + } + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts index 7184334a0..934fe7edd 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -73,7 +73,6 @@ export class CodeBlockNode extends DecoratorNode { } decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { - // TODO return { type: 'code', getNode: () => this, @@ -165,4 +164,22 @@ export function $createCodeBlockNode(language: string = '', code: string = ''): export function $isCodeBlockNode(node: LexicalNode | null | undefined) { return node instanceof CodeBlockNode; +} + +export function $openCodeEditorForNode(editor: LexicalEditor, node: CodeBlockNode): void { + const code = node.getCode(); + const language = node.getLanguage(); + + // @ts-ignore + const codeEditor = window.$components.first('code-editor'); + // TODO - Handle direction + codeEditor.open(code, language, 'ltr', (newCode: string, newLang: string) => { + editor.update(() => { + node.setCode(newCode); + node.setLanguage(newLang); + }); + // TODO - Re-focus + }, () => { + // TODO - Re-focus + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index f1fd8c199..80dcef3bd 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,33 +1,46 @@ import {EditorDecorator} from "../framework/decorator"; -import {el} from "../../helpers"; import {EditorUiContext} from "../framework/core"; -import {CodeBlockNode} from "../../nodes/code-block"; +import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; +import {ImageNode} from "../../nodes/image"; export class CodeBlockDecorator extends EditorDecorator { - render(context: EditorUiContext, element: HTMLElement): void { + protected completedSetup: boolean = false; + protected latestCode: string = ''; + protected latestLanguage: string = ''; + + // @ts-ignore + protected editor: any = null; + + setup(context: EditorUiContext, element: HTMLElement) { const codeNode = this.getNode() as CodeBlockNode; const preEl = element.querySelector('pre'); + if (!preEl) { + return; + } + if (preEl) { preEl.hidden = true; } - const code = codeNode.__code; - const language = codeNode.__language; - const lines = code.split('\n').length; + this.latestCode = codeNode.__code; + this.latestLanguage = codeNode.__language; + const lines = this.latestCode.split('\n').length; const height = (lines * 19.2) + 18 + 24; element.style.height = `${height}px`; - let editor = null; const startTime = Date.now(); - // Todo - Handling click/edit control - // Todo - Add toolbar button for code + element.addEventListener('dblclick', event => { + context.editor.getEditorState().read(() => { + $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); + }); + }); // @ts-ignore const renderEditor = (Code) => { - editor = Code.wysiwygView(element, document, code, language); + this.editor = Code.wysiwygView(element, document, this.latestCode, this.latestLanguage); setTimeout(() => { element.style.height = ''; }, 12); @@ -38,5 +51,32 @@ export class CodeBlockDecorator extends EditorDecorator { const timeout = (Date.now() - startTime < 20) ? 20 : 0; setTimeout(() => renderEditor(Code), timeout); }); + + this.completedSetup = true; + } + + update() { + const codeNode = this.getNode() as CodeBlockNode; + const code = codeNode.getCode(); + const language = codeNode.getLanguage(); + + if (this.latestCode === code && this.latestLanguage === language) { + return; + } + this.latestLanguage = language; + this.latestCode = code; + + if (this.editor) { + this.editor.setContent(code); + this.editor.setMode(language, code); + } + } + + render(context: EditorUiContext, element: HTMLElement): void { + if (this.completedSetup) { + this.update(); + } else { + this.setup(context, element); + } } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 4a45ef75d..9f83fbea3 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -49,10 +49,12 @@ import unlinkIcon from "@icons/editor/unlink.svg" import tableIcon from "@icons/editor/table.svg" import imageIcon from "@icons/editor/image.svg" import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg" +import codeBlockIcon from "@icons/editor/code-block.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" import fullscreenIcon from "@icons/editor/fullscreen.svg" import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -336,6 +338,31 @@ export const horizontalRule: EditorButtonDefinition = { } }; +export const codeBlock: EditorButtonDefinition = { + label: 'Insert code block', + icon: codeBlockIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const codeBlock = getNodeFromSelection(selection, $isCodeBlockNode) as (CodeBlockNode|null); + if (codeBlock === null) { + context.editor.update(() => { + const codeBlock = $createCodeBlockNode(); + codeBlock.setCode(selection?.getTextContent() || ''); + insertNewBlockNodeAtSelection(codeBlock, true); + $openCodeEditorForNode(context.editor, codeBlock); + codeBlock.selectStart(); + }); + } else { + $openCodeEditorForNode(context.editor, codeBlock); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, $isCodeBlockNode); + } +}; + export const details: EditorButtonDefinition = { label: 'Insert collapsible block', icon: detailsIcon, diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index b0d2392fd..a9917ab23 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -29,6 +29,7 @@ export abstract class EditorDecorator { /** * Render the decorator. + * Can run on both creation and update for a node decorator. * If an element is returned, this will be appended to the element * that is being decorated. */ diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index a75d24786..6477c4a1a 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -157,21 +157,23 @@ export class EditorUIManager { // Register our DOM decorate listener with the editor const domDecorateListener: DecoratorListener = (decorators: Record) => { - const keys = Object.keys(decorators); - for (const key of keys) { - const decoratedEl = editor.getElementByKey(key); - if (!decoratedEl) { - continue; - } + editor.getEditorState().read(() => { + const keys = Object.keys(decorators); + for (const key of keys) { + const decoratedEl = editor.getElementByKey(key); + if (!decoratedEl) { + continue; + } - const adapter = decorators[key]; - const decorator = this.getDecorator(adapter.type, key); - decorator.setNode(adapter.getNode()); - const decoratorEl = decorator.render(this.getContext(), decoratedEl); - if (decoratorEl) { - decoratedEl.append(decoratorEl); + const adapter = decorators[key]; + const decorator = this.getDecorator(adapter.type, key); + decorator.setNode(adapter.getNode()); + const decoratorEl = decorator.render(this.getContext(), decoratedEl); + if (decoratorEl) { + decoratedEl.append(decoratorEl); + } } - } + }); } editor.registerDecoratorListener(domDecorateListener); } diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 550c798c2..18b811380 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,6 +1,6 @@ import {EditorButton} from "./framework/buttons"; import { - blockquote, bold, bulletList, clearFormating, code, + blockquote, bold, bulletList, clearFormating, code, codeBlock, dangerCallout, details, fullscreen, h2, h3, h4, h5, highlightColor, horizontalRule, image, infoCallout, italic, link, numberList, paragraph, @@ -68,6 +68,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), new EditorButton(image), new EditorButton(horizontalRule), + new EditorButton(codeBlock), new EditorButton(details), ]), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 753038263..5305ada82 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -244,6 +244,13 @@ body.editor-is-fullscreen { cursor: row-resize; } +.editor-code-block-wrap { + user-select: none; + > * { + pointer-events: none; + } +} + // Editor theme styles .editor-theme-bold { font-weight: bold; From feca1f0502177dc3d9911101000244ed6a25396d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 3 Jul 2024 10:28:04 +0100 Subject: [PATCH 038/107] Lexical: Started diagram support --- resources/js/wysiwyg/nodes/code-block.ts | 1 - resources/js/wysiwyg/nodes/diagram.ts | 158 ++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 2 + resources/js/wysiwyg/ui/decorators/diagram.ts | 25 +++ resources/js/wysiwyg/ui/index.ts | 2 + tsconfig.json | 2 +- 6 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/diagram.ts create mode 100644 resources/js/wysiwyg/ui/decorators/diagram.ts diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts index 934fe7edd..f839501db 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -10,7 +10,6 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../helpers"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {code} from "../ui/defaults/button-definitions"; export type SerializedCodeBlockNode = Spread<{ language: string; diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts new file mode 100644 index 000000000..15726813c --- /dev/null +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -0,0 +1,158 @@ +import { + DecoratorNode, + DOMConversion, + DOMConversionMap, + DOMConversionOutput, + LexicalEditor, LexicalNode, + SerializedLexicalNode, + Spread +} from "lexical"; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {el} from "../helpers"; +import {EditorDecoratorAdapter} from "../ui/framework/decorator"; + +export type SerializedDiagramNode = Spread<{ + id: string; + drawingId: string; + drawingUrl: string; +}, SerializedLexicalNode> + +export class DiagramNode extends DecoratorNode { + __id: string = ''; + __drawingId: string = ''; + __drawingUrl: string = ''; + + static getType(): string { + return 'diagram'; + } + + static clone(node: DiagramNode): DiagramNode { + return new DiagramNode(node.__drawingId, node.__drawingUrl); + } + + constructor(drawingId: string, drawingUrl: string, key?: string) { + super(key); + this.__drawingId = drawingId; + this.__drawingUrl = drawingUrl; + } + + setDrawingIdAndUrl(drawingId: string, drawingUrl: string): void { + const self = this.getWritable(); + self.__drawingUrl = drawingUrl; + self.__drawingId = drawingId; + } + + getDrawingIdAndUrl(): {id: string, url: string} { + const self = this.getLatest(); + return { + id: self.__drawingUrl, + url: self.__drawingUrl, + }; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { + return { + type: 'diagram', + getNode: () => this, + }; + } + + isInline(): boolean { + return false; + } + + isIsolated() { + return true; + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + return el('div', { + id: this.__id || null, + 'drawio-diagram': this.__drawingId, + }, [ + el('img', {src: this.__drawingUrl}), + ]); + } + + updateDOM(prevNode: DiagramNode, dom: HTMLElement) { + const img = dom.querySelector('img'); + if (!img) return false; + + if (prevNode.__id !== this.__id) { + dom.setAttribute('id', this.__id); + } + + if (prevNode.__drawingUrl !== this.__drawingUrl) { + img.setAttribute('src', this.__drawingUrl); + } + + if (prevNode.__drawingId !== this.__drawingId) { + dom.setAttribute('drawio-diagram', this.__drawingId); + } + + return false; + } + + static importDOM(): DOMConversionMap|null { + return { + div(node: HTMLElement): DOMConversion|null { + + if (!node.hasAttribute('drawio-diagram')) { + return null; + } + + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + + const img = element.querySelector('img'); + const drawingUrl = img?.getAttribute('src') || ''; + const drawingId = element.getAttribute('drawio-diagram') || ''; + + return { + node: $createDiagramNode(drawingId, drawingUrl), + }; + }, + priority: 3, + }; + }, + }; + } + + exportJSON(): SerializedDiagramNode { + return { + type: 'diagram', + version: 1, + id: this.__id, + drawingId: this.__drawingId, + drawingUrl: this.__drawingUrl, + }; + } + + static importJSON(serializedNode: SerializedDiagramNode): DiagramNode { + const node = $createDiagramNode(serializedNode.drawingId, serializedNode.drawingUrl); + node.setId(serializedNode.id || ''); + return node; + } +} + +export function $createDiagramNode(drawingId: string = '', drawingUrl: string = ''): DiagramNode { + return new DiagramNode(drawingId, drawingUrl); +} + +export function $isDiagramNode(node: LexicalNode | null | undefined) { + return node instanceof DiagramNode; +} + +export function $openDrawingEditorForNode(editor: LexicalEditor, node: DiagramNode): void { + // Todo +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 4cc6bd08b..e2c6902d3 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -10,6 +10,7 @@ import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CustomTableNode} from "./custom-table"; import {HorizontalRuleNode} from "./horizontal-rule"; import {CodeBlockNode} from "./code-block"; +import {DiagramNode} from "./diagram"; /** * Load the nodes for lexical. @@ -28,6 +29,7 @@ export function getNodesForPageEditor(): (KlassConstructor | HorizontalRuleNode, DetailsNode, SummaryNode, CodeBlockNode, + DiagramNode, CustomParagraphNode, LinkNode, { diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts new file mode 100644 index 000000000..2f092bd20 --- /dev/null +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -0,0 +1,25 @@ +import {EditorDecorator} from "../framework/decorator"; +import {EditorUiContext} from "../framework/core"; + + +export class DiagramDecorator extends EditorDecorator { + protected completedSetup: boolean = false; + + setup(context: EditorUiContext, element: HTMLElement) { + // + + this.completedSetup = true; + } + + update() { + // + } + + render(context: EditorUiContext, element: HTMLElement): void { + if (this.completedSetup) { + this.update(); + } else { + this.setup(context, element); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 1ad1395dc..50307fa61 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -5,6 +5,7 @@ import {image as imageFormDefinition, link as linkFormDefinition, source as sour import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; +import {DiagramDecorator} from "./decorators/diagram"; export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor) { const manager = new EditorUIManager(); @@ -51,4 +52,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('code', CodeBlockDecorator); + manager.registerDecoratorType('diagram', DiagramDecorator); } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 40d930149..9913c1235 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,7 +46,7 @@ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ From a8f1160743ed01091910e3df3075a6cadfa6d960 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 3 Jul 2024 11:00:57 +0100 Subject: [PATCH 039/107] JS: Converted come common services to typescript --- package.json | 3 +- resources/js/app.js | 9 +- resources/js/custom.d.ts | 4 + resources/js/global.d.ts | 9 +- resources/js/services/components.js | 165 --------------------- resources/js/services/components.ts | 153 +++++++++++++++++++ resources/js/services/{text.js => text.ts} | 10 +- resources/js/wysiwyg/nodes/code-block.ts | 3 +- tsconfig.json | 3 +- 9 files changed, 172 insertions(+), 187 deletions(-) create mode 100644 resources/js/custom.d.ts delete mode 100644 resources/js/services/components.js create mode 100644 resources/js/services/components.ts rename resources/js/services/{text.js => text.ts} (55%) diff --git a/package.json b/package.json index 439eaa5a1..71debf2bd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "livereload": "livereload ./public/dist/", "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads", "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", - "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"" + "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"", + "ts:lint": "tsc --noEmit" }, "devDependencies": { "@lezer/generator": "^1.5.1", diff --git a/resources/js/app.js b/resources/js/app.js index 5b822e900..123d6c8f5 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,9 +1,8 @@ import * as events from './services/events'; import * as httpInstance from './services/http'; import Translations from './services/translations'; - -import * as components from './services/components'; import * as componentMap from './components'; +import {ComponentStore} from './services/components.ts'; // Url retrieval function window.baseUrl = function baseUrl(path) { @@ -32,6 +31,6 @@ window.trans_choice = translator.getPlural.bind(translator); window.trans_plural = translator.parsePlural.bind(translator); // Load & initialise components -components.register(componentMap); -window.$components = components; -components.init(); +window.$components = new ComponentStore(); +window.$components.register(componentMap); +window.$components.init(); diff --git a/resources/js/custom.d.ts b/resources/js/custom.d.ts new file mode 100644 index 000000000..c5aba8ee2 --- /dev/null +++ b/resources/js/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index 537da6368..da19545d1 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -1,12 +1,7 @@ -declare module '*.svg' { - const content: string; - export default content; -} +import {ComponentStore} from "./services/components"; declare global { interface Window { - $components: { - first: (string) => Object, - } + $components: ComponentStore, } } \ No newline at end of file diff --git a/resources/js/services/components.js b/resources/js/services/components.js deleted file mode 100644 index beb0ce92f..000000000 --- a/resources/js/services/components.js +++ /dev/null @@ -1,165 +0,0 @@ -import {kebabToCamel, camelToKebab} from './text'; - -/** - * A mapping of active components keyed by name, with values being arrays of component - * instances since there can be multiple components of the same type. - * @type {Object} - */ -const components = {}; - -/** - * A mapping of component class models, keyed by name. - * @type {Object>} - */ -const componentModelMap = {}; - -/** - * A mapping of active component maps, keyed by the element components are assigned to. - * @type {WeakMap>} - */ -const elementComponentMap = new WeakMap(); - -/** - * Parse out the element references within the given element - * for the given component name. - * @param {String} name - * @param {Element} element - */ -function parseRefs(name, element) { - const refs = {}; - const manyRefs = {}; - - const prefix = `${name}@`; - const selector = `[refs*="${prefix}"]`; - const refElems = [...element.querySelectorAll(selector)]; - if (element.matches(selector)) { - refElems.push(element); - } - - for (const el of refElems) { - const refNames = el.getAttribute('refs') - .split(' ') - .filter(str => str.startsWith(prefix)) - .map(str => str.replace(prefix, '')) - .map(kebabToCamel); - for (const ref of refNames) { - refs[ref] = el; - if (typeof manyRefs[ref] === 'undefined') { - manyRefs[ref] = []; - } - manyRefs[ref].push(el); - } - } - return {refs, manyRefs}; -} - -/** - * Parse out the element component options. - * @param {String} componentName - * @param {Element} element - * @return {Object} - */ -function parseOpts(componentName, element) { - const opts = {}; - const prefix = `option:${componentName}:`; - for (const {name, value} of element.attributes) { - if (name.startsWith(prefix)) { - const optName = name.replace(prefix, ''); - opts[kebabToCamel(optName)] = value || ''; - } - } - return opts; -} - -/** - * Initialize a component instance on the given dom element. - * @param {String} name - * @param {Element} element - */ -function initComponent(name, element) { - /** @type {Function|undefined} * */ - const ComponentModel = componentModelMap[name]; - if (ComponentModel === undefined) return; - - // Create our component instance - /** @type {Component} * */ - let instance; - try { - instance = new ComponentModel(); - instance.$name = name; - instance.$el = element; - const allRefs = parseRefs(name, element); - instance.$refs = allRefs.refs; - instance.$manyRefs = allRefs.manyRefs; - instance.$opts = parseOpts(name, element); - instance.setup(); - } catch (e) { - console.error('Failed to create component', e, name, element); - } - - // Add to global listing - if (typeof components[name] === 'undefined') { - components[name] = []; - } - components[name].push(instance); - - // Add to element mapping - const elComponents = elementComponentMap.get(element) || {}; - elComponents[name] = instance; - elementComponentMap.set(element, elComponents); -} - -/** - * Initialize all components found within the given element. - * @param {Element|Document} parentElement - */ -export function init(parentElement = document) { - const componentElems = parentElement.querySelectorAll('[component],[components]'); - - for (const el of componentElems) { - const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean); - for (const name of componentNames) { - initComponent(name, el); - } - } -} - -/** - * Register the given component mapping into the component system. - * @param {Object>} mapping - */ -export function register(mapping) { - const keys = Object.keys(mapping); - for (const key of keys) { - componentModelMap[camelToKebab(key)] = mapping[key]; - } -} - -/** - * Get the first component of the given name. - * @param {String} name - * @returns {Component|null} - */ -export function first(name) { - return (components[name] || [null])[0]; -} - -/** - * Get all the components of the given name. - * @param {String} name - * @returns {Component[]} - */ -export function get(name) { - return components[name] || []; -} - -/** - * Get the first component, of the given name, that's assigned to the given element. - * @param {Element} element - * @param {String} name - * @returns {Component|null} - */ -export function firstOnElement(element, name) { - const elComponents = elementComponentMap.get(element) || {}; - return elComponents[name] || null; -} diff --git a/resources/js/services/components.ts b/resources/js/services/components.ts new file mode 100644 index 000000000..c19939e92 --- /dev/null +++ b/resources/js/services/components.ts @@ -0,0 +1,153 @@ +import {kebabToCamel, camelToKebab} from './text'; +import {Component} from "../components/component"; + +/** + * Parse out the element references within the given element + * for the given component name. + */ +function parseRefs(name: string, element: HTMLElement): + {refs: Record, manyRefs: Record} { + const refs: Record = {}; + const manyRefs: Record = {}; + + const prefix = `${name}@`; + const selector = `[refs*="${prefix}"]`; + const refElems = [...element.querySelectorAll(selector)]; + if (element.matches(selector)) { + refElems.push(element); + } + + for (const el of refElems as HTMLElement[]) { + const refNames = (el.getAttribute('refs') || '') + .split(' ') + .filter(str => str.startsWith(prefix)) + .map(str => str.replace(prefix, '')) + .map(kebabToCamel); + for (const ref of refNames) { + refs[ref] = el; + if (typeof manyRefs[ref] === 'undefined') { + manyRefs[ref] = []; + } + manyRefs[ref].push(el); + } + } + return {refs, manyRefs}; +} + +/** + * Parse out the element component options. + */ +function parseOpts(componentName: string, element: HTMLElement): Record { + const opts: Record = {}; + const prefix = `option:${componentName}:`; + for (const {name, value} of element.attributes) { + if (name.startsWith(prefix)) { + const optName = name.replace(prefix, ''); + opts[kebabToCamel(optName)] = value || ''; + } + } + return opts; +} + +export class ComponentStore { + /** + * A mapping of active components keyed by name, with values being arrays of component + * instances since there can be multiple components of the same type. + */ + protected components: Record = {}; + + /** + * A mapping of component class models, keyed by name. + */ + protected componentModelMap: Record = {}; + + /** + * A mapping of active component maps, keyed by the element components are assigned to. + */ + protected elementComponentMap: WeakMap> = new WeakMap(); + + /** + * Initialize a component instance on the given dom element. + */ + protected initComponent(name: string, element: HTMLElement): void { + const ComponentModel = this.componentModelMap[name]; + if (ComponentModel === undefined) return; + + // Create our component instance + let instance: Component|null = null; + try { + instance = new ComponentModel(); + instance.$name = name; + instance.$el = element; + const allRefs = parseRefs(name, element); + instance.$refs = allRefs.refs; + instance.$manyRefs = allRefs.manyRefs; + instance.$opts = parseOpts(name, element); + instance.setup(); + } catch (e) { + console.error('Failed to create component', e, name, element); + } + + if (!instance) { + return; + } + + // Add to global listing + if (typeof this.components[name] === 'undefined') { + this.components[name] = []; + } + this.components[name].push(instance); + + // Add to element mapping + const elComponents = this.elementComponentMap.get(element) || {}; + elComponents[name] = instance; + this.elementComponentMap.set(element, elComponents); + } + + /** + * Initialize all components found within the given element. + */ + public init(parentElement: Document|HTMLElement = document) { + const componentElems = parentElement.querySelectorAll('[component],[components]'); + + for (const el of componentElems) { + const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean); + for (const name of componentNames) { + this.initComponent(name, el as HTMLElement); + } + } + } + + /** + * Register the given component mapping into the component system. + * @param {Object>} mapping + */ + public register(mapping: Record) { + const keys = Object.keys(mapping); + for (const key of keys) { + this.componentModelMap[camelToKebab(key)] = mapping[key]; + } + } + + /** + * Get the first component of the given name. + */ + public first(name: string): Component|null { + return (this.components[name] || [null])[0]; + } + + /** + * Get all the components of the given name. + */ + public get(name: string): Component[] { + return this.components[name] || []; + } + + /** + * Get the first component, of the given name, that's assigned to the given element. + */ + public firstOnElement(element: HTMLElement, name: string): Component|null { + const elComponents = this.elementComponentMap.get(element) || {}; + return elComponents[name] || null; + } +} diff --git a/resources/js/services/text.js b/resources/js/services/text.ts similarity index 55% rename from resources/js/services/text.js rename to resources/js/services/text.ts index d5e6fa798..351e80167 100644 --- a/resources/js/services/text.js +++ b/resources/js/services/text.ts @@ -1,19 +1,15 @@ /** * Convert a kebab-case string to camelCase - * @param {String} kebab - * @returns {string} */ -export function kebabToCamel(kebab) { - const ucFirst = word => word.slice(0, 1).toUpperCase() + word.slice(1); +export function kebabToCamel(kebab: string): string { + const ucFirst = (word: string) => word.slice(0, 1).toUpperCase() + word.slice(1); const words = kebab.split('-'); return words[0] + words.slice(1).map(ucFirst).join(''); } /** * Convert a camelCase string to a kebab-case string. - * @param {String} camelStr - * @returns {String} */ -export function camelToKebab(camelStr) { +export function camelToKebab(camelStr: string): string { return camelStr.replace(/[A-Z]/g, (str, offset) => (offset > 0 ? '-' : '') + str.toLowerCase()); } diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts index f839501db..2478ba249 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -10,6 +10,7 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../helpers"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; +import {CodeEditor} from "../../components"; export type SerializedCodeBlockNode = Spread<{ language: string; @@ -170,7 +171,7 @@ export function $openCodeEditorForNode(editor: LexicalEditor, node: CodeBlockNod const language = node.getLanguage(); // @ts-ignore - const codeEditor = window.$components.first('code-editor'); + const codeEditor = window.$components.first('code-editor') as CodeEditor; // TODO - Handle direction codeEditor.open(code, language, 'ltr', (newCode: string, newLang: string) => { editor.update(() => { diff --git a/tsconfig.json b/tsconfig.json index 9913c1235..3ca03da30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "include": ["resources/js/**/*"], "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ @@ -26,7 +27,7 @@ /* Modules */ "module": "commonjs", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ + "rootDir": "./resources/js/", /* Specify the root folder within your source files. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ From 04c7e680fd8e86004ab27c517b3fa300c7091062 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 4 Jul 2024 13:09:53 +0100 Subject: [PATCH 040/107] Lexical: Linked up saving logic of editor via interface --- resources/js/components/markdown-editor.js | 4 +-- resources/js/components/page-editor.js | 8 +++-- .../js/components/wysiwyg-editor-tinymce.js | 4 +-- resources/js/components/wysiwyg-editor.js | 31 ++++++++++++++++--- resources/js/wysiwyg/index.ts | 20 ++++++++++-- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/resources/js/components/markdown-editor.js b/resources/js/components/markdown-editor.js index cd928de9f..ad5bcf090 100644 --- a/resources/js/components/markdown-editor.js +++ b/resources/js/components/markdown-editor.js @@ -133,9 +133,9 @@ export class MarkdownEditor extends Component { /** * Get the content of this editor. * Used by the parent page editor component. - * @return {{html: String, markdown: String}} + * @return {Promise<{html: String, markdown: String}>} */ - getContent() { + async getContent() { return this.editor.actions.getContent(); } diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 963c21008..ecfc3546f 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -118,7 +118,7 @@ export class PageEditor extends Component { async saveDraft() { const data = {name: this.titleElem.value.trim()}; - const editorContent = this.getEditorComponent().getContent(); + const editorContent = await this.getEditorComponent().getContent(); Object.assign(data, editorContent); let didSave = false; @@ -235,10 +235,12 @@ export class PageEditor extends Component { } /** - * @return MarkdownEditor|WysiwygEditor + * @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce} */ getEditorComponent() { - return window.$components.first('markdown-editor') || window.$components.first('wysiwyg-editor'); + return window.$components.first('markdown-editor') + || window.$components.first('wysiwyg-editor') + || window.$components.first('wysiwyg-editor-tinymce'); } } diff --git a/resources/js/components/wysiwyg-editor-tinymce.js b/resources/js/components/wysiwyg-editor-tinymce.js index 093442ea2..46ae6ecf4 100644 --- a/resources/js/components/wysiwyg-editor-tinymce.js +++ b/resources/js/components/wysiwyg-editor-tinymce.js @@ -37,9 +37,9 @@ export class WysiwygEditorTinymce extends Component { /** * Get the content of this editor. * Used by the parent page editor component. - * @return {{html: String}} + * @return {Promise<{html: String}>} */ - getContent() { + async getContent() { return { html: this.editor.getContent(), }; diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 2f0e660b1..deb371864 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -7,13 +7,35 @@ export class WysiwygEditor extends Component { this.editContainer = this.$refs.editContainer; this.input = this.$refs.input; + /** @var {SimpleWysiwygEditorInterface|null} */ + this.editor = null; + window.importVersioned('wysiwyg').then(wysiwyg => { const editorContent = this.input.value; - wysiwyg.createPageEditorInstance(this.editContainer, editorContent); + this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent); + }); + + let handlingFormSubmit = false; + this.input.form.addEventListener('submit', event => { + if (!this.editor) { + return; + } + + if (!handlingFormSubmit) { + event.preventDefault(); + handlingFormSubmit = true; + this.editor.getContentAsHtml().then(html => { + this.input.value = html; + this.input.form.submit(); + }); + } else { + handlingFormSubmit = false; + } }); } getDrawIoUrl() { + // TODO const drawioUrlElem = document.querySelector('[drawio-url]'); if (drawioUrlElem) { return drawioUrlElem.getAttribute('drawio-url'); @@ -24,12 +46,11 @@ export class WysiwygEditor extends Component { /** * Get the content of this editor. * Used by the parent page editor component. - * @return {{html: String}} + * @return {Promise<{html: String}>} */ - getContent() { - // TODO - Update + async getContent() { return { - html: this.editor.getContent(), + html: await this.editor.getContentAsHtml(), }; } diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index b0ff896c7..09b6e060b 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,14 +1,14 @@ -import {createEditor, CreateEditorArgs} from 'lexical'; +import {createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; import {getNodesForPageEditor} from './nodes'; import {buildEditorUI} from "./ui"; -import {setEditorContentFromHtml} from "./actions"; +import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {el} from "./helpers"; -export function createPageEditorInstance(container: HTMLElement, htmlContent: string) { +export function createPageEditorInstance(container: HTMLElement, htmlContent: string): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { namespace: 'BookStackPageEditor', nodes: getNodesForPageEditor(), @@ -57,4 +57,18 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st }); buildEditorUI(container, editArea, editor); + + return new SimpleWysiwygEditorInterface(editor); } + +export class SimpleWysiwygEditorInterface { + protected editor: LexicalEditor; + + constructor(editor: LexicalEditor) { + this.editor = editor; + } + + async getContentAsHtml(): Promise { + return await getEditorContentAsHtml(this.editor); + } +} \ No newline at end of file From 2c96af9aeafe2d4943a76cd69679ee7dcec737a3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 4 Jul 2024 16:16:16 +0100 Subject: [PATCH 041/107] Lexical: Worked on toolbar styling, got format submenu working --- .../ui/framework/blocks/dropdown-button.ts | 14 +-- .../ui/framework/blocks/format-menu.ts | 11 ++- .../ui/framework/blocks/overflow-container.ts | 7 +- .../wysiwyg/ui/framework/helpers/dropdowns.ts | 20 ++++- resources/js/wysiwyg/ui/toolbars.ts | 85 +++++++++++-------- resources/sass/_editor.scss | 48 ++++++++++- 6 files changed, 133 insertions(+), 52 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index 70e1a9ffc..a75cf64fe 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -7,10 +7,12 @@ export class EditorDropdownButton extends EditorContainerUiElement { protected button: EditorButton; protected childItems: EditorUiElement[]; protected open: boolean = false; + protected showOnHover: boolean = false; - constructor(button: EditorBasicButtonDefinition|EditorButton, children: EditorUiElement[]) { + constructor(button: EditorBasicButtonDefinition|EditorButton, showOnHover: boolean, children: EditorUiElement[]) { super(children); - this.childItems = children + this.childItems = children; + this.showOnHover = showOnHover; if (button instanceof EditorButton) { this.button = button; @@ -47,13 +49,15 @@ export class EditorDropdownButton extends EditorContainerUiElement { class: 'editor-dropdown-menu-container', }, [button, menu]); - handleDropdown(button, menu, () => { + handleDropdown({toggle : button, menu : menu, + showOnHover: this.showOnHover, + onOpen : () => { this.open = true; this.getContext().manager.triggerStateUpdateForElement(this.button); - }, () => { + }, onClose : () => { this.open = false; this.getContext().manager.triggerStateUpdateForElement(this.button); - }); + }}); return wrapper; } diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts index bcd61e45c..52a9c3809 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -20,7 +20,7 @@ export class EditorFormatMenu extends EditorContainerUiElement { class: 'editor-format-menu editor-dropdown-menu-container', }, [toggle, menu]); - handleDropdown(toggle, menu); + handleDropdown({toggle : toggle, menu : menu}); return wrapper; } @@ -33,6 +33,15 @@ export class EditorFormatMenu extends EditorContainerUiElement { this.updateToggleLabel(child.getLabel()); return; } + + if (child instanceof EditorContainerUiElement) { + for (const grandchild of child.getChildren()) { + if (grandchild instanceof EditorButton && grandchild.isActive()) { + this.updateToggleLabel(grandchild.getLabel()); + return; + } + } + } } this.updateToggleLabel(this.trans('Formats')); diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts index 2c188471e..83f394d9d 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -17,13 +17,14 @@ export class EditorOverflowContainer extends EditorContainerUiElement { this.overflowButton = new EditorDropdownButton({ label: 'More', icon: moreHorizontal, - }, []); + }, false, []); this.addChildren(this.overflowButton); } protected buildDOM(): HTMLElement { - const visibleChildren = this.content.slice(0, this.size); - const invisibleChildren = this.content.slice(this.size); + const slicePosition = this.content.length > this.size ? this.size - 1 : this.size; + const visibleChildren = this.content.slice(0, slicePosition); + const invisibleChildren = this.content.slice(slicePosition); const visibleElements = visibleChildren.map(child => child.getDOMElement()); if (invisibleChildren.length > 0) { diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index 35886d2f9..45c3f39d1 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -1,7 +1,16 @@ -export function handleDropdown(toggle: HTMLElement, menu: HTMLElement, onOpen: Function|undefined = undefined, onClose: Function|undefined = undefined) { +interface HandleDropdownParams { + toggle: HTMLElement; + menu: HTMLElement; + showOnHover?: boolean, + onOpen?: Function | undefined; + onClose?: Function | undefined; +} + +export function handleDropdown(options: HandleDropdownParams) { + const {menu, toggle, onClose, onOpen, showOnHover} = options; let clickListener: Function|null = null; const hide = () => { @@ -27,8 +36,13 @@ export function handleDropdown(toggle: HTMLElement, menu: HTMLElement, onOpen: F } }; - toggle.addEventListener('click', event => { + const toggleShowing = (event: MouseEvent) => { menu.hasAttribute('hidden') ? show() : hide(); - }); + }; + toggle.addEventListener('click', toggleShowing); + if (showOnHover) { + toggle.addEventListener('mouseenter', toggleShowing); + } + menu.addEventListener('mouseleave', hide); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 18b811380..df514e504 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -21,9 +21,12 @@ import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ + // History state - new EditorButton(undo), - new EditorButton(redo), + new EditorOverflowContainer(2, [ + new EditorButton(undo), + new EditorButton(redo), + ]), // Block formats new EditorFormatMenu([ @@ -33,37 +36,43 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new FormatPreviewButton(el('h5'), h5), new FormatPreviewButton(el('blockquote'), blockquote), new FormatPreviewButton(el('p'), paragraph), - new FormatPreviewButton(el('p', {class: 'callout info'}), infoCallout), - new FormatPreviewButton(el('p', {class: 'callout success'}), successCallout), - new FormatPreviewButton(el('p', {class: 'callout warning'}), warningCallout), - new FormatPreviewButton(el('p', {class: 'callout danger'}), dangerCallout), + new EditorDropdownButton({label: 'Callouts'}, true, [ + new FormatPreviewButton(el('p', {class: 'callout info'}), infoCallout), + new FormatPreviewButton(el('p', {class: 'callout success'}), successCallout), + new FormatPreviewButton(el('p', {class: 'callout warning'}), warningCallout), + new FormatPreviewButton(el('p', {class: 'callout danger'}), dangerCallout), + ]), ]), // Inline formats - new EditorButton(bold), - new EditorButton(italic), - new EditorButton(underline), - new EditorDropdownButton(new EditorColorButton(textColor, 'color'), [ - new EditorColorPicker('color'), + new EditorOverflowContainer(6, [ + new EditorButton(bold), + new EditorButton(italic), + new EditorButton(underline), + new EditorDropdownButton(new EditorColorButton(textColor, 'color'), false, [ + new EditorColorPicker('color'), + ]), + new EditorDropdownButton(new EditorColorButton(highlightColor, 'background-color'), false, [ + new EditorColorPicker('background-color'), + ]), + new EditorButton(strikethrough), + new EditorButton(superscript), + new EditorButton(subscript), + new EditorButton(code), + new EditorButton(clearFormating), ]), - new EditorDropdownButton(new EditorColorButton(highlightColor, 'background-color'), [ - new EditorColorPicker('background-color'), - ]), - new EditorButton(strikethrough), - new EditorButton(superscript), - new EditorButton(subscript), - new EditorButton(code), - new EditorButton(clearFormating), // Lists - new EditorButton(bulletList), - new EditorButton(numberList), - new EditorButton(taskList), + new EditorOverflowContainer(3, [ + new EditorButton(bulletList), + new EditorButton(numberList), + new EditorButton(taskList), + ]), // Insert types new EditorOverflowContainer(6, [ new EditorButton(link), - new EditorDropdownButton(table, [ + new EditorDropdownButton(table, false, [ new EditorTableCreator(), ]), new EditorButton(image), @@ -73,21 +82,23 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Meta elements - new EditorButton(source), - new EditorButton(fullscreen), + new EditorOverflowContainer(3, [ + new EditorButton(source), + new EditorButton(fullscreen), - // Test - new EditorButton({ - label: 'Test button', - action(context: EditorUiContext) { - context.editor.update(() => { - // Do stuff - }); - }, - isActive() { - return false; - } - }) + // Test + new EditorButton({ + label: 'Test button', + action(context: EditorUiContext) { + context.editor.update(() => { + // Do stuff + }); + }, + isActive() { + return false; + } + }) + ]), ]); } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 5305ada82..f5e166cc3 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -37,12 +37,13 @@ body.editor-is-fullscreen { // Buttons .editor-button { font-size: 12px; - padding: 4px 6px; + padding: 4px; color: #444; border-radius: 4px; display: flex; align-items: center; justify-content: center; + margin: 2px; } .editor-button:hover { background-color: #EEE; @@ -67,6 +68,7 @@ body.editor-is-fullscreen { height: 24px; color: inherit; fill: currentColor; + display: block; } // Containers @@ -79,22 +81,60 @@ body.editor-is-fullscreen { box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); z-index: 99; min-width: 120px; + display: flex; + flex-direction: row; } .editor-menu-list { display: flex; flex-direction: column; + align-items: stretch; } -.editor-menu-list > .editor-button { +.editor-menu-list .editor-button { border-bottom: 0; text-align: start; + display: block; + width: 100%; +} +.editor-menu-list > .editor-dropdown-menu-container .editor-dropdown-menu { + inset-inline-start: 100%; + top: 0; + flex-direction: column; } +.editor-format-menu-toggle { + width: 130px; + height: 32px; + overflow: hidden; + padding-inline: 12px; + justify-content: start; + background-image: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: 98% 50%; + background-size: 28px; +} .editor-format-menu .editor-dropdown-menu { - min-width: 320px; + min-width: 300px; + .editor-dropdown-menu { + min-width: 220px; + } +} +.editor-format-menu .editor-dropdown-menu .editor-dropdown-menu-container > .editor-button { + padding: 8px 10px; } .editor-overflow-container { display: flex; + border-inline: 1px solid #DDD; + padding-inline: 4px; + &:first-child { + border-inline-start: none; + } + &:last-child { + border-inline-end: none; + } + + .editor-overflow-container { + border-inline-start: none; + } } .editor-context-toolbar { @@ -104,6 +144,8 @@ body.editor-is-fullscreen { padding: .2rem; border-radius: 4px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: row; &:before { content: ''; z-index: -1; From 51d8044a547164080bb6ab31a60eb53348e595e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Jul 2024 20:49:47 +0100 Subject: [PATCH 042/107] Lexical: Added initial form/modal styles --- resources/icons/close.svg | 2 +- resources/js/wysiwyg/index.ts | 3 + resources/js/wysiwyg/ui/framework/modals.ts | 9 +- resources/js/wysiwyg/ui/toolbars.ts | 22 ++--- resources/sass/_editor.scss | 104 +++++++++++++++++++- 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/resources/icons/close.svg b/resources/icons/close.svg index c2ef46510..afd3f4671 100644 --- a/resources/icons/close.svg +++ b/resources/icons/close.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 09b6e060b..d1d96b172 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -49,6 +49,9 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st setEditorContentFromHtml(editor, htmlContent); const debugView = document.getElementById('lexical-debug'); + if (debugView) { + debugView.hidden = true; + } editor.registerUpdateListener(({editorState}) => { console.log('editorState', editorState.toJSON()); if (debugView) { diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index bfc5fc619..6b09accdc 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -1,7 +1,7 @@ import {EditorForm, EditorFormDefinition} from "./forms"; import {el} from "../../helpers"; import {EditorContainerUiElement} from "./core"; - +import closeIcon from "@icons/close.svg"; export interface EditorModalDefinition { title: string; @@ -37,7 +37,12 @@ export class EditorFormModal extends EditorContainerUiElement { } protected buildDOM(): HTMLElement { - const closeButton = el('button', {class: 'editor-modal-close', type: 'button', title: this.trans('Close')}, ['x']); + const closeButton = el('button', { + class: 'editor-modal-close', + type: 'button', + title: this.trans('Close'), + }); + closeButton.innerHTML = closeIcon; closeButton.addEventListener('click', this.hide.bind(this)); const modal = el('div', {class: 'editor-modal editor-form-modal'}, [ diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index df514e504..25a7e7815 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -87,17 +87,17 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(fullscreen), // Test - new EditorButton({ - label: 'Test button', - action(context: EditorUiContext) { - context.editor.update(() => { - // Do stuff - }); - }, - isActive() { - return false; - } - }) + // new EditorButton({ + // label: 'Test button', + // action(context: EditorUiContext) { + // context.editor.update(() => { + // // Do stuff + // }); + // }, + // isActive() { + // return false; + // } + // }) ]), ]); } diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index f5e166cc3..1f932e147 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -176,17 +176,38 @@ body.editor-is-fullscreen { } .editor-modal { background-color: #FFF; - border: 1px solid #DDD; - padding: 1rem; border-radius: 4px; + overflow: hidden; + box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); } .editor-modal-header { display: flex; justify-content: space-between; - margin-bottom: 1rem; + align-items: stretch; + background-color: var(--color-primary); + color: #FFF; } .editor-modal-title { - font-weight: 700; + padding: 8px $-m; +} +.editor-modal-close { + color: #FFF; + padding: 8px $-m; + align-items: center; + justify-content: center; + cursor: pointer; + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + svg { + width: 1rem; + height: 1rem; + fill: currentColor; + display: block; + } +} +.editor-modal-body { + padding: $-m; } // Specific UI elements @@ -293,6 +314,81 @@ body.editor-is-fullscreen { } } +// Editor form elements +.editor-form-field-wrapper { + margin-bottom: .5rem; +} +.editor-form-field-input { + display: block; + width: 100%; + min-width: 250px; + border: 1px solid #DDD; + padding: .5rem; + border-radius: 4px; + color: #444; +} +textarea.editor-form-field-input { + font-family: var(--font-code); + width: 350px; + height: 250px; + font-size: 12px; +} +.editor-form-field-label { + color: #444; + font-weight: 700; + font-size: 12px; +} +.editor-form-actions { + display: flex; + justify-content: end; + gap: $-s; + margin-top: $-m; +} +.editor-form-actions > button { + display: block; + font-size: 0.85rem; + line-height: 1.4em; + padding: $-xs*1.3 $-m; + font-weight: 400; + border-radius: 4px; + cursor: pointer; + box-shadow: none; + &:focus { + outline: 1px dotted currentColor; + outline-offset: -$-xs; + box-shadow: none; + filter: brightness(90%); + } +} +.editor-form-action-primary { + background-color: var(--color-primary); + color: #FFF; + border: 1px solid var(--color-primary); + &:hover { + @include lightDark(box-shadow, $bs-light, $bs-dark); + filter: brightness(110%); + } +} +.editor-form-action-secondary { + border: 1px solid; + @include lightDark(border-color, #CCC, #666); + @include lightDark(color, #666, #AAA); + &:hover, &:focus, &:active { + @include lightDark(color, #444, #BBB); + border: 1px solid #CCC; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); + background-color: #F2F2F2; + @include lightDark(background-color, #f8f8f8, #444); + filter: none; + } + &:active { + border-color: #BBB; + background-color: #DDD; + color: #666; + box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1); + } +} + // Editor theme styles .editor-theme-bold { font-weight: bold; From ea4c50c2c22be9a8920d5dfe7f1162c3454f2d53 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 16 Jul 2024 16:36:08 +0100 Subject: [PATCH 043/107] Lexical: Added code block selection & edit features Also added extra lifecycle handling for decorators to things can be properly cleaned up after node destruction. --- resources/js/wysiwyg/helpers.ts | 29 ++++++++++++++--- resources/js/wysiwyg/index.ts | 6 ++-- resources/js/wysiwyg/nodes/index.ts | 32 ++++++++++++++++++- .../js/wysiwyg/ui/decorators/code-block.ts | 18 ++++++++++- resources/js/wysiwyg/ui/decorators/image.ts | 6 ++-- .../wysiwyg/ui/defaults/button-definitions.ts | 8 ++++- .../js/wysiwyg/ui/framework/decorator.ts | 19 +++++++++++ resources/js/wysiwyg/ui/framework/manager.ts | 29 +++++++++++++++-- resources/js/wysiwyg/ui/index.ts | 15 +++++++-- resources/js/wysiwyg/ui/toolbars.ts | 10 ++++-- resources/sass/_editor.scss | 3 ++ 11 files changed, 156 insertions(+), 19 deletions(-) diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 64bcb6490..3708c2b25 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -1,14 +1,14 @@ import { + $createNodeSelection, $createParagraphNode, $getRoot, $getSelection, - $isTextNode, - BaseSelection, ElementNode, + $isTextNode, $setSelection, + BaseSelection, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; -import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; +import {getNodesForPageEditor, LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; -import {$createDetailsNode} from "./nodes/details"; export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { const el = document.createElement(tag); @@ -93,4 +93,25 @@ export function insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: bo } else { $getRoot().append(node); } +} + +export function selectSingleNode(node: LexicalNode) { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(node.getKey()); + $setSelection(nodeSelection); +} + +export function selectionContainsNode(selection: BaseSelection|null, node: LexicalNode): boolean { + if (!selection) { + return false; + } + + const key = node.getKey(); + for (const node of selection.getNodes()) { + if (node.getKey() === key) { + return true; + } + } + + return false; } \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index d1d96b172..8cbaccd79 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -2,11 +2,12 @@ import {createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; -import {getNodesForPageEditor} from './nodes'; +import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; import {buildEditorUI} from "./ui"; import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {el} from "./helpers"; +import {EditorUiContext} from "./ui/framework/core"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -59,7 +60,8 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st } }); - buildEditorUI(container, editArea, editor); + const context: EditorUiContext = buildEditorUI(container, editArea, editor); + registerCommonNodeMutationListeners(context); return new SimpleWysiwygEditorInterface(editor); } diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index e2c6902d3..a2c739576 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,6 +1,14 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; -import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; +import { + $getNodeByKey, + ElementNode, + KlassConstructor, + LexicalEditor, + LexicalNode, + LexicalNodeReplacement, NodeMutation, + ParagraphNode +} from "lexical"; import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; @@ -11,6 +19,8 @@ import {CustomTableNode} from "./custom-table"; import {HorizontalRuleNode} from "./horizontal-rule"; import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; +import {EditorUIManager} from "../ui/framework/manager"; +import {EditorUiContext} from "../ui/framework/core"; /** * Load the nodes for lexical. @@ -47,5 +57,25 @@ export function getNodesForPageEditor(): (KlassConstructor | ]; } +export function registerCommonNodeMutationListeners(context: EditorUiContext): void { + const decorated = [ImageNode, CodeBlockNode, DiagramNode]; + + const decorationDestroyListener = (mutations: Map): void => { + for (let [nodeKey, mutation] of mutations) { + if (mutation === "destroyed") { + const decorator = context.manager.getDecoratorByNodeKey(nodeKey); + if (decorator) { + decorator.destroy(context); + } + } + } + }; + + for (let decoratedNode of decorated) { + // Have to pass a unique function here since they are stored by lexical keyed on listener function. + context.editor.registerMutationListener(decoratedNode, (mutations) => decorationDestroyListener(mutations)); + } +} + export type LexicalNodeMatcher = (node: LexicalNode|null|undefined) => boolean; export type LexicalElementNodeCreator = () => ElementNode; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index 80dcef3bd..11cc02e8f 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,7 +1,9 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {ImageNode} from "../../nodes/image"; +import {selectionContainsNode, selectSingleNode} from "../../helpers"; +import {context} from "esbuild"; +import {BaseSelection} from "lexical"; export class CodeBlockDecorator extends EditorDecorator { @@ -32,12 +34,26 @@ export class CodeBlockDecorator extends EditorDecorator { const startTime = Date.now(); + element.addEventListener('click', event => { + context.editor.update(() => { + selectSingleNode(this.getNode()); + }) + }); + element.addEventListener('dblclick', event => { context.editor.getEditorState().read(() => { $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); }); }); + const selectionChange = (selection: BaseSelection|null): void => { + element.classList.toggle('selected', selectionContainsNode(selection, codeNode)); + }; + context.manager.onSelectionChange(selectionChange); + this.onDestroy(() => { + context.manager.offSelectionChange(selectionChange); + }); + // @ts-ignore const renderEditor = (Code) => { this.editor = Code.wysiwygView(element, document, this.latestCode, this.latestLanguage); diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index 1e8bfd165..1bc1ea543 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -1,5 +1,5 @@ import {EditorDecorator} from "../framework/decorator"; -import {el} from "../../helpers"; +import {el, selectSingleNode} from "../../helpers"; import {$createNodeSelection, $setSelection} from "lexical"; import {EditorUiContext} from "../framework/core"; import {ImageNode} from "../../nodes/image"; @@ -41,9 +41,7 @@ export class ImageDecorator extends EditorDecorator { tracker = this.setupTracker(decorateEl, context); context.editor.update(() => { - const nodeSelection = $createNodeSelection(); - nodeSelection.add(this.getNode().getKey()); - $setSelection(nodeSelection); + selectSingleNode(this.getNode()); }); }; diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 9f83fbea3..c6ea85b0d 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -53,6 +53,7 @@ import codeBlockIcon from "@icons/editor/code-block.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" import fullscreenIcon from "@icons/editor/fullscreen.svg" +import editIcon from "@icons/edit.svg" import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; @@ -344,7 +345,7 @@ export const codeBlock: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); - const codeBlock = getNodeFromSelection(selection, $isCodeBlockNode) as (CodeBlockNode|null); + const codeBlock = getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); if (codeBlock === null) { context.editor.update(() => { const codeBlock = $createCodeBlockNode(); @@ -363,6 +364,11 @@ export const codeBlock: EditorButtonDefinition = { } }; +export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, { + label: 'Edit code block', + icon: editIcon, +}); + export const details: EditorButtonDefinition = { label: 'Insert collapsible block', icon: detailsIcon, diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index a9917ab23..570b8222b 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -11,6 +11,8 @@ export abstract class EditorDecorator { protected node: LexicalNode | null = null; protected context: EditorUiContext; + private onDestroyCallbacks: (() => void)[] = []; + constructor(context: EditorUiContext) { this.context = context; } @@ -27,6 +29,13 @@ export abstract class EditorDecorator { this.node = node; } + /** + * Register a callback to be ran on destroy of this decorator's node. + */ + protected onDestroy(callback: () => void) { + this.onDestroyCallbacks.push(callback); + } + /** * Render the decorator. * Can run on both creation and update for a node decorator. @@ -35,4 +44,14 @@ export abstract class EditorDecorator { */ abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; + /** + * Destroy this decorator. Used for tear-down operations upon destruction + * of the underlying node this decorator is attached to. + */ + destroy(context: EditorUiContext): void { + for (const callback of this.onDestroyCallbacks) { + callback(); + } + } + } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 6477c4a1a..cfa94e8ae 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,11 +1,13 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; -import {$getSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; +import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; +export type SelectionChangeHandler = (selection: BaseSelection|null) => void; + export class EditorUIManager { protected modalDefinitionsByKey: Record = {}; @@ -15,6 +17,7 @@ export class EditorUIManager { protected toolbar: EditorContainerUiElement|null = null; protected contextToolbarDefinitionsByKey: Record = {}; protected activeContextToolbars: EditorContextToolbar[] = []; + protected selectionChangeHandlers: Set = new Set(); setContext(context: EditorUiContext) { this.context = context; @@ -72,6 +75,10 @@ export class EditorUIManager { return decorator; } + getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null { + return this.decoratorInstancesByNodeKey[nodeKey] || null; + } + setToolbar(toolbar: EditorContainerUiElement) { if (this.toolbar) { this.toolbar.getDOMElement().remove(); @@ -94,7 +101,7 @@ export class EditorUIManager { for (const toolbar of this.activeContextToolbars) { toolbar.updateState(update); } - // console.log('selection update', update.selection); + this.triggerSelectionChange(update.selection); } triggerStateRefresh(): void { @@ -104,6 +111,24 @@ export class EditorUIManager { }); } + protected triggerSelectionChange(selection: BaseSelection|null): void { + if (!selection) { + return; + } + + for (const handler of this.selectionChangeHandlers) { + handler(selection); + } + } + + onSelectionChange(handler: SelectionChangeHandler): void { + this.selectionChangeHandlers.add(handler); + } + + offSelectionChange(handler: SelectionChangeHandler): void { + this.selectionChangeHandlers.delete(handler); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (const toolbar of this.activeContextToolbars) { toolbar.empty(); diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 50307fa61..748370959 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,5 +1,10 @@ import {LexicalEditor} from "lexical"; -import {getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar} from "./toolbars"; +import { + getCodeToolbarContent, + getImageToolbarContent, + getLinkToolbarContent, + getMainEditorFullToolbar +} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {ImageDecorator} from "./decorators/image"; @@ -7,7 +12,7 @@ import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; -export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor) { +export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor): EditorUiContext { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, @@ -48,9 +53,15 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit selector: 'a', content: getLinkToolbarContent(), }); + manager.registerContextToolbar('code', { + selector: '.editor-code-block-wrap', + content: getCodeToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('code', CodeBlockDecorator); manager.registerDecoratorType('diagram', DiagramDecorator); + + return context; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 25a7e7815..d512b58e2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,7 +1,7 @@ import {EditorButton} from "./framework/buttons"; import { blockquote, bold, bulletList, clearFormating, code, codeBlock, - dangerCallout, details, fullscreen, + dangerCallout, details, editCodeBlock, fullscreen, h2, h3, h4, h5, highlightColor, horizontalRule, image, infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, @@ -9,7 +9,7 @@ import { undo, unlink, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core"; import {el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; @@ -111,4 +111,10 @@ export function getLinkToolbarContent(): EditorUiElement[] { new EditorButton(link), new EditorButton(unlink), ]; +} + +export function getCodeToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(editCodeBlock), + ]; } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 1f932e147..99045dd5a 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -312,6 +312,9 @@ body.editor-is-fullscreen { > * { pointer-events: none; } + &.selected .cm-editor { + border: 1px dashed var(--editor-color-primary); + } } // Editor form elements From b367490edc84b4aa5cd51e9481037faf0da4558b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Jul 2024 16:38:20 +0100 Subject: [PATCH 044/107] Lexical: Added list support, started todo --- resources/js/wysiwyg/helpers.ts | 38 +++++- resources/js/wysiwyg/todo.md | 27 ++++ .../wysiwyg/ui/defaults/button-definitions.ts | 126 ++++++++++++++---- resources/js/wysiwyg/ui/toolbars.ts | 11 ++ 4 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 resources/js/wysiwyg/todo.md diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 3708c2b25..600e71dd1 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -1,13 +1,13 @@ import { $createNodeSelection, $createParagraphNode, $getRoot, - $getSelection, + $getSelection, $isElementNode, $isTextNode, $setSelection, - BaseSelection, + BaseSelection, ElementFormatType, ElementNode, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; -import {getNodesForPageEditor, LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; -import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; +import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { @@ -114,4 +114,34 @@ export function selectionContainsNode(selection: BaseSelection|null, node: Lexic } return false; +} + +export function selectionContainsElementFormat(selection: BaseSelection|null, format: ElementFormatType): boolean { + const nodes = getBlockElementNodesInSelection(selection); + for (const node of nodes) { + if (node.getFormatType() === format) { + return true; + } + } + + return false; +} + +export function getBlockElementNodesInSelection(selection: BaseSelection|null): ElementNode[] { + if (!selection) { + return []; + } + + const blockNodes: Map = new Map(); + for (const node of selection.getNodes()) { + const blockElement = $findMatchingParent(node, (node) => { + return $isElementNode(node) && !node.isInline(); + }) as ElementNode|null; + + if (blockElement) { + blockNodes.set(blockElement.getKey(), blockElement); + } + } + + return Array.from(blockNodes.values()); } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md new file mode 100644 index 000000000..67b5fb780 --- /dev/null +++ b/resources/js/wysiwyg/todo.md @@ -0,0 +1,27 @@ +# Lexical based editor todo + +## Main Todo + +- Alignments: Use existing classes for blocks +- Alignments: Handle inline block content (image, video) +- Add Type: Video/media/embed +- Add Type: Drawings +- Handle toolbars on scroll +- Table features +- Image paste upload +- Keyboard shortcuts support +- Global/shared editor events support +- Draft/change management (connect with page editor component) +- Add ID support to all block types +- Template drag & drop / insert +- Video attachment drop / insert +- Task list render/import from existing format +- Link popup menu for cross-content reference +- Link heading-based ID reference menu +- Image gallery integration for insert +- Image gallery integration for form + +## Bugs + +- Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. +- Table resize bars often floating around in wrong place, and shows on hover or interrupts mouse actions. \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index c6ea85b0d..d1d22dae1 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -1,15 +1,28 @@ import {EditorBasicButtonDefinition, EditorButton, EditorButtonDefinition} from "../framework/buttons"; import { $createNodeSelection, - $createParagraphNode, $createTextNode, $getRoot, $getSelection, - $isParagraphNode, $isTextNode, $setSelection, - BaseSelection, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, ElementNode, FORMAT_TEXT_COMMAND, + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isParagraphNode, + $isTextNode, + $setSelection, + BaseSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + ElementFormatType, + ElementNode, + FORMAT_TEXT_COMMAND, LexicalNode, - REDO_COMMAND, TextFormatType, + REDO_COMMAND, + TextFormatType, UNDO_COMMAND } from "lexical"; import { - getNodeFromSelection, insertNewBlockNodeAtSelection, + getBlockElementNodesInSelection, + getNodeFromSelection, insertNewBlockNodeAtSelection, selectionContainsElementFormat, selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType @@ -29,31 +42,35 @@ import {$isImageNode, ImageNode} from "../../nodes/image"; import {$createDetailsNode, $isDetailsNode} from "../../nodes/details"; import {getEditorContentAsHtml} from "../../actions"; import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; -import undoIcon from "@icons/editor/undo.svg" -import redoIcon from "@icons/editor/redo.svg" -import boldIcon from "@icons/editor/bold.svg" -import italicIcon from "@icons/editor/italic.svg" -import underlinedIcon from "@icons/editor/underlined.svg" +import undoIcon from "@icons/editor/undo.svg"; +import redoIcon from "@icons/editor/redo.svg"; +import boldIcon from "@icons/editor/bold.svg"; +import italicIcon from "@icons/editor/italic.svg"; +import underlinedIcon from "@icons/editor/underlined.svg"; import textColorIcon from "@icons/editor/text-color.svg"; import highlightIcon from "@icons/editor/highlighter.svg"; -import strikethroughIcon from "@icons/editor/strikethrough.svg" -import superscriptIcon from "@icons/editor/superscript.svg" -import subscriptIcon from "@icons/editor/subscript.svg" -import codeIcon from "@icons/editor/code.svg" -import formatClearIcon from "@icons/editor/format-clear.svg" -import listBulletIcon from "@icons/editor/list-bullet.svg" -import listNumberedIcon from "@icons/editor/list-numbered.svg" -import listCheckIcon from "@icons/editor/list-check.svg" -import linkIcon from "@icons/editor/link.svg" -import unlinkIcon from "@icons/editor/unlink.svg" -import tableIcon from "@icons/editor/table.svg" -import imageIcon from "@icons/editor/image.svg" -import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg" -import codeBlockIcon from "@icons/editor/code-block.svg" -import detailsIcon from "@icons/editor/details.svg" -import sourceIcon from "@icons/editor/source-view.svg" -import fullscreenIcon from "@icons/editor/fullscreen.svg" -import editIcon from "@icons/edit.svg" +import strikethroughIcon from "@icons/editor/strikethrough.svg"; +import superscriptIcon from "@icons/editor/superscript.svg"; +import subscriptIcon from "@icons/editor/subscript.svg"; +import codeIcon from "@icons/editor/code.svg"; +import formatClearIcon from "@icons/editor/format-clear.svg"; +import alignLeftIcon from "@icons/editor/align-left.svg"; +import alignCenterIcon from "@icons/editor/align-center.svg"; +import alignRightIcon from "@icons/editor/align-right.svg"; +import alignJustifyIcon from "@icons/editor/align-justify.svg"; +import listBulletIcon from "@icons/editor/list-bullet.svg"; +import listNumberedIcon from "@icons/editor/list-numbered.svg"; +import listCheckIcon from "@icons/editor/list-check.svg"; +import linkIcon from "@icons/editor/link.svg"; +import unlinkIcon from "@icons/editor/unlink.svg"; +import tableIcon from "@icons/editor/table.svg"; +import imageIcon from "@icons/editor/image.svg"; +import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; +import codeBlockIcon from "@icons/editor/code-block.svg"; +import detailsIcon from "@icons/editor/details.svg"; +import sourceIcon from "@icons/editor/source-view.svg"; +import fullscreenIcon from "@icons/editor/fullscreen.svg"; +import editIcon from "@icons/edit.svg"; import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; @@ -203,6 +220,59 @@ export const clearFormating: EditorButtonDefinition = { } }; +function setAlignmentForSection(alignment: ElementFormatType): void { + const selection = $getSelection(); + const elements = getBlockElementNodesInSelection(selection); + for (const node of elements) { + node.setFormat(alignment); + } +} + +export const alignLeft: EditorButtonDefinition = { + label: 'Align left', + icon: alignLeftIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('left')); + }, + isActive(selection: BaseSelection|null) { + return selectionContainsElementFormat(selection, 'left'); + } +}; + +export const alignCenter: EditorButtonDefinition = { + label: 'Align center', + icon: alignCenterIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('center')); + }, + isActive(selection: BaseSelection|null) { + return selectionContainsElementFormat(selection, 'center'); + } +}; + +export const alignRight: EditorButtonDefinition = { + label: 'Align right', + icon: alignRightIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('right')); + }, + isActive(selection: BaseSelection|null) { + return selectionContainsElementFormat(selection, 'right'); + } +}; + +export const alignJustify: EditorButtonDefinition = { + label: 'Align justify', + icon: alignJustifyIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('justify')); + }, + isActive(selection: BaseSelection|null) { + return selectionContainsElementFormat(selection, 'justify'); + } +}; + + function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { return { label, diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index d512b58e2..9145b8761 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,5 +1,8 @@ import {EditorButton} from "./framework/buttons"; import { + alignCenter, alignJustify, + alignLeft, + alignRight, blockquote, bold, bulletList, clearFormating, code, codeBlock, dangerCallout, details, editCodeBlock, fullscreen, h2, h3, h4, h5, highlightColor, horizontalRule, image, @@ -62,6 +65,14 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(clearFormating), ]), + // Alignment + new EditorOverflowContainer(4, [ + new EditorButton(alignLeft), + new EditorButton(alignCenter), + new EditorButton(alignRight), + new EditorButton(alignJustify), + ]), + // Lists new EditorOverflowContainer(3, [ new EditorButton(bulletList), From 5002a89754bc0161cf33ea51f5a80d8c6f89eb94 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Jul 2024 16:45:57 +0100 Subject: [PATCH 045/107] Lexical: Standardised helper function format --- resources/js/wysiwyg/helpers.ts | 40 +++++----- .../js/wysiwyg/ui/decorators/code-block.ts | 6 +- resources/js/wysiwyg/ui/decorators/image.ts | 4 +- .../wysiwyg/ui/defaults/button-definitions.ts | 80 ++++++++++--------- .../ui/framework/blocks/table-creator.ts | 4 +- 5 files changed, 69 insertions(+), 65 deletions(-) diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 600e71dd1..a7c3e4453 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -4,7 +4,7 @@ import { $getSelection, $isElementNode, $isTextNode, $setSelection, BaseSelection, ElementFormatType, ElementNode, - LexicalEditor, LexicalNode, TextFormatType + LexicalNode, TextFormatType } from "lexical"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; @@ -30,11 +30,11 @@ export function el(tag: string, attrs: Record = {}, childre return el; } -export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { - return getNodeFromSelection(selection, matcher) !== null; +export function $selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { + return $getNodeFromSelection(selection, matcher) !== null; } -export function getNodeFromSelection(selection: BaseSelection|null, matcher: LexicalNodeMatcher): LexicalNode|null { +export function $getNodeFromSelection(selection: BaseSelection|null, matcher: LexicalNodeMatcher): LexicalNode|null { if (!selection) { return null; } @@ -54,7 +54,7 @@ export function getNodeFromSelection(selection: BaseSelection|null, matcher: Lex return null; } -export function selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean { +export function $selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean { if (!selection) { return false; } @@ -68,19 +68,17 @@ export function selectionContainsTextFormat(selection: BaseSelection|null, forma return false; } -export function toggleSelectionBlockNodeType(editor: LexicalEditor, matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { - editor.update(() => { - const selection = $getSelection(); - const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; - if (selection && matcher(blockElement)) { - $setBlocksType(selection, $createParagraphNode); - } else { - $setBlocksType(selection, creator); - } - }); +export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { + const selection = $getSelection(); + const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + if (selection && matcher(blockElement)) { + $setBlocksType(selection, $createParagraphNode); + } else { + $setBlocksType(selection, creator); + } } -export function insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: boolean = true) { +export function $insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: boolean = true) { const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; @@ -95,13 +93,13 @@ export function insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: bo } } -export function selectSingleNode(node: LexicalNode) { +export function $selectSingleNode(node: LexicalNode) { const nodeSelection = $createNodeSelection(); nodeSelection.add(node.getKey()); $setSelection(nodeSelection); } -export function selectionContainsNode(selection: BaseSelection|null, node: LexicalNode): boolean { +export function $selectionContainsNode(selection: BaseSelection|null, node: LexicalNode): boolean { if (!selection) { return false; } @@ -116,8 +114,8 @@ export function selectionContainsNode(selection: BaseSelection|null, node: Lexic return false; } -export function selectionContainsElementFormat(selection: BaseSelection|null, format: ElementFormatType): boolean { - const nodes = getBlockElementNodesInSelection(selection); +export function $selectionContainsElementFormat(selection: BaseSelection|null, format: ElementFormatType): boolean { + const nodes = $getBlockElementNodesInSelection(selection); for (const node of nodes) { if (node.getFormatType() === format) { return true; @@ -127,7 +125,7 @@ export function selectionContainsElementFormat(selection: BaseSelection|null, fo return false; } -export function getBlockElementNodesInSelection(selection: BaseSelection|null): ElementNode[] { +export function $getBlockElementNodesInSelection(selection: BaseSelection|null): ElementNode[] { if (!selection) { return []; } diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index 11cc02e8f..cfb2c6aef 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,7 +1,7 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {selectionContainsNode, selectSingleNode} from "../../helpers"; +import {$selectionContainsNode, $selectSingleNode} from "../../helpers"; import {context} from "esbuild"; import {BaseSelection} from "lexical"; @@ -36,7 +36,7 @@ export class CodeBlockDecorator extends EditorDecorator { element.addEventListener('click', event => { context.editor.update(() => { - selectSingleNode(this.getNode()); + $selectSingleNode(this.getNode()); }) }); @@ -47,7 +47,7 @@ export class CodeBlockDecorator extends EditorDecorator { }); const selectionChange = (selection: BaseSelection|null): void => { - element.classList.toggle('selected', selectionContainsNode(selection, codeNode)); + element.classList.toggle('selected', $selectionContainsNode(selection, codeNode)); }; context.manager.onSelectionChange(selectionChange); this.onDestroy(() => { diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index 1bc1ea543..2046260a0 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -1,5 +1,5 @@ import {EditorDecorator} from "../framework/decorator"; -import {el, selectSingleNode} from "../../helpers"; +import {el, $selectSingleNode} from "../../helpers"; import {$createNodeSelection, $setSelection} from "lexical"; import {EditorUiContext} from "../framework/core"; import {ImageNode} from "../../nodes/image"; @@ -41,7 +41,7 @@ export class ImageDecorator extends EditorDecorator { tracker = this.setupTracker(decorateEl, context); context.editor.update(() => { - selectSingleNode(this.getNode()); + $selectSingleNode(this.getNode()); }); }; diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index d1d22dae1..bf725f8c8 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -21,11 +21,11 @@ import { UNDO_COMMAND } from "lexical"; import { - getBlockElementNodesInSelection, - getNodeFromSelection, insertNewBlockNodeAtSelection, selectionContainsElementFormat, - selectionContainsNodeType, - selectionContainsTextFormat, - toggleSelectionBlockNodeType + $getBlockElementNodesInSelection, + $getNodeFromSelection, $insertNewBlockNodeAtSelection, $selectionContainsElementFormat, + $selectionContainsNodeType, + $selectionContainsTextFormat, + $toggleSelectionBlockNodeType } from "../../helpers"; import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout"; import { @@ -116,14 +116,15 @@ function buildCalloutButton(category: CalloutCategory, name: string): EditorButt return { label: `${name} Callout`, action(context: EditorUiContext) { - toggleSelectionBlockNodeType( - context.editor, - (node) => $isCalloutNodeOfCategory(node, category), - () => $createCalloutNode(category), - ) + context.editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => $isCalloutNodeOfCategory(node, category), + () => $createCalloutNode(category), + ) + }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category)); + return $selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category)); } }; } @@ -141,14 +142,15 @@ function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefin return { label: name, action(context: EditorUiContext) { - toggleSelectionBlockNodeType( - context.editor, + context.editor.update(() => { + $toggleSelectionBlockNodeType( (node) => isHeaderNodeOfTag(node, tag), () => $createHeadingNode(tag), - ) + ) + }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); + return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); } }; } @@ -161,20 +163,24 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header') export const blockquote: EditorButtonDefinition = { label: 'Blockquote', action(context: EditorUiContext) { - toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode); + context.editor.update(() => { + $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); + }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isQuoteNode); + return $selectionContainsNodeType(selection, $isQuoteNode); } }; export const paragraph: EditorButtonDefinition = { label: 'Paragraph', action(context: EditorUiContext) { - toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode); + context.editor.update(() => { + $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); + }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isParagraphNode); + return $selectionContainsNodeType(selection, $isParagraphNode); } } @@ -186,7 +192,7 @@ function buildFormatButton(label: string, format: TextFormatType, icon: string): context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsTextFormat(selection, format); + return $selectionContainsTextFormat(selection, format); } }; } @@ -222,7 +228,7 @@ export const clearFormating: EditorButtonDefinition = { function setAlignmentForSection(alignment: ElementFormatType): void { const selection = $getSelection(); - const elements = getBlockElementNodesInSelection(selection); + const elements = $getBlockElementNodesInSelection(selection); for (const node of elements) { node.setFormat(alignment); } @@ -235,7 +241,7 @@ export const alignLeft: EditorButtonDefinition = { context.editor.update(() => setAlignmentForSection('left')); }, isActive(selection: BaseSelection|null) { - return selectionContainsElementFormat(selection, 'left'); + return $selectionContainsElementFormat(selection, 'left'); } }; @@ -246,7 +252,7 @@ export const alignCenter: EditorButtonDefinition = { context.editor.update(() => setAlignmentForSection('center')); }, isActive(selection: BaseSelection|null) { - return selectionContainsElementFormat(selection, 'center'); + return $selectionContainsElementFormat(selection, 'center'); } }; @@ -257,7 +263,7 @@ export const alignRight: EditorButtonDefinition = { context.editor.update(() => setAlignmentForSection('right')); }, isActive(selection: BaseSelection|null) { - return selectionContainsElementFormat(selection, 'right'); + return $selectionContainsElementFormat(selection, 'right'); } }; @@ -268,7 +274,7 @@ export const alignJustify: EditorButtonDefinition = { context.editor.update(() => setAlignmentForSection('justify')); }, isActive(selection: BaseSelection|null) { - return selectionContainsElementFormat(selection, 'justify'); + return $selectionContainsElementFormat(selection, 'justify'); } }; @@ -288,7 +294,7 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { return $isListNode(node) && (node as ListNode).getListType() === type; }); } @@ -307,7 +313,7 @@ export const link: EditorButtonDefinition = { const linkModal = context.manager.createModal('link'); context.editor.getEditorState().read(() => { const selection = $getSelection(); - const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; let formDefaults = {}; if (selectedLink) { @@ -329,7 +335,7 @@ export const link: EditorButtonDefinition = { }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isLinkNode); + return $selectionContainsNodeType(selection, $isLinkNode); } }; @@ -339,7 +345,7 @@ export const unlink: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.update(() => { const selection = context.lastSelection; - const selectedLink = getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; const selectionPoints = selection?.getStartEndPoints(); if (selectedLink) { @@ -369,7 +375,7 @@ export const image: EditorButtonDefinition = { action(context: EditorUiContext) { const imageModal = context.manager.createModal('image'); const selection = context.lastSelection; - const selectedImage = getNodeFromSelection(selection, $isImageNode) as ImageNode|null; + const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode|null; context.editor.getEditorState().read(() => { let formDefaults = {}; @@ -392,7 +398,7 @@ export const image: EditorButtonDefinition = { }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isImageNode); + return $selectionContainsNodeType(selection, $isImageNode); } }; @@ -401,11 +407,11 @@ export const horizontalRule: EditorButtonDefinition = { icon: horizontalRuleIcon, action(context: EditorUiContext) { context.editor.update(() => { - insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); + $insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isHorizontalRuleNode); + return $selectionContainsNodeType(selection, $isHorizontalRuleNode); } }; @@ -415,12 +421,12 @@ export const codeBlock: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); - const codeBlock = getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); + const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); if (codeBlock === null) { context.editor.update(() => { const codeBlock = $createCodeBlockNode(); codeBlock.setCode(selection?.getTextContent() || ''); - insertNewBlockNodeAtSelection(codeBlock, true); + $insertNewBlockNodeAtSelection(codeBlock, true); $openCodeEditorForNode(context.editor, codeBlock); codeBlock.selectStart(); }); @@ -430,7 +436,7 @@ export const codeBlock: EditorButtonDefinition = { }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isCodeBlockNode); + return $selectionContainsNodeType(selection, $isCodeBlockNode); } }; @@ -463,7 +469,7 @@ export const details: EditorButtonDefinition = { }); }, isActive(selection: BaseSelection|null): boolean { - return selectionContainsNodeType(selection, $isDetailsNode); + return $selectionContainsNodeType(selection, $isDetailsNode); } } diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts index 8c28953d5..1981fcb86 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -1,4 +1,4 @@ -import {el, insertNewBlockNodeAtSelection} from "../../../helpers"; +import {el, $insertNewBlockNodeAtSelection} from "../../../helpers"; import {EditorUiElement} from "../core"; import {$createTableNodeWithDimensions} from "@lexical/table"; import {CustomTableNode} from "../../../nodes/custom-table"; @@ -75,7 +75,7 @@ export class EditorTableCreator extends EditorUiElement { this.getContext().editor.update(() => { const table = $createTableNodeWithDimensions(rows, columns, false) as CustomTableNode; - insertNewBlockNodeAtSelection(table); + $insertNewBlockNodeAtSelection(table); }); } } \ No newline at end of file From 634b0aaa07097f4a413a85e7c172176dda8e42e1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 18 Jul 2024 11:19:11 +0100 Subject: [PATCH 046/107] Lexical: Started converting drawio to TS Converted events service to TS as part of this. --- resources/js/app.js | 4 +- resources/js/global.d.ts | 2 + .../js/services/{drawio.js => drawio.ts} | 60 ++++++++------ resources/js/services/events.js | 81 ------------------- resources/js/services/events.ts | 71 ++++++++++++++++ resources/js/wysiwyg/todo.md | 8 +- .../js/wysiwyg/ui/decorators/code-block.ts | 1 - resources/js/wysiwyg/ui/decorators/diagram.ts | 25 +++++- tsconfig.json | 2 +- 9 files changed, 142 insertions(+), 112 deletions(-) rename resources/js/services/{drawio.js => drawio.ts} (69%) delete mode 100644 resources/js/services/events.js create mode 100644 resources/js/services/events.ts diff --git a/resources/js/app.js b/resources/js/app.js index 123d6c8f5..812a451f2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,4 @@ -import * as events from './services/events'; +import {EventManager} from './services/events.ts'; import * as httpInstance from './services/http'; import Translations from './services/translations'; import * as componentMap from './components'; @@ -21,7 +21,7 @@ window.importVersioned = function importVersioned(moduleName) { // Set events and http services on window window.$http = httpInstance; -window.$events = events; +window.$events = new EventManager(); // Translation setup // Creates a global function with name 'trans' to be used in the same way as the Laravel translation system diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index da19545d1..a9b9275e9 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -1,7 +1,9 @@ import {ComponentStore} from "./services/components"; +import {EventManager} from "./services/events"; declare global { interface Window { $components: ComponentStore, + $events: EventManager, } } \ No newline at end of file diff --git a/resources/js/services/drawio.js b/resources/js/services/drawio.ts similarity index 69% rename from resources/js/services/drawio.js rename to resources/js/services/drawio.ts index 46e10327a..75b161f75 100644 --- a/resources/js/services/drawio.js +++ b/resources/js/services/drawio.ts @@ -1,17 +1,31 @@ // Docs: https://www.diagrams.net/doc/faq/embed-mode import * as store from './store'; +import {ConfirmDialog} from "../components"; -let iFrame = null; -let lastApprovedOrigin; -let onInit; -let onSave; +type DrawioExportEventResponse = { + action: 'export', + format: string, + message: string, + data: string, + xml: string, +}; + +type DrawioSaveEventResponse = { + action: 'save', + xml: string, +}; + +let iFrame: HTMLIFrameElement|null = null; +let lastApprovedOrigin: string; +let onInit: () => Promise; +let onSave: (data: string) => Promise; const saveBackupKey = 'last-drawing-save'; -function drawPostMessage(data) { - iFrame.contentWindow.postMessage(JSON.stringify(data), lastApprovedOrigin); +function drawPostMessage(data: Record): void { + iFrame?.contentWindow?.postMessage(JSON.stringify(data), lastApprovedOrigin); } -function drawEventExport(message) { +function drawEventExport(message: DrawioExportEventResponse) { store.set(saveBackupKey, message.data); if (onSave) { onSave(message.data).then(() => { @@ -20,7 +34,7 @@ function drawEventExport(message) { } } -function drawEventSave(message) { +function drawEventSave(message: DrawioSaveEventResponse) { drawPostMessage({ action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing', }); @@ -35,8 +49,10 @@ function drawEventInit() { function drawEventConfigure() { const config = {}; - window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config}); - drawPostMessage({action: 'configure', config}); + if (iFrame) { + window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config}); + drawPostMessage({action: 'configure', config}); + } } function drawEventClose() { @@ -47,9 +63,8 @@ function drawEventClose() { /** * Receive and handle a message event from the draw.io window. - * @param {MessageEvent} event */ -function drawReceive(event) { +function drawReceive(event: MessageEvent) { if (!event.data || event.data.length < 1) return; if (event.origin !== lastApprovedOrigin) return; @@ -59,9 +74,9 @@ function drawReceive(event) { } else if (message.event === 'exit') { drawEventClose(); } else if (message.event === 'save') { - drawEventSave(message); + drawEventSave(message as DrawioSaveEventResponse); } else if (message.event === 'export') { - drawEventExport(message); + drawEventExport(message as DrawioExportEventResponse); } else if (message.event === 'configure') { drawEventConfigure(); } @@ -79,9 +94,8 @@ async function attemptRestoreIfExists() { console.error('Missing expected unsaved-drawing dialog'); } - if (backupVal) { - /** @var {ConfirmDialog} */ - const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog'); + if (backupVal && dialogEl) { + const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog') as ConfirmDialog; const restore = await dialog.show(); if (restore) { onInit = async () => backupVal; @@ -94,11 +108,9 @@ async function attemptRestoreIfExists() { * onSaveCallback must return a promise that resolves on successful save and errors on failure. * onInitCallback must return a promise with the xml to load for the editor. * Will attempt to provide an option to restore unsaved changes if found to exist. - * @param {String} drawioUrl - * @param {Function>} onInitCallback - * @param {Function} onSaveCallback - Is called with the drawing data on save. + * onSaveCallback Is called with the drawing data on save. */ -export async function show(drawioUrl, onInitCallback, onSaveCallback) { +export async function show(drawioUrl: string, onInitCallback: () => Promise, onSaveCallback: (data: string) => Promise): Promise { onInit = onInitCallback; onSave = onSaveCallback; @@ -114,7 +126,7 @@ export async function show(drawioUrl, onInitCallback, onSaveCallback) { lastApprovedOrigin = (new URL(drawioUrl)).origin; } -export async function upload(imageData, pageUploadedToId) { +export async function upload(imageData: string, pageUploadedToId: string): Promise<{}|string> { const data = { image: imageData, uploaded_to: pageUploadedToId, @@ -129,10 +141,8 @@ export function close() { /** * Load an existing image, by fetching it as Base64 from the system. - * @param drawingId - * @returns {Promise} */ -export async function load(drawingId) { +export async function load(drawingId: string): Promise { try { const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`)); return `data:image/png;base64,${resp.data.content}`; diff --git a/resources/js/services/events.js b/resources/js/services/events.js deleted file mode 100644 index 761305793..000000000 --- a/resources/js/services/events.js +++ /dev/null @@ -1,81 +0,0 @@ -const listeners = {}; -const stack = []; - -/** - * Emit a custom event for any handlers to pick-up. - * @param {String} eventName - * @param {*} eventData - */ -export function emit(eventName, eventData) { - stack.push({name: eventName, data: eventData}); - - const listenersToRun = listeners[eventName] || []; - for (const listener of listenersToRun) { - listener(eventData); - } -} - -/** - * Listen to a custom event and run the given callback when that event occurs. - * @param {String} eventName - * @param {Function} callback - * @returns {Events} - */ -export function listen(eventName, callback) { - if (typeof listeners[eventName] === 'undefined') listeners[eventName] = []; - listeners[eventName].push(callback); -} - -/** - * Emit an event for public use. - * Sends the event via the native DOM event handling system. - * @param {Element} targetElement - * @param {String} eventName - * @param {Object} eventData - */ -export function emitPublic(targetElement, eventName, eventData) { - const event = new CustomEvent(eventName, { - detail: eventData, - bubbles: true, - }); - targetElement.dispatchEvent(event); -} - -/** - * Emit a success event with the provided message. - * @param {String} message - */ -export function success(message) { - emit('success', message); -} - -/** - * Emit an error event with the provided message. - * @param {String} message - */ -export function error(message) { - emit('error', message); -} - -/** - * Notify of standard server-provided validation errors. - * @param {Object} responseErr - */ -export function showValidationErrors(responseErr) { - if (!responseErr.status) return; - if (responseErr.status === 422 && responseErr.data) { - const message = Object.values(responseErr.data).flat().join('\n'); - error(message); - } -} - -/** - * Notify standard server-provided error messages. - * @param {Object} responseErr - */ -export function showResponseError(responseErr) { - if (!responseErr.status) return; - if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) { - error(responseErr.data.message); - } -} diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts new file mode 100644 index 000000000..c251ee21b --- /dev/null +++ b/resources/js/services/events.ts @@ -0,0 +1,71 @@ +export class EventManager { + protected listeners: Record void)[]> = {}; + protected stack: {name: string, data: {}}[] = []; + + /** + * Emit a custom event for any handlers to pick-up. + */ + emit(eventName: string, eventData: {}): void { + this.stack.push({name: eventName, data: eventData}); + + const listenersToRun = this.listeners[eventName] || []; + for (const listener of listenersToRun) { + listener(eventData); + } + } + + /** + * Listen to a custom event and run the given callback when that event occurs. + */ + listen(eventName: string, callback: (data: {}) => void): void { + if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = []; + this.listeners[eventName].push(callback); + } + + /** + * Emit an event for public use. + * Sends the event via the native DOM event handling system. + */ + emitPublic(targetElement: Element, eventName: string, eventData: {}): void { + const event = new CustomEvent(eventName, { + detail: eventData, + bubbles: true, + }); + targetElement.dispatchEvent(event); + } + + /** + * Emit a success event with the provided message. + */ + success(message: string): void { + this.emit('success', message); + } + + /** + * Emit an error event with the provided message. + */ + error(message: string): void { + this.emit('error', message); + } + + /** + * Notify of standard server-provided validation errors. + */ + showValidationErrors(responseErr: {status?: number, data?: object}): void { + if (!responseErr.status) return; + if (responseErr.status === 422 && responseErr.data) { + const message = Object.values(responseErr.data).flat().join('\n'); + this.error(message); + } + } + + /** + * Notify standard server-provided error messages. + */ + showResponseError(responseErr: {status?: number, data?: {message?: string}}): void { + if (!responseErr.status) return; + if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) { + this.error(responseErr.data.message); + } + } +} diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 67b5fb780..61b592ca0 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -1,11 +1,16 @@ # Lexical based editor todo +## In progress + +- Add Type: Drawings + - Continue converting drawio to typescript + - Next step to convert http service to ts. + ## Main Todo - Alignments: Use existing classes for blocks - Alignments: Handle inline block content (image, video) - Add Type: Video/media/embed -- Add Type: Drawings - Handle toolbars on scroll - Table features - Image paste upload @@ -20,6 +25,7 @@ - Link heading-based ID reference menu - Image gallery integration for insert - Image gallery integration for form +- Drawing gallery integration ## Bugs diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index cfb2c6aef..d6947ea75 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -2,7 +2,6 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; import {$selectionContainsNode, $selectSingleNode} from "../../helpers"; -import {context} from "esbuild"; import {BaseSelection} from "lexical"; diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 2f092bd20..9c48f8c24 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,12 +1,35 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; +import {$selectionContainsNode, $selectSingleNode} from "../../helpers"; +import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; +import {BaseSelection} from "lexical"; +import {$openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram"; export class DiagramDecorator extends EditorDecorator { protected completedSetup: boolean = false; setup(context: EditorUiContext, element: HTMLElement) { - // + const diagramNode = this.getNode(); + element.addEventListener('click', event => { + context.editor.update(() => { + $selectSingleNode(this.getNode()); + }) + }); + + element.addEventListener('dblclick', event => { + context.editor.getEditorState().read(() => { + $openDrawingEditorForNode(context.editor, (this.getNode() as DiagramNode)); + }); + }); + + const selectionChange = (selection: BaseSelection|null): void => { + element.classList.toggle('selected', $selectionContainsNode(selection, diagramNode)); + }; + context.manager.onSelectionChange(selectionChange); + this.onDestroy(() => { + context.manager.offSelectionChange(selectionChange); + }); this.completedSetup = true; } diff --git a/tsconfig.json b/tsconfig.json index 3ca03da30..0be5421c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ From fb87fb57502d03daa36c2eda9df53efebab8cb58 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 18 Jul 2024 15:13:14 +0100 Subject: [PATCH 047/107] JS: Converted http service to ts --- resources/js/app.js | 4 +- resources/js/global.d.ts | 3 + resources/js/services/drawio.ts | 6 +- resources/js/services/events.ts | 6 +- resources/js/services/http.js | 238 -------------------------------- resources/js/services/http.ts | 221 +++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 244 deletions(-) delete mode 100644 resources/js/services/http.js create mode 100644 resources/js/services/http.ts diff --git a/resources/js/app.js b/resources/js/app.js index 812a451f2..e08b90ba1 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,5 @@ import {EventManager} from './services/events.ts'; -import * as httpInstance from './services/http'; +import {HttpManager} from './services/http.ts'; import Translations from './services/translations'; import * as componentMap from './components'; import {ComponentStore} from './services/components.ts'; @@ -20,7 +20,7 @@ window.importVersioned = function importVersioned(moduleName) { }; // Set events and http services on window -window.$http = httpInstance; +window.$http = new HttpManager(); window.$events = new EventManager(); // Translation setup diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index a9b9275e9..1f216b7a5 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -1,9 +1,12 @@ import {ComponentStore} from "./services/components"; import {EventManager} from "./services/events"; +import {HttpManager} from "./services/http"; declare global { interface Window { $components: ComponentStore, $events: EventManager, + $http: HttpManager, + baseUrl: (path: string) => string; } } \ No newline at end of file diff --git a/resources/js/services/drawio.ts b/resources/js/services/drawio.ts index 75b161f75..c0a6b5044 100644 --- a/resources/js/services/drawio.ts +++ b/resources/js/services/drawio.ts @@ -1,6 +1,7 @@ // Docs: https://www.diagrams.net/doc/faq/embed-mode import * as store from './store'; import {ConfirmDialog} from "../components"; +import {HttpError} from "./http"; type DrawioExportEventResponse = { action: 'export', @@ -145,9 +146,10 @@ export function close() { export async function load(drawingId: string): Promise { try { const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`)); - return `data:image/png;base64,${resp.data.content}`; + const data = resp.data as {content: string}; + return `data:image/png;base64,${data.content}`; } catch (error) { - if (error instanceof window.$http.HttpError) { + if (error instanceof HttpError) { window.$events.showResponseError(error); } close(); diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index c251ee21b..7d72a9f1a 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -1,3 +1,5 @@ +import {HttpError} from "./http"; + export class EventManager { protected listeners: Record void)[]> = {}; protected stack: {name: string, data: {}}[] = []; @@ -62,9 +64,9 @@ export class EventManager { /** * Notify standard server-provided error messages. */ - showResponseError(responseErr: {status?: number, data?: {message?: string}}): void { + showResponseError(responseErr: {status?: number, data?: Record}|HttpError): void { if (!responseErr.status) return; - if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) { + if (responseErr.status >= 400 && typeof responseErr.data === 'object' && responseErr.data.message) { this.error(responseErr.data.message); } } diff --git a/resources/js/services/http.js b/resources/js/services/http.js deleted file mode 100644 index d95e4a59a..000000000 --- a/resources/js/services/http.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * @typedef FormattedResponse - * @property {Headers} headers - * @property {Response} original - * @property {Object|String} data - * @property {Boolean} redirected - * @property {Number} status - * @property {string} statusText - * @property {string} url - */ - -/** - * Get the content from a fetch response. - * Checks the content-type header to determine the format. - * @param {Response} response - * @returns {Promise} - */ -async function getResponseContent(response) { - if (response.status === 204) { - return null; - } - - const responseContentType = response.headers.get('Content-Type') || ''; - const subType = responseContentType.split(';')[0].split('/').pop(); - - if (subType === 'javascript' || subType === 'json') { - return response.json(); - } - - return response.text(); -} - -export class HttpError extends Error { - - constructor(response, content) { - super(response.statusText); - this.data = content; - this.headers = response.headers; - this.redirected = response.redirected; - this.status = response.status; - this.statusText = response.statusText; - this.url = response.url; - this.original = response; - } - -} - -/** - * @param {String} method - * @param {String} url - * @param {Object} events - * @return {XMLHttpRequest} - */ -export function createXMLHttpRequest(method, url, events = {}) { - const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); - const req = new XMLHttpRequest(); - - for (const [eventName, callback] of Object.entries(events)) { - req.addEventListener(eventName, callback.bind(req)); - } - - req.open(method, url); - req.withCredentials = true; - req.setRequestHeader('X-CSRF-TOKEN', csrfToken); - - return req; -} - -/** - * Create a new HTTP request, setting the required CSRF information - * to communicate with the back-end. Parses & formats the response. - * @param {String} url - * @param {Object} options - * @returns {Promise} - */ -async function request(url, options = {}) { - let requestUrl = url; - - if (!requestUrl.startsWith('http')) { - requestUrl = window.baseUrl(requestUrl); - } - - if (options.params) { - const urlObj = new URL(requestUrl); - for (const paramName of Object.keys(options.params)) { - const value = options.params[paramName]; - if (typeof value !== 'undefined' && value !== null) { - urlObj.searchParams.set(paramName, value); - } - } - requestUrl = urlObj.toString(); - } - - const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); - const requestOptions = {...options, credentials: 'same-origin'}; - requestOptions.headers = { - ...requestOptions.headers || {}, - baseURL: window.baseUrl(''), - 'X-CSRF-TOKEN': csrfToken, - }; - - const response = await fetch(requestUrl, requestOptions); - const content = await getResponseContent(response); - const returnData = { - data: content, - headers: response.headers, - redirected: response.redirected, - status: response.status, - statusText: response.statusText, - url: response.url, - original: response, - }; - - if (!response.ok) { - throw new HttpError(response, content); - } - - return returnData; -} - -/** - * Perform a HTTP request to the back-end that includes data in the body. - * Parses the body to JSON if an object, setting the correct headers. - * @param {String} method - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -async function dataRequest(method, url, data = null) { - const options = { - method, - body: data, - }; - - // Send data as JSON if a plain object - if (typeof data === 'object' && !(data instanceof FormData)) { - options.headers = { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }; - options.body = JSON.stringify(data); - } - - // Ensure FormData instances are sent over POST - // Since Laravel does not read multipart/form-data from other types - // of request. Hence the addition of the magic _method value. - if (data instanceof FormData && method !== 'post') { - data.append('_method', method); - options.method = 'post'; - } - - return request(url, options); -} - -/** - * Perform a HTTP GET request. - * Can easily pass query parameters as the second parameter. - * @param {String} url - * @param {Object} params - * @returns {Promise} - */ -export async function get(url, params = {}) { - return request(url, { - method: 'GET', - params, - }); -} - -/** - * Perform a HTTP POST request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function post(url, data = null) { - return dataRequest('POST', url, data); -} - -/** - * Perform a HTTP PUT request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function put(url, data = null) { - return dataRequest('PUT', url, data); -} - -/** - * Perform a HTTP PATCH request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -export async function patch(url, data = null) { - return dataRequest('PATCH', url, data); -} - -/** - * Perform a HTTP DELETE request. - * @param {String} url - * @param {Object} data - * @returns {Promise} - */ -async function performDelete(url, data = null) { - return dataRequest('DELETE', url, data); -} - -export {performDelete as delete}; - -/** - * Parse the response text for an error response to a user - * presentable string. Handles a range of errors responses including - * validation responses & server response text. - * @param {String} text - * @returns {String} - */ -export function formatErrorResponseText(text) { - const data = text.startsWith('{') ? JSON.parse(text) : {message: text}; - if (!data) { - return text; - } - - if (data.message || data.error) { - return data.message || data.error; - } - - const values = Object.values(data); - const isValidation = values.every(val => { - return Array.isArray(val) || val.every(x => typeof x === 'string'); - }); - - if (isValidation) { - return values.flat().join(' '); - } - - return text; -} diff --git a/resources/js/services/http.ts b/resources/js/services/http.ts new file mode 100644 index 000000000..f9eaafc39 --- /dev/null +++ b/resources/js/services/http.ts @@ -0,0 +1,221 @@ +type ResponseData = Record|string; + +type RequestOptions = { + params?: Record, + headers?: Record +}; + +type FormattedResponse = { + headers: Headers; + original: Response; + data: ResponseData; + redirected: boolean; + status: number; + statusText: string; + url: string; +}; + +export class HttpError extends Error implements FormattedResponse { + + data: ResponseData; + headers: Headers; + original: Response; + redirected: boolean; + status: number; + statusText: string; + url: string; + + constructor(response: Response, content: ResponseData) { + super(response.statusText); + this.data = content; + this.headers = response.headers; + this.redirected = response.redirected; + this.status = response.status; + this.statusText = response.statusText; + this.url = response.url; + this.original = response; + } +} + +export class HttpManager { + + /** + * Get the content from a fetch response. + * Checks the content-type header to determine the format. + */ + protected async getResponseContent(response: Response): Promise { + if (response.status === 204) { + return null; + } + + const responseContentType = response.headers.get('Content-Type') || ''; + const subType = responseContentType.split(';')[0].split('/').pop(); + + if (subType === 'javascript' || subType === 'json') { + return response.json(); + } + + return response.text(); + } + + createXMLHttpRequest(method: string, url: string, events: Record void> = {}): XMLHttpRequest { + const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content'); + const req = new XMLHttpRequest(); + + for (const [eventName, callback] of Object.entries(events)) { + req.addEventListener(eventName, callback.bind(req)); + } + + req.open(method, url); + req.withCredentials = true; + req.setRequestHeader('X-CSRF-TOKEN', csrfToken || ''); + + return req; + } + + /** + * Create a new HTTP request, setting the required CSRF information + * to communicate with the back-end. Parses & formats the response. + */ + protected async request(url: string, options: RequestOptions & RequestInit = {}): Promise { + let requestUrl = url; + + if (!requestUrl.startsWith('http')) { + requestUrl = window.baseUrl(requestUrl); + } + + if (options.params) { + const urlObj = new URL(requestUrl); + for (const paramName of Object.keys(options.params)) { + const value = options.params[paramName]; + if (typeof value !== 'undefined' && value !== null) { + urlObj.searchParams.set(paramName, value); + } + } + requestUrl = urlObj.toString(); + } + + const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || ''; + const requestOptions: RequestInit = {...options, credentials: 'same-origin'}; + requestOptions.headers = { + ...requestOptions.headers || {}, + baseURL: window.baseUrl(''), + 'X-CSRF-TOKEN': csrfToken, + }; + + const response = await fetch(requestUrl, requestOptions); + const content = await this.getResponseContent(response) || ''; + const returnData: FormattedResponse = { + data: content, + headers: response.headers, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + url: response.url, + original: response, + }; + + if (!response.ok) { + throw new HttpError(response, content); + } + + return returnData; + } + + /** + * Perform a HTTP request to the back-end that includes data in the body. + * Parses the body to JSON if an object, setting the correct headers. + */ + protected async dataRequest(method: string, url: string, data: Record|null): Promise { + const options: RequestInit & RequestOptions = { + method, + body: data as BodyInit, + }; + + // Send data as JSON if a plain object + if (typeof data === 'object' && !(data instanceof FormData)) { + options.headers = { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }; + options.body = JSON.stringify(data); + } + + // Ensure FormData instances are sent over POST + // Since Laravel does not read multipart/form-data from other types + // of request, hence the addition of the magic _method value. + if (data instanceof FormData && method !== 'post') { + data.append('_method', method); + options.method = 'post'; + } + + return this.request(url, options); + } + + /** + * Perform a HTTP GET request. + * Can easily pass query parameters as the second parameter. + */ + async get(url: string, params: {} = {}): Promise { + return this.request(url, { + method: 'GET', + params, + }); + } + + /** + * Perform a HTTP POST request. + */ + async post(url: string, data: null|Record = null): Promise { + return this.dataRequest('POST', url, data); + } + + /** + * Perform a HTTP PUT request. + */ + async put(url: string, data: null|Record = null): Promise { + return this.dataRequest('PUT', url, data); + } + + /** + * Perform a HTTP PATCH request. + */ + async patch(url: string, data: null|Record = null): Promise { + return this.dataRequest('PATCH', url, data); + } + + /** + * Perform a HTTP DELETE request. + */ + async delete(url: string, data: null|Record = null): Promise { + return this.dataRequest('DELETE', url, data); + } + + /** + * Parse the response text for an error response to a user + * presentable string. Handles a range of errors responses including + * validation responses & server response text. + */ + protected formatErrorResponseText(text: string): string { + const data = text.startsWith('{') ? JSON.parse(text) : {message: text}; + if (!data) { + return text; + } + + if (data.message || data.error) { + return data.message || data.error; + } + + const values = Object.values(data); + const isValidation = values.every(val => { + return Array.isArray(val) && val.every(x => typeof x === 'string'); + }); + + if (isValidation) { + return values.flat().join(' '); + } + + return text; + } + +} From c7c0df096487a10f879d2a427373c5198bf2435c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 19 Jul 2024 12:09:41 +0100 Subject: [PATCH 048/107] Lexical: Finished up core drawing insert/editing Added new options that sits on the context, for things needed but not for the core editor, which are defined out of the editor (drawio URL, error message text, pageId etc...) --- resources/icons/editor/diagram.svg | 1 + resources/js/components/wysiwyg-editor.js | 10 ++- resources/js/services/drawio.ts | 4 +- resources/js/wysiwyg-tinymce/plugin-drawio.js | 2 +- resources/js/wysiwyg/index.ts | 4 +- resources/js/wysiwyg/nodes/diagram.ts | 75 +++++++++++++++++-- resources/js/wysiwyg/todo.md | 3 - resources/js/wysiwyg/ui/decorators/diagram.ts | 4 +- .../wysiwyg/ui/defaults/button-definitions.ts | 27 +++++++ resources/js/wysiwyg/ui/framework/core.ts | 17 +++-- resources/js/wysiwyg/ui/index.ts | 5 +- resources/js/wysiwyg/ui/toolbars.ts | 3 +- resources/sass/_editor.scss | 3 + 13 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 resources/icons/editor/diagram.svg diff --git a/resources/icons/editor/diagram.svg b/resources/icons/editor/diagram.svg new file mode 100644 index 000000000..6ac78f56e --- /dev/null +++ b/resources/icons/editor/diagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index deb371864..ebc142e2a 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -12,7 +12,14 @@ export class WysiwygEditor extends Component { window.importVersioned('wysiwyg').then(wysiwyg => { const editorContent = this.input.value; - this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent); + this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent, { + drawioUrl: this.getDrawIoUrl(), + pageId: Number(this.$opts.pageId), + translations: { + imageUploadErrorText: this.$opts.imageUploadErrorText, + serverUploadLimitText: this.$opts.serverUploadLimitText, + }, + }); }); let handlingFormSubmit = false; @@ -35,7 +42,6 @@ export class WysiwygEditor extends Component { } getDrawIoUrl() { - // TODO const drawioUrlElem = document.querySelector('[drawio-url]'); if (drawioUrlElem) { return drawioUrlElem.getAttribute('drawio-url'); diff --git a/resources/js/services/drawio.ts b/resources/js/services/drawio.ts index c0a6b5044..4d7d88f1f 100644 --- a/resources/js/services/drawio.ts +++ b/resources/js/services/drawio.ts @@ -127,13 +127,13 @@ export async function show(drawioUrl: string, onInitCallback: () => Promise { +export async function upload(imageData: string, pageUploadedToId: string): Promise<{id: number, url: string}> { const data = { image: imageData, uploaded_to: pageUploadedToId, }; const resp = await window.$http.post(window.baseUrl('/images/drawio'), data); - return resp.data; + return resp.data as {id: number, url: string}; } export function close() { diff --git a/resources/js/wysiwyg-tinymce/plugin-drawio.js b/resources/js/wysiwyg-tinymce/plugin-drawio.js index 3b343a958..342cac0af 100644 --- a/resources/js/wysiwyg-tinymce/plugin-drawio.js +++ b/resources/js/wysiwyg-tinymce/plugin-drawio.js @@ -1,4 +1,4 @@ -import * as DrawIO from '../services/drawio'; +import * as DrawIO from '../services/drawio.ts'; import {wait} from '../services/util'; let pageEditor = null; diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 8cbaccd79..0aa04dfd9 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -9,7 +9,7 @@ import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {el} from "./helpers"; import {EditorUiContext} from "./ui/framework/core"; -export function createPageEditorInstance(container: HTMLElement, htmlContent: string): SimpleWysiwygEditorInterface { +export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { namespace: 'BookStackPageEditor', nodes: getNodesForPageEditor(), @@ -60,7 +60,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st } }); - const context: EditorUiContext = buildEditorUI(container, editArea, editor); + const context: EditorUiContext = buildEditorUI(container, editArea, editor, options); registerCommonNodeMutationListeners(context); return new SimpleWysiwygEditorInterface(editor); diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts index 15726813c..1aff06400 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -10,6 +10,9 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../helpers"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; +import * as DrawIO from '../../services/drawio'; +import {EditorUiContext} from "../ui/framework/core"; +import {HttpError} from "../../services/http"; export type SerializedDiagramNode = Spread<{ id: string; @@ -42,10 +45,10 @@ export class DiagramNode extends DecoratorNode { self.__drawingId = drawingId; } - getDrawingIdAndUrl(): {id: string, url: string} { + getDrawingIdAndUrl(): { id: string, url: string } { const self = this.getLatest(); return { - id: self.__drawingUrl, + id: self.__drawingId, url: self.__drawingUrl, }; } @@ -103,16 +106,16 @@ export class DiagramNode extends DecoratorNode { return false; } - static importDOM(): DOMConversionMap|null { + static importDOM(): DOMConversionMap | null { return { - div(node: HTMLElement): DOMConversion|null { + div(node: HTMLElement): DOMConversion | null { if (!node.hasAttribute('drawio-diagram')) { return null; } return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { + conversion: (element: HTMLElement): DOMConversionOutput | null => { const img = element.querySelector('img'); const drawingUrl = img?.getAttribute('src') || ''; @@ -153,6 +156,64 @@ export function $isDiagramNode(node: LexicalNode | null | undefined) { return node instanceof DiagramNode; } -export function $openDrawingEditorForNode(editor: LexicalEditor, node: DiagramNode): void { - // Todo + +function handleUploadError(error: HttpError, context: EditorUiContext): void { + if (error.status === 413) { + window.$events.emit('error', context.options.translations.serverUploadLimitText || ''); + } else { + window.$events.emit('error', context.options.translations.imageUploadErrorText || ''); + } + console.error(error); +} + +async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise { + const drawingId = await new Promise((res, rej) => { + editor.getEditorState().read(() => { + const {id: drawingId} = node.getDrawingIdAndUrl(); + res(drawingId); + }); + }); + + return drawingId || ''; +} + +async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise { + DrawIO.close(); + + if (isNew) { + const loadingImage: string = window.baseUrl('/loading.gif'); + context.editor.update(() => { + node.setDrawingIdAndUrl('', loadingImage); + }); + } + + try { + const img = await DrawIO.upload(pngData, context.options.pageId); + context.editor.update(() => { + node.setDrawingIdAndUrl(String(img.id), img.url); + }); + } catch (err) { + if (err instanceof HttpError) { + handleUploadError(err, context); + } + + if (isNew) { + context.editor.update(() => { + node.remove(); + }); + } + + throw new Error(`Failed to save image with error: ${err}`); + } +} + +export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void { + let isNew = false; + DrawIO.show(context.options.drawioUrl, async () => { + const drawingId = await loadDiagramIdFromNode(context.editor, node); + isNew = !drawingId; + return isNew ? '' : DrawIO.load(drawingId); + }, async (pngData: string) => { + return updateDrawingNodeFromData(context, node, pngData, isNew); + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 61b592ca0..e0b58eef6 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,9 +2,6 @@ ## In progress -- Add Type: Drawings - - Continue converting drawio to typescript - - Next step to convert http service to ts. ## Main Todo diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 9c48f8c24..0f1263f38 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,7 +1,6 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$selectionContainsNode, $selectSingleNode} from "../../helpers"; -import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; import {BaseSelection} from "lexical"; import {$openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram"; @@ -11,6 +10,7 @@ export class DiagramDecorator extends EditorDecorator { setup(context: EditorUiContext, element: HTMLElement) { const diagramNode = this.getNode(); + element.classList.add('editor-diagram'); element.addEventListener('click', event => { context.editor.update(() => { $selectSingleNode(this.getNode()); @@ -19,7 +19,7 @@ export class DiagramDecorator extends EditorDecorator { element.addEventListener('dblclick', event => { context.editor.getEditorState().read(() => { - $openDrawingEditorForNode(context.editor, (this.getNode() as DiagramNode)); + $openDrawingEditorForNode(context, (this.getNode() as DiagramNode)); }); }); diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index bf725f8c8..5316dacf7 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -67,12 +67,14 @@ import tableIcon from "@icons/editor/table.svg"; import imageIcon from "@icons/editor/image.svg"; import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; import codeBlockIcon from "@icons/editor/code-block.svg"; +import diagramIcon from "@icons/editor/diagram.svg"; import detailsIcon from "@icons/editor/details.svg"; import sourceIcon from "@icons/editor/source-view.svg"; import fullscreenIcon from "@icons/editor/fullscreen.svg"; import editIcon from "@icons/edit.svg"; import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; +import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -445,6 +447,31 @@ export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock icon: editIcon, }); +export const diagram: EditorButtonDefinition = { + label: 'Insert/edit drawing', + icon: diagramIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode|null); + if (diagramNode === null) { + context.editor.update(() => { + const diagram = $createDiagramNode(); + $insertNewBlockNodeAtSelection(diagram, true); + $openDrawingEditorForNode(context, diagram); + diagram.selectStart(); + }); + } else { + $openDrawingEditorForNode(context, diagramNode); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isDiagramNode); + } +}; + + export const details: EditorButtonDefinition = { label: 'Insert collapsible block', icon: detailsIcon, diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 465765caa..22a821a89 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -3,17 +3,18 @@ import {EditorUIManager} from "./manager"; import {el} from "../../helpers"; export type EditorUiStateUpdate = { - editor: LexicalEditor, - selection: BaseSelection|null, + editor: LexicalEditor; + selection: BaseSelection|null; }; export type EditorUiContext = { - editor: LexicalEditor, - editorDOM: HTMLElement, - containerDOM: HTMLElement, - translate: (text: string) => string, - manager: EditorUIManager, - lastSelection: BaseSelection|null, + editor: LexicalEditor; // Lexical editor instance + editorDOM: HTMLElement; // DOM element the editor is bound to + containerDOM: HTMLElement; // DOM element which contains all editor elements + translate: (text: string) => string; // Translate function + manager: EditorUIManager; // UI Manager instance for this editor + lastSelection: BaseSelection|null; // The last tracked selection made by the user + options: Record; // General user options which may be used by sub elements }; export abstract class EditorUiElement { diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 748370959..31407497f 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -12,7 +12,7 @@ import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; -export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor): EditorUiContext { +export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, @@ -21,6 +21,7 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit manager, translate: (text: string): string => text, lastSelection: null, + options, }; manager.setContext(context); @@ -43,7 +44,7 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit // Register context toolbars manager.registerContextToolbar('image', { - selector: 'img', + selector: 'img:not([drawio-diagram] img)', content: getImageToolbarContent(), displayTargetLocator(originalTarget: HTMLElement) { return originalTarget.closest('a') || originalTarget; diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 9145b8761..f5eae6b21 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -4,7 +4,7 @@ import { alignLeft, alignRight, blockquote, bold, bulletList, clearFormating, code, codeBlock, - dangerCallout, details, editCodeBlock, fullscreen, + dangerCallout, details, diagram, editCodeBlock, fullscreen, h2, h3, h4, h5, highlightColor, horizontalRule, image, infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, @@ -89,6 +89,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(image), new EditorButton(horizontalRule), new EditorButton(codeBlock), + new EditorButton(diagram), new EditorButton(details), ]), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 99045dd5a..b577d1850 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -316,6 +316,9 @@ body.editor-is-fullscreen { border: 1px dashed var(--editor-color-primary); } } +.editor-diagram.selected { + outline: 2px dashed var(--editor-color-primary); +} // Editor form elements .editor-form-field-wrapper { From 63f4b424534bcddbc89c0df6690ee277ebe236ef Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 19 Jul 2024 18:12:51 +0100 Subject: [PATCH 049/107] Lexical: Added toolbar scroll/resize handling Also added smarter above/below positioning to respond if toolbar would be off the bottom of the editor, and added hide/show when they'd go outside editor scroll bounds. --- resources/js/wysiwyg/index.ts | 2 +- resources/js/wysiwyg/todo.md | 4 +- resources/js/wysiwyg/ui/framework/core.ts | 1 + resources/js/wysiwyg/ui/framework/manager.ts | 23 +++++++++--- resources/js/wysiwyg/ui/framework/toolbars.ts | 37 +++++++++++++++++-- resources/js/wysiwyg/ui/index.ts | 10 +++-- resources/sass/_editor.scss | 4 ++ 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 0aa04dfd9..5f131df57 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -60,7 +60,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st } }); - const context: EditorUiContext = buildEditorUI(container, editArea, editor, options); + const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); registerCommonNodeMutationListeners(context); return new SimpleWysiwygEditorInterface(editor); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index e0b58eef6..9950254df 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -8,7 +8,6 @@ - Alignments: Use existing classes for blocks - Alignments: Handle inline block content (image, video) - Add Type: Video/media/embed -- Handle toolbars on scroll - Table features - Image paste upload - Keyboard shortcuts support @@ -27,4 +26,5 @@ ## Bugs - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. -- Table resize bars often floating around in wrong place, and shows on hover or interrupts mouse actions. \ No newline at end of file +- Table resize bars often floating around in wrong place, and shows on hover or interrupts mouse actions. +- Removing link around image via button deletes image, not just link \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 22a821a89..c8f390c48 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -11,6 +11,7 @@ export type EditorUiContext = { editor: LexicalEditor; // Lexical editor instance editorDOM: HTMLElement; // DOM element the editor is bound to containerDOM: HTMLElement; // DOM element which contains all editor elements + scrollDOM: HTMLElement; // DOM element which is the main content scroll container translate: (text: string) => string; // Translate function manager: EditorUIManager; // UI Manager instance for this editor lastSelection: BaseSelection|null; // The last tracked selection made by the user diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index cfa94e8ae..29d959910 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -21,6 +21,7 @@ export class EditorUIManager { setContext(context: EditorUiContext) { this.context = context; + this.setupEventListeners(context); this.setupEditor(context.editor); } @@ -130,9 +131,10 @@ export class EditorUIManager { } protected updateContextToolbars(update: EditorUiStateUpdate): void { - for (const toolbar of this.activeContextToolbars) { - toolbar.empty(); - toolbar.getDOMElement().remove(); + for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { + const toolbar = this.activeContextToolbars[i]; + toolbar.destroy(); + this.activeContextToolbars.splice(i, 1); } const node = (update.selection?.getNodes() || [])[0] || null; @@ -161,12 +163,12 @@ export class EditorUIManager { } for (const [target, contents] of contentByTarget) { - const toolbar = new EditorContextToolbar(contents); + const toolbar = new EditorContextToolbar(target, contents); toolbar.setContext(this.getContext()); this.activeContextToolbars.push(toolbar); this.getContext().containerDOM.append(toolbar.getDOMElement()); - toolbar.attachTo(target); + toolbar.updatePosition(); } } @@ -202,4 +204,15 @@ export class EditorUIManager { } editor.registerDecoratorListener(domDecorateListener); } + + protected setupEventListeners(context: EditorUiContext) { + const updateToolbars = (event: Event) => { + for (const toolbar of this.activeContextToolbars) { + toolbar.updatePosition(); + } + }; + + window.addEventListener('scroll', updateToolbars, {capture: true, passive: true}); + window.addEventListener('resize', updateToolbars, {passive: true}); + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index c9db0d6bd..d7c481934 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -9,20 +9,44 @@ export type EditorContextToolbarDefinition = { export class EditorContextToolbar extends EditorContainerUiElement { + protected target: HTMLElement; + + constructor(target: HTMLElement, children: EditorUiElement[]) { + super(children); + this.target = target; + } + protected buildDOM(): HTMLElement { return el('div', { class: 'editor-context-toolbar', }, this.getChildren().map(child => child.getDOMElement())); } - attachTo(target: HTMLElement) { - const targetBounds = target.getBoundingClientRect(); + updatePosition() { + const editorBounds = this.getContext().scrollDOM.getBoundingClientRect(); + const targetBounds = this.target.getBoundingClientRect(); const dom = this.getDOMElement(); const domBounds = dom.getBoundingClientRect(); + const showing = targetBounds.bottom > editorBounds.top + && targetBounds.top < editorBounds.bottom; + + dom.hidden = !showing; + + if (!showing) { + return; + } + + const showAbove: boolean = targetBounds.bottom + 6 + domBounds.height > editorBounds.bottom; + dom.classList.toggle('is-above', showAbove); + const targetMid = targetBounds.left + (targetBounds.width / 2); const targetLeft = targetMid - (domBounds.width / 2); - dom.style.top = (targetBounds.bottom + 6) + 'px'; + if (showAbove) { + dom.style.top = (targetBounds.top - 6 - domBounds.height) + 'px'; + } else { + dom.style.top = (targetBounds.bottom + 6) + 'px'; + } dom.style.left = targetLeft + 'px'; } @@ -32,11 +56,16 @@ export class EditorContextToolbar extends EditorContainerUiElement { dom.append(...children.map(child => child.getDOMElement())); } - empty() { + protected empty() { const children = this.getChildren(); for (const child of children) { child.getDOMElement().remove(); } this.removeChildren(...children); } + + destroy() { + this.empty(); + this.getDOMElement().remove(); + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 31407497f..f728ae48f 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -12,12 +12,13 @@ import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; -export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { +export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, containerDOM: container, editorDOM: element, + scrollDOM: scrollContainer, manager, translate: (text: string): string => text, lastSelection: null, @@ -46,13 +47,14 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit manager.registerContextToolbar('image', { selector: 'img:not([drawio-diagram] img)', content: getImageToolbarContent(), - displayTargetLocator(originalTarget: HTMLElement) { - return originalTarget.closest('a') || originalTarget; - } }); manager.registerContextToolbar('link', { selector: 'a', content: getLinkToolbarContent(), + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + const image = originalTarget.querySelector('img'); + return image || originalTarget; + } }); manager.registerContextToolbar('code', { selector: '.editor-code-block-wrap', diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index b577d1850..17e4af97b 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -161,6 +161,10 @@ body.editor-is-fullscreen { margin-left: -4px; top: -5px; } + &.is-above:before { + top: calc(100% - 5px); + transform: rotate(225deg); + } } // Modals From b6182875858d637f19f49a2a98c61887a0c5c5d3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 21 Jul 2024 15:11:24 +0100 Subject: [PATCH 050/107] Lexical: Added table toolbar, organised button code --- .../icons/editor/table-delete-column.svg | 1 + resources/icons/editor/table-delete-row.svg | 1 + resources/icons/editor/table-delete.svg | 1 + .../editor/table-insert-column-after.svg | 1 + .../editor/table-insert-column-before.svg | 1 + .../icons/editor/table-insert-row-above.svg | 1 + .../icons/editor/table-insert-row-below.svg | 1 + resources/js/wysiwyg/helpers.ts | 17 +- resources/js/wysiwyg/todo.md | 12 +- .../wysiwyg/ui/defaults/button-definitions.ts | 528 ------------------ .../wysiwyg/ui/defaults/buttons/alignments.ts | 61 ++ .../ui/defaults/buttons/block-formats.ts | 85 +++ .../wysiwyg/ui/defaults/buttons/controls.ts | 81 +++ .../ui/defaults/buttons/inline-formats.ts | 56 ++ .../js/wysiwyg/ui/defaults/buttons/lists.ts | 35 ++ .../js/wysiwyg/ui/defaults/buttons/objects.ts | 215 +++++++ .../js/wysiwyg/ui/defaults/buttons/tables.ts | 122 ++++ resources/js/wysiwyg/ui/index.ts | 10 +- resources/js/wysiwyg/ui/toolbars.ts | 74 ++- 19 files changed, 756 insertions(+), 547 deletions(-) create mode 100644 resources/icons/editor/table-delete-column.svg create mode 100644 resources/icons/editor/table-delete-row.svg create mode 100644 resources/icons/editor/table-delete.svg create mode 100644 resources/icons/editor/table-insert-column-after.svg create mode 100644 resources/icons/editor/table-insert-column-before.svg create mode 100644 resources/icons/editor/table-insert-row-above.svg create mode 100644 resources/icons/editor/table-insert-row-below.svg delete mode 100644 resources/js/wysiwyg/ui/defaults/button-definitions.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/alignments.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/controls.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/lists.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/objects.ts create mode 100644 resources/js/wysiwyg/ui/defaults/buttons/tables.ts diff --git a/resources/icons/editor/table-delete-column.svg b/resources/icons/editor/table-delete-column.svg new file mode 100644 index 000000000..428fc29a6 --- /dev/null +++ b/resources/icons/editor/table-delete-column.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-delete-row.svg b/resources/icons/editor/table-delete-row.svg new file mode 100644 index 000000000..ee2f8a00d --- /dev/null +++ b/resources/icons/editor/table-delete-row.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-delete.svg b/resources/icons/editor/table-delete.svg new file mode 100644 index 000000000..412cf0732 --- /dev/null +++ b/resources/icons/editor/table-delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-column-after.svg b/resources/icons/editor/table-insert-column-after.svg new file mode 100644 index 000000000..75abd9a85 --- /dev/null +++ b/resources/icons/editor/table-insert-column-after.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-column-before.svg b/resources/icons/editor/table-insert-column-before.svg new file mode 100644 index 000000000..5bb38cd29 --- /dev/null +++ b/resources/icons/editor/table-insert-column-before.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-row-above.svg b/resources/icons/editor/table-insert-row-above.svg new file mode 100644 index 000000000..df951485a --- /dev/null +++ b/resources/icons/editor/table-insert-row-above.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/table-insert-row-below.svg b/resources/icons/editor/table-insert-row-below.svg new file mode 100644 index 000000000..b2af77592 --- /dev/null +++ b/resources/icons/editor/table-insert-row-below.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index a7c3e4453..6a55c429c 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -44,10 +44,19 @@ export function $getNodeFromSelection(selection: BaseSelection|null, matcher: Le return node; } - for (const parent of node.getParents()) { - if (matcher(parent)) { - return parent; - } + const matchedParent = $getParentOfType(node, matcher); + if (matchedParent) { + return matchedParent; + } + } + + return null; +} + +export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode|null { + for (const parent of node.getParents()) { + if (matcher(parent)) { + return parent; } } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 9950254df..7b9588194 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,12 +2,22 @@ ## In progress +- Add Type: Video/media/embed + - TinyMce media embed supported: + - iframe + - embed + - object + - video - Can take sources + - audio - Can take sources + - Pretty much all attributes look like they were supported. + - Core old logic seen here: https://github.com/tinymce/tinymce/blob/main/modules/tinymce/src/plugins/media/main/ts/core/DataToHtml.ts + - Copy/store attributes on node based on allow list? + - width, height, src, controls, etc... Take valid values from MDN ## Main Todo - Alignments: Use existing classes for blocks - Alignments: Handle inline block content (image, video) -- Add Type: Video/media/embed - Table features - Image paste upload - Keyboard shortcuts support diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts deleted file mode 100644 index 5316dacf7..000000000 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ /dev/null @@ -1,528 +0,0 @@ -import {EditorBasicButtonDefinition, EditorButton, EditorButtonDefinition} from "../framework/buttons"; -import { - $createNodeSelection, - $createParagraphNode, - $createTextNode, - $getRoot, - $getSelection, - $isParagraphNode, - $isTextNode, - $setSelection, - BaseSelection, - CAN_REDO_COMMAND, - CAN_UNDO_COMMAND, - COMMAND_PRIORITY_LOW, - ElementFormatType, - ElementNode, - FORMAT_TEXT_COMMAND, - LexicalNode, - REDO_COMMAND, - TextFormatType, - UNDO_COMMAND -} from "lexical"; -import { - $getBlockElementNodesInSelection, - $getNodeFromSelection, $insertNewBlockNodeAtSelection, $selectionContainsElementFormat, - $selectionContainsNodeType, - $selectionContainsTextFormat, - $toggleSelectionBlockNodeType -} from "../../helpers"; -import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout"; -import { - $createHeadingNode, - $createQuoteNode, - $isHeadingNode, - $isQuoteNode, - HeadingNode, - HeadingTagType -} from "@lexical/rich-text"; -import {$isLinkNode, LinkNode} from "@lexical/link"; -import {EditorUiContext} from "../framework/core"; -import {$isImageNode, ImageNode} from "../../nodes/image"; -import {$createDetailsNode, $isDetailsNode} from "../../nodes/details"; -import {getEditorContentAsHtml} from "../../actions"; -import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; -import undoIcon from "@icons/editor/undo.svg"; -import redoIcon from "@icons/editor/redo.svg"; -import boldIcon from "@icons/editor/bold.svg"; -import italicIcon from "@icons/editor/italic.svg"; -import underlinedIcon from "@icons/editor/underlined.svg"; -import textColorIcon from "@icons/editor/text-color.svg"; -import highlightIcon from "@icons/editor/highlighter.svg"; -import strikethroughIcon from "@icons/editor/strikethrough.svg"; -import superscriptIcon from "@icons/editor/superscript.svg"; -import subscriptIcon from "@icons/editor/subscript.svg"; -import codeIcon from "@icons/editor/code.svg"; -import formatClearIcon from "@icons/editor/format-clear.svg"; -import alignLeftIcon from "@icons/editor/align-left.svg"; -import alignCenterIcon from "@icons/editor/align-center.svg"; -import alignRightIcon from "@icons/editor/align-right.svg"; -import alignJustifyIcon from "@icons/editor/align-justify.svg"; -import listBulletIcon from "@icons/editor/list-bullet.svg"; -import listNumberedIcon from "@icons/editor/list-numbered.svg"; -import listCheckIcon from "@icons/editor/list-check.svg"; -import linkIcon from "@icons/editor/link.svg"; -import unlinkIcon from "@icons/editor/unlink.svg"; -import tableIcon from "@icons/editor/table.svg"; -import imageIcon from "@icons/editor/image.svg"; -import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; -import codeBlockIcon from "@icons/editor/code-block.svg"; -import diagramIcon from "@icons/editor/diagram.svg"; -import detailsIcon from "@icons/editor/details.svg"; -import sourceIcon from "@icons/editor/source-view.svg"; -import fullscreenIcon from "@icons/editor/fullscreen.svg"; -import editIcon from "@icons/edit.svg"; -import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; -import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram"; - -export const undo: EditorButtonDefinition = { - label: 'Undo', - icon: undoIcon, - action(context: EditorUiContext) { - context.editor.dispatchCommand(UNDO_COMMAND, undefined); - }, - isActive(selection: BaseSelection|null): boolean { - return false; - }, - setup(context: EditorUiContext, button: EditorButton) { - button.toggleDisabled(true); - - context.editor.registerCommand(CAN_UNDO_COMMAND, (payload: boolean): boolean => { - button.toggleDisabled(!payload) - return false; - }, COMMAND_PRIORITY_LOW); - } -} - -export const redo: EditorButtonDefinition = { - label: 'Redo', - icon: redoIcon, - action(context: EditorUiContext) { - context.editor.dispatchCommand(REDO_COMMAND, undefined); - }, - isActive(selection: BaseSelection|null): boolean { - return false; - }, - setup(context: EditorUiContext, button: EditorButton) { - button.toggleDisabled(true); - - context.editor.registerCommand(CAN_REDO_COMMAND, (payload: boolean): boolean => { - button.toggleDisabled(!payload) - return false; - }, COMMAND_PRIORITY_LOW); - } -} - -function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { - return { - label: `${name} Callout`, - action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType( - (node) => $isCalloutNodeOfCategory(node, category), - () => $createCalloutNode(category), - ) - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category)); - } - }; -} - -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; -}; - -function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { - return { - label: name, - action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType( - (node) => isHeaderNodeOfTag(node, tag), - () => $createHeadingNode(tag), - ) - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); - } - }; -} - -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 blockquote: EditorButtonDefinition = { - label: 'Blockquote', - action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isQuoteNode); - } -}; - -export const paragraph: EditorButtonDefinition = { - label: 'Paragraph', - action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isParagraphNode); - } -} - -function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { - return { - label: label, - icon, - action(context: EditorUiContext) { - context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsTextFormat(selection, format); - } - }; -} - -export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon); -export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); -export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); -export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; -export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon}; - -export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); -export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); -export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); -export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code', codeIcon); -export const clearFormating: EditorButtonDefinition = { - label: 'Clear formatting', - icon: formatClearIcon, - action(context: EditorUiContext) { - context.editor.update(() => { - const selection = $getSelection(); - for (const node of selection?.getNodes() || []) { - if ($isTextNode(node)) { - node.setFormat(0); - node.setStyle(''); - } - } - }); - }, - isActive() { - return false; - } -}; - -function setAlignmentForSection(alignment: ElementFormatType): void { - const selection = $getSelection(); - const elements = $getBlockElementNodesInSelection(selection); - for (const node of elements) { - node.setFormat(alignment); - } -} - -export const alignLeft: EditorButtonDefinition = { - label: 'Align left', - icon: alignLeftIcon, - action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('left')); - }, - isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'left'); - } -}; - -export const alignCenter: EditorButtonDefinition = { - label: 'Align center', - icon: alignCenterIcon, - action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('center')); - }, - isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'center'); - } -}; - -export const alignRight: EditorButtonDefinition = { - label: 'Align right', - icon: alignRightIcon, - action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('right')); - }, - isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'right'); - } -}; - -export const alignJustify: EditorButtonDefinition = { - label: 'Align justify', - icon: alignJustifyIcon, - action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('justify')); - }, - isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'justify'); - } -}; - - -function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { - return { - label, - icon, - action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - if (this.isActive(selection, context)) { - removeList(context.editor); - } else { - insertList(context.editor, type); - } - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { - return $isListNode(node) && (node as ListNode).getListType() === type; - }); - } - }; -} - -export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon); -export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); -export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); - - -export const link: EditorButtonDefinition = { - label: 'Insert/edit link', - icon: linkIcon, - action(context: EditorUiContext) { - const linkModal = context.manager.createModal('link'); - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; - - let formDefaults = {}; - if (selectedLink) { - formDefaults = { - url: selectedLink.getURL(), - text: selectedLink.getTextContent(), - title: selectedLink.getTitle(), - target: selectedLink.getTarget(), - } - - context.editor.update(() => { - const selection = $createNodeSelection(); - selection.add(selectedLink.getKey()); - $setSelection(selection); - }); - } - - linkModal.show(formDefaults); - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isLinkNode); - } -}; - -export const unlink: EditorButtonDefinition = { - label: 'Remove link', - icon: unlinkIcon, - action(context: EditorUiContext) { - context.editor.update(() => { - const selection = context.lastSelection; - const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; - const selectionPoints = selection?.getStartEndPoints(); - - if (selectedLink) { - const newNode = $createTextNode(selectedLink.getTextContent()); - selectedLink.replace(newNode); - if (selectionPoints?.length === 2) { - newNode.select(selectionPoints[0].offset, selectionPoints[1].offset); - } else { - newNode.select(); - } - } - }); - }, - isActive(selection: BaseSelection|null): boolean { - return false; - } -}; - -export const table: EditorBasicButtonDefinition = { - label: 'Table', - icon: tableIcon, -}; - -export const image: EditorButtonDefinition = { - label: 'Insert/Edit Image', - icon: imageIcon, - action(context: EditorUiContext) { - const imageModal = context.manager.createModal('image'); - const selection = context.lastSelection; - const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode|null; - - context.editor.getEditorState().read(() => { - let formDefaults = {}; - if (selectedImage) { - formDefaults = { - src: selectedImage.getSrc(), - alt: selectedImage.getAltText(), - height: selectedImage.getHeight(), - width: selectedImage.getWidth(), - } - - context.editor.update(() => { - const selection = $createNodeSelection(); - selection.add(selectedImage.getKey()); - $setSelection(selection); - }); - } - - imageModal.show(formDefaults); - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isImageNode); - } -}; - -export const horizontalRule: EditorButtonDefinition = { - label: 'Insert horizontal line', - icon: horizontalRuleIcon, - action(context: EditorUiContext) { - context.editor.update(() => { - $insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isHorizontalRuleNode); - } -}; - -export const codeBlock: EditorButtonDefinition = { - label: 'Insert code block', - icon: codeBlockIcon, - action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); - if (codeBlock === null) { - context.editor.update(() => { - const codeBlock = $createCodeBlockNode(); - codeBlock.setCode(selection?.getTextContent() || ''); - $insertNewBlockNodeAtSelection(codeBlock, true); - $openCodeEditorForNode(context.editor, codeBlock); - codeBlock.selectStart(); - }); - } else { - $openCodeEditorForNode(context.editor, codeBlock); - } - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isCodeBlockNode); - } -}; - -export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, { - label: 'Edit code block', - icon: editIcon, -}); - -export const diagram: EditorButtonDefinition = { - label: 'Insert/edit drawing', - icon: diagramIcon, - action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode|null); - if (diagramNode === null) { - context.editor.update(() => { - const diagram = $createDiagramNode(); - $insertNewBlockNodeAtSelection(diagram, true); - $openDrawingEditorForNode(context, diagram); - diagram.selectStart(); - }); - } else { - $openDrawingEditorForNode(context, diagramNode); - } - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isDiagramNode); - } -}; - - -export const details: EditorButtonDefinition = { - label: 'Insert collapsible block', - icon: detailsIcon, - action(context: EditorUiContext) { - context.editor.update(() => { - const selection = $getSelection(); - const detailsNode = $createDetailsNode(); - const selectionNodes = selection?.getNodes() || []; - const topLevels = selectionNodes.map(n => n.getTopLevelElement()) - .filter(n => n !== null) as ElementNode[]; - const uniqueTopLevels = [...new Set(topLevels)]; - - if (uniqueTopLevels.length > 0) { - uniqueTopLevels[0].insertAfter(detailsNode); - } else { - $getRoot().append(detailsNode); - } - - for (const node of uniqueTopLevels) { - detailsNode.append(node); - } - }); - }, - isActive(selection: BaseSelection|null): boolean { - return $selectionContainsNodeType(selection, $isDetailsNode); - } -} - -export const source: EditorButtonDefinition = { - label: 'Source code', - icon: sourceIcon, - async action(context: EditorUiContext) { - const modal = context.manager.createModal('source'); - const source = await getEditorContentAsHtml(context.editor); - modal.show({source}); - }, - isActive() { - return false; - } -}; - -export const fullscreen: EditorButtonDefinition = { - label: 'Fullscreen', - icon: fullscreenIcon, - async action(context: EditorUiContext, button: EditorButton) { - const isFullScreen = context.containerDOM.classList.contains('fullscreen'); - context.containerDOM.classList.toggle('fullscreen', !isFullScreen); - (context.containerDOM.closest('body') as HTMLElement).classList.toggle('editor-is-fullscreen', !isFullScreen); - button.setActiveState(!isFullScreen); - }, - isActive(selection, context: EditorUiContext) { - return context.containerDOM.classList.contains('fullscreen'); - } -}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts new file mode 100644 index 000000000..2b441e5da --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -0,0 +1,61 @@ +import {$getSelection, BaseSelection, ElementFormatType} from "lexical"; +import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../helpers"; +import {EditorButtonDefinition} from "../../framework/buttons"; +import alignLeftIcon from "@icons/editor/align-left.svg"; +import {EditorUiContext} from "../../framework/core"; +import alignCenterIcon from "@icons/editor/align-center.svg"; +import alignRightIcon from "@icons/editor/align-right.svg"; +import alignJustifyIcon from "@icons/editor/align-justify.svg"; + + +function setAlignmentForSection(alignment: ElementFormatType): void { + const selection = $getSelection(); + const elements = $getBlockElementNodesInSelection(selection); + for (const node of elements) { + node.setFormat(alignment); + } +} + +export const alignLeft: EditorButtonDefinition = { + label: 'Align left', + icon: alignLeftIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('left')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsElementFormat(selection, 'left'); + } +}; + +export const alignCenter: EditorButtonDefinition = { + label: 'Align center', + icon: alignCenterIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('center')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsElementFormat(selection, 'center'); + } +}; + +export const alignRight: EditorButtonDefinition = { + label: 'Align right', + icon: alignRightIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('right')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsElementFormat(selection, 'right'); + } +}; + +export const alignJustify: EditorButtonDefinition = { + label: 'Align justify', + icon: alignJustifyIcon, + action(context: EditorUiContext) { + context.editor.update(() => setAlignmentForSection('justify')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsElementFormat(selection, 'justify'); + } +}; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts new file mode 100644 index 000000000..0eb07ecf1 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -0,0 +1,85 @@ +import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; +import {EditorButtonDefinition} from "../../framework/buttons"; +import {EditorUiContext} from "../../framework/core"; +import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../helpers"; +import {$createParagraphNode, $isParagraphNode, BaseSelection, LexicalNode} from "lexical"; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + $isQuoteNode, + HeadingNode, + HeadingTagType +} from "@lexical/rich-text"; + +function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { + return { + label: `${name} Callout`, + action(context: EditorUiContext) { + context.editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => $isCalloutNodeOfCategory(node, category), + () => $createCalloutNode(category), + ) + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, category)); + } + }; +} + +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; +}; + +function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { + return { + label: name, + action(context: EditorUiContext) { + context.editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => isHeaderNodeOfTag(node, tag), + () => $createHeadingNode(tag), + ) + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); + } + }; +} + +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 blockquote: EditorButtonDefinition = { + label: 'Blockquote', + action(context: EditorUiContext) { + context.editor.update(() => { + $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isQuoteNode); + } +}; + +export const paragraph: EditorButtonDefinition = { + label: 'Paragraph', + action(context: EditorUiContext) { + context.editor.update(() => { + $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isParagraphNode); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts new file mode 100644 index 000000000..ad69d69d1 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -0,0 +1,81 @@ +import {EditorButton, EditorButtonDefinition} from "../../framework/buttons"; +import undoIcon from "@icons/editor/undo.svg"; +import {EditorUiContext} from "../../framework/core"; +import { + BaseSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, + REDO_COMMAND, + UNDO_COMMAND +} from "lexical"; +import redoIcon from "@icons/editor/redo.svg"; +import sourceIcon from "@icons/editor/source-view.svg"; +import {getEditorContentAsHtml} from "../../../actions"; +import fullscreenIcon from "@icons/editor/fullscreen.svg"; + +export const undo: EditorButtonDefinition = { + label: 'Undo', + icon: undoIcon, + action(context: EditorUiContext) { + context.editor.dispatchCommand(UNDO_COMMAND, undefined); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + }, + setup(context: EditorUiContext, button: EditorButton) { + button.toggleDisabled(true); + + context.editor.registerCommand(CAN_UNDO_COMMAND, (payload: boolean): boolean => { + button.toggleDisabled(!payload) + return false; + }, COMMAND_PRIORITY_LOW); + } +} + +export const redo: EditorButtonDefinition = { + label: 'Redo', + icon: redoIcon, + action(context: EditorUiContext) { + context.editor.dispatchCommand(REDO_COMMAND, undefined); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + }, + setup(context: EditorUiContext, button: EditorButton) { + button.toggleDisabled(true); + + context.editor.registerCommand(CAN_REDO_COMMAND, (payload: boolean): boolean => { + button.toggleDisabled(!payload) + return false; + }, COMMAND_PRIORITY_LOW); + } +} + + +export const source: EditorButtonDefinition = { + label: 'Source code', + icon: sourceIcon, + async action(context: EditorUiContext) { + const modal = context.manager.createModal('source'); + const source = await getEditorContentAsHtml(context.editor); + modal.show({source}); + }, + isActive() { + return false; + } +}; + +export const fullscreen: EditorButtonDefinition = { + label: 'Fullscreen', + icon: fullscreenIcon, + async action(context: EditorUiContext, button: EditorButton) { + const isFullScreen = context.containerDOM.classList.contains('fullscreen'); + context.containerDOM.classList.toggle('fullscreen', !isFullScreen); + (context.containerDOM.closest('body') as HTMLElement).classList.toggle('editor-is-fullscreen', !isFullScreen); + button.setActiveState(!isFullScreen); + }, + isActive(selection, context: EditorUiContext) { + return context.containerDOM.classList.contains('fullscreen'); + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts new file mode 100644 index 000000000..d04f72a2e --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -0,0 +1,56 @@ +import {$getSelection, $isTextNode, BaseSelection, FORMAT_TEXT_COMMAND, TextFormatType} from "lexical"; +import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../../framework/buttons"; +import {EditorUiContext} from "../../framework/core"; +import {$selectionContainsTextFormat} from "../../../helpers"; +import boldIcon from "@icons/editor/bold.svg"; +import italicIcon from "@icons/editor/italic.svg"; +import underlinedIcon from "@icons/editor/underlined.svg"; +import textColorIcon from "@icons/editor/text-color.svg"; +import highlightIcon from "@icons/editor/highlighter.svg"; +import strikethroughIcon from "@icons/editor/strikethrough.svg"; +import superscriptIcon from "@icons/editor/superscript.svg"; +import subscriptIcon from "@icons/editor/subscript.svg"; +import codeIcon from "@icons/editor/code.svg"; +import formatClearIcon from "@icons/editor/format-clear.svg"; + +function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { + return { + label: label, + icon, + action(context: EditorUiContext) { + context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsTextFormat(selection, format); + } + }; +} + +export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', boldIcon); +export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); +export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); +export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon}; + +export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); +export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); +export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); +export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code', codeIcon); +export const clearFormating: EditorButtonDefinition = { + label: 'Clear formatting', + icon: formatClearIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + for (const node of selection?.getNodes() || []) { + if ($isTextNode(node)) { + node.setFormat(0); + node.setStyle(''); + } + } + }); + }, + isActive() { + return false; + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts new file mode 100644 index 000000000..ecda290a1 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -0,0 +1,35 @@ +import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {EditorButtonDefinition} from "../../framework/buttons"; +import {EditorUiContext} from "../../framework/core"; +import {$getSelection, BaseSelection, LexicalNode} from "lexical"; +import {$selectionContainsNodeType} from "../../../helpers"; +import listBulletIcon from "@icons/editor/list-bullet.svg"; +import listNumberedIcon from "@icons/editor/list-numbered.svg"; +import listCheckIcon from "@icons/editor/list-check.svg"; + + +function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { + return { + label, + icon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + if (this.isActive(selection, context)) { + removeList(context.editor); + } else { + insertList(context.editor, type); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $isListNode(node) && (node as ListNode).getListType() === type; + }); + } + }; +} + +export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon); +export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); +export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts new file mode 100644 index 000000000..88241e926 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -0,0 +1,215 @@ +import {EditorButtonDefinition} from "../../framework/buttons"; +import linkIcon from "@icons/editor/link.svg"; +import {EditorUiContext} from "../../framework/core"; +import { + $createNodeSelection, + $createTextNode, + $getRoot, + $getSelection, + $setSelection, + BaseSelection, + ElementNode +} from "lexical"; +import {$getNodeFromSelection, $insertNewBlockNodeAtSelection, $selectionContainsNodeType} from "../../../helpers"; +import {$isLinkNode, LinkNode} from "@lexical/link"; +import unlinkIcon from "@icons/editor/unlink.svg"; +import imageIcon from "@icons/editor/image.svg"; +import {$isImageNode, ImageNode} from "../../../nodes/image"; +import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; +import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; +import codeBlockIcon from "@icons/editor/code-block.svg"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../../nodes/code-block"; +import editIcon from "@icons/edit.svg"; +import diagramIcon from "@icons/editor/diagram.svg"; +import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../../nodes/diagram"; +import detailsIcon from "@icons/editor/details.svg"; +import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details"; + +export const link: EditorButtonDefinition = { + label: 'Insert/edit link', + icon: linkIcon, + action(context: EditorUiContext) { + const linkModal = context.manager.createModal('link'); + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + + let formDefaults = {}; + if (selectedLink) { + formDefaults = { + url: selectedLink.getURL(), + text: selectedLink.getTextContent(), + title: selectedLink.getTitle(), + target: selectedLink.getTarget(), + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(selectedLink.getKey()); + $setSelection(selection); + }); + } + + linkModal.show(formDefaults); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isLinkNode); + } +}; + +export const unlink: EditorButtonDefinition = { + label: 'Remove link', + icon: unlinkIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = context.lastSelection; + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + const selectionPoints = selection?.getStartEndPoints(); + + if (selectedLink) { + const newNode = $createTextNode(selectedLink.getTextContent()); + selectedLink.replace(newNode); + if (selectionPoints?.length === 2) { + newNode.select(selectionPoints[0].offset, selectionPoints[1].offset); + } else { + newNode.select(); + } + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return false; + } +}; + + + +export const image: EditorButtonDefinition = { + label: 'Insert/Edit Image', + icon: imageIcon, + action(context: EditorUiContext) { + const imageModal = context.manager.createModal('image'); + const selection = context.lastSelection; + const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode|null; + + context.editor.getEditorState().read(() => { + let formDefaults = {}; + if (selectedImage) { + formDefaults = { + src: selectedImage.getSrc(), + alt: selectedImage.getAltText(), + height: selectedImage.getHeight(), + width: selectedImage.getWidth(), + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(selectedImage.getKey()); + $setSelection(selection); + }); + } + + imageModal.show(formDefaults); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isImageNode); + } +}; + +export const horizontalRule: EditorButtonDefinition = { + label: 'Insert horizontal line', + icon: horizontalRuleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isHorizontalRuleNode); + } +}; + +export const codeBlock: EditorButtonDefinition = { + label: 'Insert code block', + icon: codeBlockIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); + if (codeBlock === null) { + context.editor.update(() => { + const codeBlock = $createCodeBlockNode(); + codeBlock.setCode(selection?.getTextContent() || ''); + $insertNewBlockNodeAtSelection(codeBlock, true); + $openCodeEditorForNode(context.editor, codeBlock); + codeBlock.selectStart(); + }); + } else { + $openCodeEditorForNode(context.editor, codeBlock); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isCodeBlockNode); + } +}; + +export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, { + label: 'Edit code block', + icon: editIcon, +}); + +export const diagram: EditorButtonDefinition = { + label: 'Insert/edit drawing', + icon: diagramIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode|null); + if (diagramNode === null) { + context.editor.update(() => { + const diagram = $createDiagramNode(); + $insertNewBlockNodeAtSelection(diagram, true); + $openDrawingEditorForNode(context, diagram); + diagram.selectStart(); + }); + } else { + $openDrawingEditorForNode(context, diagramNode); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isDiagramNode); + } +}; + + +export const details: EditorButtonDefinition = { + label: 'Insert collapsible block', + icon: detailsIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + const detailsNode = $createDetailsNode(); + const selectionNodes = selection?.getNodes() || []; + const topLevels = selectionNodes.map(n => n.getTopLevelElement()) + .filter(n => n !== null) as ElementNode[]; + const uniqueTopLevels = [...new Set(topLevels)]; + + if (uniqueTopLevels.length > 0) { + uniqueTopLevels[0].insertAfter(detailsNode); + } else { + $getRoot().append(detailsNode); + } + + for (const node of uniqueTopLevels) { + detailsNode.append(node); + } + }); + }, + isActive(selection: BaseSelection|null): boolean { + return $selectionContainsNodeType(selection, $isDetailsNode); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts new file mode 100644 index 000000000..32fa49f88 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -0,0 +1,122 @@ +import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../../framework/buttons"; +import tableIcon from "@icons/editor/table.svg"; +import deleteIcon from "@icons/editor/table-delete.svg"; +import deleteColumnIcon from "@icons/editor/table-delete-column.svg"; +import deleteRowIcon from "@icons/editor/table-delete-row.svg"; +import insertColumnAfterIcon from "@icons/editor/table-insert-column-after.svg"; +import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg"; +import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; +import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; +import {EditorUiContext} from "../../framework/core"; +import {$getBlockElementNodesInSelection, $getNodeFromSelection, $getParentOfType} from "../../../helpers"; +import {$getSelection} from "lexical"; +import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table"; +import { + $deleteTableColumn, $deleteTableColumn__EXPERIMENTAL, + $deleteTableRow__EXPERIMENTAL, + $getTableRowIndexFromTableCellNode, $insertTableColumn, $insertTableColumn__EXPERIMENTAL, + $insertTableRow, $insertTableRow__EXPERIMENTAL, + $isTableCellNode, + $isTableRowNode, + TableCellNode +} from "@lexical/table"; + + +export const table: EditorBasicButtonDefinition = { + label: 'Table', + icon: tableIcon, +}; + +export const deleteTable: EditorButtonDefinition = { + label: 'Delete table', + icon: deleteIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const table = $getNodeFromSelection($getSelection(), $isCustomTableNode); + if (table) { + table.remove(); + } + }); + }, + isActive() { + return false; + } +}; + +export const insertRowAbove: EditorButtonDefinition = { + label: 'Insert row above', + icon: insertRowAboveIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableRow__EXPERIMENTAL(false); + }); + }, + isActive() { + return false; + } +}; + +export const insertRowBelow: EditorButtonDefinition = { + label: 'Insert row below', + icon: insertRowBelowIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableRow__EXPERIMENTAL(true); + }); + }, + isActive() { + return false; + } +}; + +export const deleteRow: EditorButtonDefinition = { + label: 'Delete row', + icon: deleteRowIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $deleteTableRow__EXPERIMENTAL(); + }); + }, + isActive() { + return false; + } +}; + +export const insertColumnBefore: EditorButtonDefinition = { + label: 'Insert column before', + icon: insertColumnBeforeIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableColumn__EXPERIMENTAL(false); + }); + }, + isActive() { + return false; + } +}; + +export const insertColumnAfter: EditorButtonDefinition = { + label: 'Insert column after', + icon: insertColumnAfterIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $insertTableColumn__EXPERIMENTAL(true); + }); + }, + isActive() { + return false; + } +}; + +export const deleteColumn: EditorButtonDefinition = { + label: 'Delete column', + icon: deleteColumnIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + $deleteTableColumn__EXPERIMENTAL(); + }); + }, + isActive() { + return false; + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index f728ae48f..5dee6b62b 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -3,7 +3,7 @@ import { getCodeToolbarContent, getImageToolbarContent, getLinkToolbarContent, - getMainEditorFullToolbar + getMainEditorFullToolbar, getTableToolbarContent } from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; @@ -61,6 +61,14 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro content: getCodeToolbarContent(), }); + manager.registerContextToolbar('table', { + selector: 'td,th', + content: getTableToolbarContent(), + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + return originalTarget.closest('table') as HTMLTableElement; + } + }); + // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('code', CodeBlockDecorator); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index f5eae6b21..5d40578a5 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,17 +1,4 @@ import {EditorButton} from "./framework/buttons"; -import { - alignCenter, alignJustify, - alignLeft, - alignRight, - blockquote, bold, bulletList, clearFormating, code, codeBlock, - dangerCallout, details, diagram, editCodeBlock, fullscreen, - h2, h3, h4, h5, highlightColor, horizontalRule, image, - infoCallout, italic, link, numberList, paragraph, - redo, source, strikethrough, subscript, - successCallout, superscript, table, taskList, textColor, underline, - undo, unlink, - warningCallout -} from "./defaults/button-definitions"; import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core"; import {el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; @@ -21,6 +8,48 @@ import {EditorColorPicker} from "./framework/blocks/color-picker"; import {EditorTableCreator} from "./framework/blocks/table-creator"; import {EditorColorButton} from "./framework/blocks/color-button"; import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; +import { + deleteColumn, + deleteRow, + deleteTable, insertColumnAfter, + insertColumnBefore, + insertRowAbove, + insertRowBelow, + table +} from "./defaults/buttons/tables"; +import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; +import { + blockquote, dangerCallout, + h2, + h3, + h4, + h5, + infoCallout, + paragraph, + successCallout, + warningCallout +} from "./defaults/buttons/block-formats"; +import { + bold, clearFormating, code, + highlightColor, + italic, + strikethrough, subscript, + superscript, + textColor, + underline +} from "./defaults/buttons/inline-formats"; +import {alignCenter, alignJustify, alignLeft, alignRight} from "./defaults/buttons/alignments"; +import {bulletList, numberList, taskList} from "./defaults/buttons/lists"; +import { + codeBlock, + details, + diagram, + editCodeBlock, + horizontalRule, + image, + link, + unlink +} from "./defaults/buttons/objects"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -129,4 +158,23 @@ export function getCodeToolbarContent(): EditorUiElement[] { return [ new EditorButton(editCodeBlock), ]; +} + +export function getTableToolbarContent(): EditorUiElement[] { + return [ + new EditorOverflowContainer(2, [ + // Todo - Table properties + new EditorButton(deleteTable), + ]), + new EditorOverflowContainer(3, [ + new EditorButton(insertRowAbove), + new EditorButton(insertRowBelow), + new EditorButton(deleteRow), + ]), + new EditorOverflowContainer(3, [ + new EditorButton(insertColumnBefore), + new EditorButton(insertColumnAfter), + new EditorButton(deleteColumn), + ]), + ]; } \ No newline at end of file From 2cab778f19be477beebdada5096d6b945d84596a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 23 Jul 2024 12:45:58 +0100 Subject: [PATCH 051/107] Lexical: Improved table resize bars Added scoll & page resize handling. Added cropping/limiting to edit area. --- resources/js/wysiwyg/index.ts | 2 +- resources/js/wysiwyg/todo.md | 4 +- .../ui/framework/helpers/table-resizer.ts | 48 +++++++++++++++---- resources/js/wysiwyg/ui/framework/manager.ts | 1 + 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 5f131df57..469738e7f 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -44,7 +44,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerTableResizer(editor, editArea), + registerTableResizer(editor, editWrap), ); setEditorContentFromHtml(editor, htmlContent); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 7b9588194..e39a4c655 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -36,5 +36,5 @@ ## Bugs - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. -- Table resize bars often floating around in wrong place, and shows on hover or interrupts mouse actions. -- Removing link around image via button deletes image, not just link \ No newline at end of file +- Removing link around image via button deletes image, not just link +- `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index 8f1e978e9..2d995883a 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -8,7 +8,7 @@ type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; class TableResizer { protected editor: LexicalEditor; - protected editArea: HTMLElement; + protected editScrollContainer: HTMLElement; protected markerDom: MarkerDomRecord|null = null; protected mouseTracker: MouseDragTracker|null = null; protected dragging: boolean = false; @@ -16,15 +16,17 @@ class TableResizer { protected xMarkerAtStart : boolean = false; protected yMarkerAtStart : boolean = false; - constructor(editor: LexicalEditor, editArea: HTMLElement) { + constructor(editor: LexicalEditor, editScrollContainer: HTMLElement) { this.editor = editor; - this.editArea = editArea; + this.editScrollContainer = editScrollContainer; this.setupListeners(); } teardown() { - this.editArea.removeEventListener('mousemove', this.onCellMouseMove); + this.editScrollContainer.removeEventListener('mousemove', this.onCellMouseMove); + window.removeEventListener('scroll', this.onScrollOrResize, {capture: true}); + window.removeEventListener('resize', this.onScrollOrResize); if (this.mouseTracker) { this.mouseTracker.teardown(); } @@ -32,7 +34,14 @@ class TableResizer { protected setupListeners() { this.onCellMouseMove = this.onCellMouseMove.bind(this); - this.editArea.addEventListener('mousemove', this.onCellMouseMove); + this.onScrollOrResize = this.onScrollOrResize.bind(this); + this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove); + window.addEventListener('scroll', this.onScrollOrResize, {capture: true, passive: true}); + window.addEventListener('resize', this.onScrollOrResize, {passive: true}); + } + + protected onScrollOrResize(): void { + this.updateCurrentMarkerTargetPosition(); } protected onCellMouseMove(event: MouseEvent) { @@ -58,14 +67,33 @@ class TableResizer { const markers: MarkerDomRecord = this.getMarkers(); const table = cell.closest('table') as HTMLElement; const tableRect = table.getBoundingClientRect(); + const editBounds = this.editScrollContainer.getBoundingClientRect(); + const maxTop = Math.max(tableRect.top, editBounds.top); + const maxBottom = Math.min(tableRect.bottom, editBounds.bottom); + const maxHeight = maxBottom - maxTop; markers.x.style.left = xPos + 'px'; - markers.x.style.height = tableRect.height + 'px'; - markers.x.style.top = tableRect.top + 'px'; + markers.x.style.top = maxTop + 'px'; + markers.x.style.height = maxHeight + 'px'; markers.y.style.top = yPos + 'px'; markers.y.style.left = tableRect.left + 'px'; markers.y.style.width = tableRect.width + 'px'; + + // Hide markers when out of bounds + markers.y.hidden = yPos < editBounds.top || yPos > editBounds.bottom; + markers.x.hidden = tableRect.top > editBounds.bottom || tableRect.bottom < editBounds.top; + } + + protected updateCurrentMarkerTargetPosition(): void { + if (!this.targetCell) { + return; + } + + const rect = this.targetCell.getBoundingClientRect(); + const xMarkerPos = this.xMarkerAtStart ? rect.left : rect.right; + const yMarkerPos = this.yMarkerAtStart ? rect.top : rect.bottom; + this.updateMarkersTo(this.targetCell, xMarkerPos, yMarkerPos); } protected getMarkers(): MarkerDomRecord { @@ -77,7 +105,7 @@ class TableResizer { const wrapper = el('div', { class: 'editor-table-marker-wrap', }, [this.markerDom.x, this.markerDom.y]); - this.editArea.after(wrapper); + this.editScrollContainer.after(wrapper); this.watchMarkerMouseDrags(wrapper); } @@ -180,8 +208,8 @@ class TableResizer { } -export function registerTableResizer(editor: LexicalEditor, editorArea: HTMLElement): (() => void) { - const resizer = new TableResizer(editor, editorArea); +export function registerTableResizer(editor: LexicalEditor, editScrollContainer: HTMLElement): (() => void) { + const resizer = new TableResizer(editor, editScrollContainer); return () => { resizer.teardown(); diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 29d959910..c0357c3ea 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -175,6 +175,7 @@ export class EditorUIManager { protected setupEditor(editor: LexicalEditor) { // Update button states on editor selection change editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { + console.log('select change', arguments); this.triggerStateUpdate({ editor: editor, selection: $getSelection(), From 76b0d2d5d85a742aaa1aa2b6fd96a9a9ec661a7f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 23 Jul 2024 15:35:18 +0100 Subject: [PATCH 052/107] Lexical: Added common events support --- resources/js/services/events.ts | 4 +- resources/js/wysiwyg/actions.ts | 81 ++++++++++++++++++++++----- resources/js/wysiwyg/common-events.ts | 43 ++++++++++++++ resources/js/wysiwyg/index.ts | 3 + resources/js/wysiwyg/todo.md | 1 - 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 resources/js/wysiwyg/common-events.ts diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index 7d72a9f1a..32c70f5a8 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -1,7 +1,7 @@ import {HttpError} from "./http"; export class EventManager { - protected listeners: Record void)[]> = {}; + protected listeners: Record void)[]> = {}; protected stack: {name: string, data: {}}[] = []; /** @@ -19,7 +19,7 @@ export class EventManager { /** * Listen to a custom event and run the given callback when that event occurs. */ - listen(eventName: string, callback: (data: {}) => void): void { + listen(eventName: string, callback: (data: T) => void): void { if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = []; this.listeners[eventName].push(callback); } diff --git a/resources/js/wysiwyg/actions.ts b/resources/js/wysiwyg/actions.ts index 3a32b82d8..0e2202525 100644 --- a/resources/js/wysiwyg/actions.ts +++ b/resources/js/wysiwyg/actions.ts @@ -1,13 +1,30 @@ -import {$createParagraphNode, $getRoot, $isTextNode, LexicalEditor} from "lexical"; +import {$getRoot, $getSelection, $isTextNode, LexicalEditor, LexicalNode, RootNode} from "lexical"; import {$generateHtmlFromNodes, $generateNodesFromDOM} from "@lexical/html"; import {$createCustomParagraphNode} from "./nodes/custom-paragraph"; +function htmlToDom(html: string): Document { + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +} + +function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { + return nodes.map(node => { + if ($isTextNode(node)) { + const paragraph = $createCustomParagraphNode(); + paragraph.append(node); + return paragraph; + } + return node; + }); +} + +function appendNodesToRoot(root: RootNode, nodes: LexicalNode[]) { + root.append(...wrapTextNodes(nodes)); +} export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { - const parser = new DOMParser(); - const dom = parser.parseFromString(html, 'text/html'); + const dom = htmlToDom(html); - console.log(html); editor.update(() => { // Empty existing const root = $getRoot(); @@ -16,18 +33,52 @@ export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { } const nodes = $generateNodesFromDOM(editor, dom); + root.append(...wrapTextNodes(nodes)); + }); +} - // Wrap top-level text nodes - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if ($isTextNode(node)) { - const paragraph = $createCustomParagraphNode(); - paragraph.append(node); - nodes[i] = paragraph; +export function appendHtmlToEditor(editor: LexicalEditor, html: string) { + const dom = htmlToDom(html); + + editor.update(() => { + const root = $getRoot(); + const nodes = $generateNodesFromDOM(editor, dom); + root.append(...wrapTextNodes(nodes)); + }); +} + +export function prependHtmlToEditor(editor: LexicalEditor, html: string) { + const dom = htmlToDom(html); + + editor.update(() => { + const root = $getRoot(); + const nodes = wrapTextNodes($generateNodesFromDOM(editor, dom)); + let reference = root.getChildren()[0]; + for (let i = nodes.length - 1; i >= 0; i--) { + if (reference) { + reference.insertBefore(nodes[i]); + } else { + root.append(nodes[i]) + } + reference = nodes[i]; + } + }); +} + +export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) { + const dom = htmlToDom(html); + editor.update(() => { + const selection = $getSelection(); + const nodes = wrapTextNodes($generateNodesFromDOM(editor, dom)); + + const reference = selection?.getNodes()[0]; + const referencesParents = reference?.getParents() || []; + const topLevel = referencesParents[referencesParents.length - 1]; + if (topLevel && reference) { + for (let i = nodes.length - 1; i >= 0; i--) { + reference.insertAfter(nodes[i]); } } - - root.append(...nodes); }); } @@ -38,4 +89,8 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise { resolve(html); }); }); +} + +export function focusEditor(editor: LexicalEditor) { + editor.focus(() => {}, {defaultSelection: "rootStart"}); } \ No newline at end of file diff --git a/resources/js/wysiwyg/common-events.ts b/resources/js/wysiwyg/common-events.ts new file mode 100644 index 000000000..7355d977b --- /dev/null +++ b/resources/js/wysiwyg/common-events.ts @@ -0,0 +1,43 @@ +import {LexicalEditor} from "lexical"; +import { + appendHtmlToEditor, + focusEditor, + insertHtmlIntoEditor, + prependHtmlToEditor, + setEditorContentFromHtml +} from "./actions"; + +type EditorEventContent = { + html: string; + markdown: string; +}; + +function getContentToInsert(eventContent: EditorEventContent): string { + return eventContent.html || ''; +} + +export function listen(editor: LexicalEditor): void { + window.$events.listen('editor::replace', eventContent => { + const html = getContentToInsert(eventContent); + setEditorContentFromHtml(editor, html); + }); + + window.$events.listen('editor::append', eventContent => { + const html = getContentToInsert(eventContent); + appendHtmlToEditor(editor, html); + }); + + window.$events.listen('editor::prepend', eventContent => { + const html = getContentToInsert(eventContent); + prependHtmlToEditor(editor, html); + }); + + window.$events.listen('editor::insert', eventContent => { + const html = getContentToInsert(eventContent); + insertHtmlIntoEditor(editor, html); + }); + + window.$events.listen('editor::focus', () => { + focusEditor(editor); + }); +} diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 469738e7f..e53b9b057 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -8,6 +8,7 @@ import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {el} from "./helpers"; import {EditorUiContext} from "./ui/framework/core"; +import {listen as listenToCommonEvents} from "./common-events"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -47,6 +48,8 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTableResizer(editor, editWrap), ); + listenToCommonEvents(editor); + setEditorContentFromHtml(editor, htmlContent); const debugView = document.getElementById('lexical-debug'); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index e39a4c655..c62a6e524 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -21,7 +21,6 @@ - Table features - Image paste upload - Keyboard shortcuts support -- Global/shared editor events support - Draft/change management (connect with page editor component) - Add ID support to all block types - Template drag & drop / insert From f284d31861057dfdb575400b965e99e02cbf8cf7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 25 Jul 2024 16:25:08 +0100 Subject: [PATCH 053/107] Lexical: Started media node support --- resources/icons/editor/media.svg | 1 + resources/js/wysiwyg/nodes/index.ts | 5 +- resources/js/wysiwyg/nodes/media.ts | 215 ++++++++++++++++++ resources/js/wysiwyg/todo.md | 13 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 55 +++-- .../wysiwyg/ui/defaults/form-definitions.ts | 50 ++++ resources/js/wysiwyg/ui/index.ts | 6 +- resources/js/wysiwyg/ui/toolbars.ts | 5 +- 8 files changed, 320 insertions(+), 30 deletions(-) create mode 100644 resources/icons/editor/media.svg create mode 100644 resources/js/wysiwyg/nodes/media.ts diff --git a/resources/icons/editor/media.svg b/resources/icons/editor/media.svg new file mode 100644 index 000000000..0c4feea4c --- /dev/null +++ b/resources/icons/editor/media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index a2c739576..669ffe6dd 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,10 +1,8 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; import { - $getNodeByKey, ElementNode, KlassConstructor, - LexicalEditor, LexicalNode, LexicalNodeReplacement, NodeMutation, ParagraphNode @@ -19,8 +17,8 @@ import {CustomTableNode} from "./custom-table"; import {HorizontalRuleNode} from "./horizontal-rule"; import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; -import {EditorUIManager} from "../ui/framework/manager"; import {EditorUiContext} from "../ui/framework/core"; +import {MediaNode} from "./media"; /** * Load the nodes for lexical. @@ -40,6 +38,7 @@ export function getNodesForPageEditor(): (KlassConstructor | DetailsNode, SummaryNode, CodeBlockNode, DiagramNode, + MediaNode, CustomParagraphNode, LinkNode, { diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts new file mode 100644 index 000000000..e0c1b3141 --- /dev/null +++ b/resources/js/wysiwyg/nodes/media.ts @@ -0,0 +1,215 @@ +import { + DOMConversion, + DOMConversionMap, DOMConversionOutput, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode, Spread +} from 'lexical'; +import type {EditorConfig} from "lexical/LexicalEditor"; +import {el} from "../helpers"; + +export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; +export type MediaNodeSource = { + src: string; + type: string; +}; + +export type SerializedMediaNode = Spread<{ + tag: MediaNodeTag; + attributes: Record; + sources: MediaNodeSource[]; +}, SerializedElementNode> + +const attributeAllowList = [ + 'id', 'width', 'height', 'style', 'title', 'name', + 'src', 'allow', 'allowfullscreen', 'loading', 'sandbox', + 'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop', + 'muted', 'playsinline', 'poster', 'preload' +]; + +function filterAttributes(attributes: Record): Record { + const filtered: Record = {}; + for (const key in Object.keys(attributes)) { + if (attributeAllowList.includes(key)) { + filtered[key] = attributes[key]; + } + } + return filtered; +} + +function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode { + const node = $createMediaNode(tag); + + const attributes: Record = {}; + for (const attribute of element.attributes) { + attributes[attribute.name] = attribute.value; + } + node.setAttributes(attributes); + + const sources: MediaNodeSource[] = []; + if (tag === 'video' || tag === 'audio') { + for (const child of element.children) { + if (child.tagName === 'SOURCE') { + const src = child.getAttribute('src'); + const type = child.getAttribute('type'); + if (src && type) { + sources.push({ src, type }); + } + } + } + node.setSources(sources); + } + + return node; +} + +export class MediaNode extends ElementNode { + + __tag: MediaNodeTag; + __attributes: Record = {}; + __sources: MediaNodeSource[] = []; + + static getType() { + return 'media'; + } + + static clone(node: MediaNode) { + return new MediaNode(node.__tag, node.__key); + } + + constructor(tag: MediaNodeTag, key?: string) { + super(key); + this.__tag = tag; + } + + setTag(tag: MediaNodeTag) { + const self = this.getWritable(); + self.__tag = tag; + } + + getTag(): MediaNodeTag { + const self = this.getLatest(); + return self.__tag; + } + + setAttributes(attributes: Record) { + const self = this.getWritable(); + self.__attributes = filterAttributes(attributes); + } + + getAttributes(): Record { + const self = this.getLatest(); + return self.__attributes; + } + + setSources(sources: MediaNodeSource[]) { + const self = this.getWritable(); + self.__sources = sources; + } + + getSources(): MediaNodeSource[] { + const self = this.getLatest(); + return self.__sources; + } + + setSrc(src: string): void { + const attrs = Object.assign({}, this.getAttributes()); + if (this.__tag ==='object') { + attrs.data = src; + } else { + attrs.src = src; + } + this.setAttributes(attrs); + } + + setWidthAndHeight(width: string, height: string): void { + const attrs = Object.assign( + {}, + this.getAttributes(), + {width, height}, + ); + this.setAttributes(attrs); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; + const sourceEls = sources.map(source => el('source', source)); + + return el(this.__tag, this.__attributes, sourceEls); + } + + updateDOM(prevNode: unknown, dom: HTMLElement) { + return true; + } + + static importDOM(): DOMConversionMap|null { + + const buildConverter = (tag: MediaNodeTag) => { + return (node: HTMLElement): DOMConversion|null => { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return { + node: domElementToNode(tag, element), + }; + }, + priority: 3, + }; + }; + }; + + return { + iframe: buildConverter('iframe'), + embed: buildConverter('embed'), + object: buildConverter('object'), + video: buildConverter('video'), + audio: buildConverter('audio'), + }; + } + + exportJSON(): SerializedMediaNode { + return { + ...super.exportJSON(), + type: 'callout', + version: 1, + tag: this.__tag, + attributes: this.__attributes, + sources: this.__sources, + }; + } + + static importJSON(serializedNode: SerializedMediaNode): MediaNode { + return $createMediaNode(serializedNode.tag); + } + +} + +export function $createMediaNode(tag: MediaNodeTag) { + return new MediaNode(tag); +} + +export function $createMediaNodeFromHtml(html: string): MediaNode | null { + const parser = new DOMParser(); + const doc = parser.parseFromString(`${html}`, 'text/html'); + + const el = doc.body.children[0]; + if (!el) { + return null; + } + + const tag = el.tagName.toLowerCase(); + const validTypes = ['embed', 'iframe', 'video', 'audio', 'object']; + if (!validTypes.includes(tag)) { + return null; + } + + return domElementToNode(tag as MediaNodeTag, el); +} + +export function $isMediaNode(node: LexicalNode | null | undefined) { + return node instanceof MediaNode; +} + +export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag) { + return node instanceof MediaNode && (node as MediaNode).getTag() === tag; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index c62a6e524..cd36f359e 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,17 +2,7 @@ ## In progress -- Add Type: Video/media/embed - - TinyMce media embed supported: - - iframe - - embed - - object - - video - Can take sources - - audio - Can take sources - - Pretty much all attributes look like they were supported. - - Core old logic seen here: https://github.com/tinymce/tinymce/blob/main/modules/tinymce/src/plugins/media/main/ts/core/DataToHtml.ts - - Copy/store attributes on node based on allow list? - - width, height, src, controls, etc... Take valid values from MDN +- Finish initial media node & form integration ## Main Todo @@ -31,6 +21,7 @@ - Image gallery integration for insert - Image gallery integration for form - Drawing gallery integration +- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 88241e926..3c14052ba 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -23,7 +23,9 @@ import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../../nodes/diagram"; import detailsIcon from "@icons/editor/details.svg"; +import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details"; +import {$isMediaNode, MediaNode} from "../../../nodes/media"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', @@ -32,7 +34,7 @@ export const link: EditorButtonDefinition = { const linkModal = context.manager.createModal('link'); context.editor.getEditorState().read(() => { const selection = $getSelection(); - const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; let formDefaults = {}; if (selectedLink) { @@ -53,7 +55,7 @@ export const link: EditorButtonDefinition = { linkModal.show(formDefaults); }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isLinkNode); } }; @@ -64,7 +66,7 @@ export const unlink: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.update(() => { const selection = context.lastSelection; - const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null; + const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; const selectionPoints = selection?.getStartEndPoints(); if (selectedLink) { @@ -78,20 +80,19 @@ export const unlink: EditorButtonDefinition = { } }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return false; } }; - export const image: EditorButtonDefinition = { label: 'Insert/Edit Image', icon: imageIcon, action(context: EditorUiContext) { const imageModal = context.manager.createModal('image'); const selection = context.lastSelection; - const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode|null; + const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null; context.editor.getEditorState().read(() => { let formDefaults = {}; @@ -113,7 +114,7 @@ export const image: EditorButtonDefinition = { imageModal.show(formDefaults); }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isImageNode); } }; @@ -126,7 +127,7 @@ export const horizontalRule: EditorButtonDefinition = { $insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isHorizontalRuleNode); } }; @@ -137,7 +138,7 @@ export const codeBlock: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); - const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); + const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); if (codeBlock === null) { context.editor.update(() => { const codeBlock = $createCodeBlockNode(); @@ -151,7 +152,7 @@ export const codeBlock: EditorButtonDefinition = { } }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isCodeBlockNode); } }; @@ -167,7 +168,7 @@ export const diagram: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); - const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode|null); + const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode | null); if (diagramNode === null) { context.editor.update(() => { const diagram = $createDiagramNode(); @@ -180,11 +181,39 @@ export const diagram: EditorButtonDefinition = { } }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDiagramNode); } }; +export const media: EditorButtonDefinition = { + label: 'Insert/edit Media', + icon: mediaIcon, + action(context: EditorUiContext) { + const mediaModal = context.manager.createModal('media'); + + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null; + + let formDefaults = {}; + if (selectedNode) { + const nodeAttrs = selectedNode.getAttributes(); + formDefaults = { + src: nodeAttrs.src || nodeAttrs.data || '', + width: nodeAttrs.width, + height: nodeAttrs.height, + embed: '', + } + } + + mediaModal.show(formDefaults); + }); + }, + isActive(selection: BaseSelection | null): boolean { + return $selectionContainsNodeType(selection, $isMediaNode); + } +}; export const details: EditorButtonDefinition = { label: 'Insert collapsible block', @@ -209,7 +238,7 @@ export const details: EditorButtonDefinition = { } }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDetailsNode); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index 04147a4f0..e0459b5c5 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -4,6 +4,7 @@ import {$createLinkNode} from "@lexical/link"; import {$createTextNode, $getSelection} from "lexical"; import {$createImageNode} from "../../nodes/image"; import {setEditorContentFromHtml} from "../../actions"; +import {$createMediaNodeFromHtml} from "../../nodes/media"; export const link: EditorFormDefinition = { @@ -89,6 +90,55 @@ export const image: EditorFormDefinition = { ], }; +export const media: EditorFormDefinition = { + submitText: 'Save', + action(formData, context: EditorUiContext) { + + // TODO - Get media from selection + + const embedCode = (formData.get('embed') || '').toString().trim(); + if (embedCode) { + context.editor.update(() => { + const node = $createMediaNodeFromHtml(embedCode); + // TODO - Replace existing or insert new + }); + + return true; + } + + const src = (formData.get('src') || '').toString().trim(); + const height = (formData.get('height') || '').toString().trim(); + const width = (formData.get('width') || '').toString().trim(); + + // TODO - Update existing or insert new + + return true; + }, + fields: [ + { + label: 'Source', + name: 'src', + type: 'text', + }, + { + label: 'Width', + name: 'width', + type: 'text', + }, + { + label: 'Height', + name: 'height', + type: 'text', + }, + // TODO - Tabbed interface to separate this option + { + label: 'Paste your embed code below:', + name: 'embed', + type: 'textarea', + }, + ], +}; + export const source: EditorFormDefinition = { submitText: 'Save', action(formData, context: EditorUiContext) { diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 5dee6b62b..a3f150e52 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,7 +6,7 @@ import { getMainEditorFullToolbar, getTableToolbarContent } from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; +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"; @@ -38,6 +38,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro title: 'Insert/Edit Image', form: imageFormDefinition }); + manager.registerModal('media', { + title: 'Insert/Edit Media', + form: mediaFormDefinition, + }); manager.registerModal('source', { title: 'Source code', form: sourceFormDefinition, diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 5d40578a5..ae6a292a2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -47,7 +47,7 @@ import { editCodeBlock, horizontalRule, image, - link, + link, media, unlink } from "./defaults/buttons/objects"; @@ -110,7 +110,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Insert types - new EditorOverflowContainer(6, [ + new EditorOverflowContainer(8, [ new EditorButton(link), new EditorDropdownButton(table, false, [ new EditorTableCreator(), @@ -119,6 +119,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(horizontalRule), new EditorButton(codeBlock), new EditorButton(diagram), + new EditorButton(media), new EditorButton(details), ]), From c8f6b7e0d655562aa143ad7c3e82c560b376e74b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 27 Jul 2024 17:25:30 +0100 Subject: [PATCH 054/107] Lexical: Got media node core work & form done --- resources/js/wysiwyg/nodes/media.ts | 23 +++++++++- resources/js/wysiwyg/todo.md | 3 +- .../wysiwyg/ui/defaults/form-definitions.ts | 43 +++++++++++++------ resources/js/wysiwyg/ui/framework/forms.ts | 2 +- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index e0c1b3141..751f420fa 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -30,7 +30,7 @@ const attributeAllowList = [ function filterAttributes(attributes: Record): Record { const filtered: Record = {}; - for (const key in Object.keys(attributes)) { + for (const key of Object.keys(attributes)) { if (attributeAllowList.includes(key)) { filtered[key] = attributes[key]; } @@ -170,7 +170,7 @@ export class MediaNode extends ElementNode { exportJSON(): SerializedMediaNode { return { ...super.exportJSON(), - type: 'callout', + type: 'media', version: 1, tag: this.__tag, attributes: this.__attributes, @@ -206,6 +206,25 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { return domElementToNode(tag as MediaNodeTag, el); } +const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov']; +const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm']; +const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx']; + +export function $createMediaNodeFromSrc(src: string): MediaNode { + let nodeTag: MediaNodeTag = 'iframe'; + const srcEnd = src.split('?')[0].split('/').pop() || ''; + const extension = (srcEnd.split('.').pop() || '').toLowerCase(); + if (videoExtensions.includes(extension)) { + nodeTag = 'video'; + } else if (audioExtensions.includes(extension)) { + nodeTag = 'audio'; + } else if (extension && !iframeExtensions.includes(extension)) { + nodeTag = 'embed'; + } + + return new MediaNode(nodeTag); +} + export function $isMediaNode(node: LexicalNode | null | undefined) { return node instanceof MediaNode; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index cd36f359e..2125aa258 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,8 @@ ## In progress -- Finish initial media node & form integration +- Update forms to allow panels (Media) + - Will be used for table forms also. ## Main Todo diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index e0459b5c5..a2242c338 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -1,15 +1,17 @@ import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms"; import {EditorUiContext} from "../framework/core"; import {$createLinkNode} from "@lexical/link"; -import {$createTextNode, $getSelection} from "lexical"; +import {$createTextNode, $getSelection, LexicalNode} from "lexical"; import {$createImageNode} from "../../nodes/image"; import {setEditorContentFromHtml} from "../../actions"; -import {$createMediaNodeFromHtml} from "../../nodes/media"; +import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../nodes/media"; +import {$getNodeFromSelection} from "../../helpers"; +import {$insertNodeToNearestRoot} from "@lexical/utils"; export const link: EditorFormDefinition = { submitText: 'Apply', - action(formData, context: EditorUiContext) { + async action(formData, context: EditorUiContext) { context.editor.update(() => { const selection = $getSelection(); @@ -54,7 +56,7 @@ export const link: EditorFormDefinition = { export const image: EditorFormDefinition = { submitText: 'Apply', - action(formData, context: EditorUiContext) { + async action(formData, context: EditorUiContext) { context.editor.update(() => { const selection = $getSelection(); const imageNode = $createImageNode(formData.get('src')?.toString() || '', { @@ -92,25 +94,40 @@ export const image: EditorFormDefinition = { export const media: EditorFormDefinition = { submitText: 'Save', - action(formData, context: EditorUiContext) { - - // TODO - Get media from selection + async action(formData, context: EditorUiContext) { + const selectedNode: MediaNode|null = await (new Promise((res, rej) => { + context.editor.getEditorState().read(() => { + const node = $getNodeFromSelection($getSelection(), $isMediaNode); + res(node as MediaNode|null); + }); + })); const embedCode = (formData.get('embed') || '').toString().trim(); if (embedCode) { context.editor.update(() => { const node = $createMediaNodeFromHtml(embedCode); - // TODO - Replace existing or insert new + if (selectedNode && node) { + selectedNode.replace(node) + } else if (node) { + $insertNodeToNearestRoot(node); + } }); return true; } - const src = (formData.get('src') || '').toString().trim(); - const height = (formData.get('height') || '').toString().trim(); - const width = (formData.get('width') || '').toString().trim(); + context.editor.update(() => { + const src = (formData.get('src') || '').toString().trim(); + const height = (formData.get('height') || '').toString().trim(); + const width = (formData.get('width') || '').toString().trim(); - // TODO - Update existing or insert new + const updateNode = selectedNode || $createMediaNodeFromSrc(src); + updateNode.setSrc(src); + updateNode.setWidthAndHeight(width, height); + if (!selectedNode) { + $insertNodeToNearestRoot(updateNode); + } + }); return true; }, @@ -141,7 +158,7 @@ export const media: EditorFormDefinition = { export const source: EditorFormDefinition = { submitText: 'Save', - action(formData, context: EditorUiContext) { + async action(formData, context: EditorUiContext) { setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); return true; }, diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 4fee787d3..b641f993b 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -14,7 +14,7 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti export interface EditorFormDefinition { submitText: string; - action: (formData: FormData, context: EditorUiContext) => boolean; + action: (formData: FormData, context: EditorUiContext) => Promise; fields: EditorFormFieldDefinition[]; } From ce8c9dd079ec354525bab57c852e8984f08ce25c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 28 Jul 2024 12:48:58 +0100 Subject: [PATCH 055/107] Lexical: Added form complex/tab ui support --- resources/js/wysiwyg/todo.md | 4 +- .../wysiwyg/ui/defaults/form-definitions.ts | 55 ++++--- resources/js/wysiwyg/ui/framework/core.ts | 8 ++ resources/js/wysiwyg/ui/framework/forms.ts | 134 ++++++++++++++++-- resources/sass/_editor.scss | 39 +++++ 5 files changed, 209 insertions(+), 31 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 2125aa258..49f685bea 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,8 +2,7 @@ ## In progress -- Update forms to allow panels (Media) - - Will be used for table forms also. +// ## Main Todo @@ -23,6 +22,7 @@ - Image gallery integration for form - Drawing gallery integration - 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) ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index a2242c338..6c0a54f23 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -1,4 +1,4 @@ -import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms"; +import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../framework/forms"; import {EditorUiContext} from "../framework/core"; import {$createLinkNode} from "@lexical/link"; import {$createTextNode, $getSelection, LexicalNode} from "lexical"; @@ -133,25 +133,40 @@ export const media: EditorFormDefinition = { }, fields: [ { - label: 'Source', - name: 'src', - type: 'text', - }, - { - label: 'Width', - name: 'width', - type: 'text', - }, - { - label: 'Height', - name: 'height', - type: 'text', - }, - // TODO - Tabbed interface to separate this option - { - label: 'Paste your embed code below:', - name: 'embed', - type: 'textarea', + build() { + return new EditorFormTabs([ + { + label: 'General', + contents: [ + { + label: 'Source', + name: 'src', + type: 'text', + }, + { + label: 'Width', + name: 'width', + type: 'text', + }, + { + label: 'Height', + name: 'height', + type: 'text', + }, + ], + }, + { + label: 'Embed', + contents: [ + { + label: 'Paste your embed code below:', + name: 'embed', + type: 'textarea', + }, + ], + } + ]) + } }, ], }; diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index c8f390c48..f644bc37a 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -18,6 +18,14 @@ export type EditorUiContext = { options: Record; // General user options which may be used by sub elements }; +export interface EditorUiBuilderDefinition { + build: () => EditorUiElement; +} + +export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDefinition { + return 'build' in object; +} + export abstract class EditorUiElement { protected dom: HTMLElement|null = null; private context: EditorUiContext|null = null; diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index b641f993b..b225a3de2 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -1,5 +1,12 @@ -import {EditorUiContext, EditorUiElement, EditorContainerUiElement} from "./core"; +import { + EditorUiContext, + EditorUiElement, + EditorContainerUiElement, + EditorUiBuilderDefinition, + isUiBuilderDefinition +} from "./core"; import {el} from "../../helpers"; +import {uniqueId} from "../../../services/util"; export interface EditorFormFieldDefinition { label: string; @@ -12,10 +19,15 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti valuesByLabel: Record } +interface EditorFormTabDefinition { + label: string; + contents: EditorFormFieldDefinition[]; +} + export interface EditorFormDefinition { submitText: string; action: (formData: FormData, context: EditorUiContext) => Promise; - fields: EditorFormFieldDefinition[]; + fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; } export class EditorFormField extends EditorUiElement { @@ -62,7 +74,14 @@ export class EditorForm extends EditorContainerUiElement { protected onCancel: null|(() => void) = null; constructor(definition: EditorFormDefinition) { - super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition))); + let children: (EditorFormField|EditorUiElement)[] = definition.fields.map(fieldDefinition => { + if (isUiBuilderDefinition(fieldDefinition)) { + return fieldDefinition.build(); + } + return new EditorFormField(fieldDefinition) + }); + + super(children); this.definition = definition; } @@ -80,13 +99,23 @@ export class EditorForm extends EditorContainerUiElement { } protected getFieldByName(name: string): EditorFormField|null { - for (const child of this.children as EditorFormField[]) { - if (child.getName() === name) { - return child; - } - } - return null; + const search = (children: EditorUiElement[]): EditorFormField|null => { + for (const child of children) { + if (child instanceof EditorFormField && child.getName() === name) { + return child; + } else if (child instanceof EditorContainerUiElement) { + const matchingChild = search(child.getChildren()); + if (matchingChild) { + return matchingChild; + } + } + } + + return null; + }; + + return search(this.getChildren()); } protected buildDOM(): HTMLElement { @@ -113,4 +142,91 @@ export class EditorForm extends EditorContainerUiElement { return form; } +} + +export class EditorFormTab extends EditorContainerUiElement { + + protected definition: EditorFormTabDefinition; + protected fields: EditorFormField[]; + protected id: string; + + constructor(definition: EditorFormTabDefinition) { + const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef)); + super(fields); + + this.definition = definition; + this.fields = fields; + this.id = uniqueId(); + } + + public getLabel(): string { + return this.getContext().translate(this.definition.label); + } + + public getId(): string { + return this.id; + } + + protected buildDOM(): HTMLElement { + return el( + 'div', + { + class: 'editor-form-tab-content', + role: 'tabpanel', + id: `editor-tabpanel-${this.id}`, + 'aria-labelledby': `editor-tab-${this.id}`, + }, + this.fields.map(f => f.getDOMElement()) + ); + } +} +export class EditorFormTabs extends EditorContainerUiElement { + + protected definitions: EditorFormTabDefinition[] = []; + protected tabs: EditorFormTab[] = []; + + constructor(definitions: EditorFormTabDefinition[]) { + const tabs: EditorFormTab[] = definitions.map(d => new EditorFormTab(d)); + super(tabs); + + this.definitions = definitions; + this.tabs = tabs; + } + + protected buildDOM(): HTMLElement { + const controls: HTMLElement[] = []; + const contents: HTMLElement[] = []; + + const selectTab = (tabIndex: number) => { + for (let i = 0; i < controls.length; i++) { + controls[i].setAttribute('aria-selected', (i === tabIndex) ? 'true' : 'false'); + } + for (let i = 0; i < contents.length; i++) { + contents[i].hidden = !(i === tabIndex); + } + }; + + for (const tab of this.tabs) { + const button = el('button', { + class: 'editor-form-tab-control', + type: 'button', + role: 'tab', + id: `editor-tab-${tab.getId()}`, + 'aria-controls': `editor-tabpanel-${tab.getId()}` + }, [tab.getLabel()]); + contents.push(tab.getDOMElement()); + controls.push(button); + + button.addEventListener('click', event => { + selectTab(controls.indexOf(button)); + }); + } + + selectTab(0); + + return el('div', {class: 'editor-form-tab-container'}, [ + el('div', {class: 'editor-form-tab-controls'}, controls), + el('div', {class: 'editor-form-tab-contents'}, contents), + ]); + } } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 17e4af97b..1e52ad6a9 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -398,6 +398,45 @@ textarea.editor-form-field-input { box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1); } } +.editor-form-tab-container { + display: flex; + flex-direction: row; + gap: 2rem; +} +.editor-form-tab-controls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: .25rem; +} +.editor-form-tab-control { + font-weight: bold; + font-size: 14px; + color: #444; + border-bottom: 2px solid transparent; + position: relative; + cursor: pointer; + padding: .25rem .5rem; + text-align: start; + &[aria-selected="true"] { + border-color: var(--editor-color-primary); + color: var(--editor-color-primary); + } + &[aria-selected="true"]:after, &:hover:after { + background-color: var(--editor-color-primary); + opacity: .15; + content: ''; + display: block; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + } +} +.editor-form-tab-contents { + width: 360px; +} // Editor theme styles .editor-theme-bold { From 9a7edc6e52467501832202732dec2e61a515aae0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 Jul 2024 15:27:41 +0100 Subject: [PATCH 056/107] Lexical: Started drop handling, handled templates --- resources/js/wysiwyg/actions.ts | 43 ++++------------ resources/js/wysiwyg/drop-handling.ts | 71 +++++++++++++++++++++++++++ resources/js/wysiwyg/helpers.ts | 61 +++++++++++++++++++++-- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/todo.md | 4 +- 5 files changed, 141 insertions(+), 40 deletions(-) create mode 100644 resources/js/wysiwyg/drop-handling.ts diff --git a/resources/js/wysiwyg/actions.ts b/resources/js/wysiwyg/actions.ts index 0e2202525..a3d2f0ef6 100644 --- a/resources/js/wysiwyg/actions.ts +++ b/resources/js/wysiwyg/actions.ts @@ -1,30 +1,10 @@ -import {$getRoot, $getSelection, $isTextNode, LexicalEditor, LexicalNode, RootNode} from "lexical"; -import {$generateHtmlFromNodes, $generateNodesFromDOM} from "@lexical/html"; -import {$createCustomParagraphNode} from "./nodes/custom-paragraph"; +import {$getRoot, $getSelection, LexicalEditor} from "lexical"; +import {$generateHtmlFromNodes} from "@lexical/html"; +import {$htmlToBlockNodes} from "./helpers"; -function htmlToDom(html: string): Document { - const parser = new DOMParser(); - return parser.parseFromString(html, 'text/html'); -} -function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { - return nodes.map(node => { - if ($isTextNode(node)) { - const paragraph = $createCustomParagraphNode(); - paragraph.append(node); - return paragraph; - } - return node; - }); -} - -function appendNodesToRoot(root: RootNode, nodes: LexicalNode[]) { - root.append(...wrapTextNodes(nodes)); -} export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { - const dom = htmlToDom(html); - editor.update(() => { // Empty existing const root = $getRoot(); @@ -32,27 +12,23 @@ export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { child.remove(true); } - const nodes = $generateNodesFromDOM(editor, dom); - root.append(...wrapTextNodes(nodes)); + const nodes = $htmlToBlockNodes(editor, html); + root.append(...nodes); }); } export function appendHtmlToEditor(editor: LexicalEditor, html: string) { - const dom = htmlToDom(html); - editor.update(() => { const root = $getRoot(); - const nodes = $generateNodesFromDOM(editor, dom); - root.append(...wrapTextNodes(nodes)); + const nodes = $htmlToBlockNodes(editor, html); + root.append(...nodes); }); } export function prependHtmlToEditor(editor: LexicalEditor, html: string) { - const dom = htmlToDom(html); - editor.update(() => { const root = $getRoot(); - const nodes = wrapTextNodes($generateNodesFromDOM(editor, dom)); + const nodes = $htmlToBlockNodes(editor, html); let reference = root.getChildren()[0]; for (let i = nodes.length - 1; i >= 0; i--) { if (reference) { @@ -66,10 +42,9 @@ export function prependHtmlToEditor(editor: LexicalEditor, html: string) { } export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) { - const dom = htmlToDom(html); editor.update(() => { const selection = $getSelection(); - const nodes = wrapTextNodes($generateNodesFromDOM(editor, dom)); + const nodes = $htmlToBlockNodes(editor, html); const reference = selection?.getNodes()[0]; const referencesParents = reference?.getParents() || []; diff --git a/resources/js/wysiwyg/drop-handling.ts b/resources/js/wysiwyg/drop-handling.ts new file mode 100644 index 000000000..92dc758d8 --- /dev/null +++ b/resources/js/wysiwyg/drop-handling.ts @@ -0,0 +1,71 @@ +import { + $getNearestNodeFromDOMNode, + $getRoot, + $insertNodes, + $isDecoratorNode, + LexicalEditor, + LexicalNode +} from "lexical"; +import { + $getNearestBlockNodeForCoords, + $htmlToBlockNodes, + $insertNewBlockNodeAtSelection, $insertNewBlockNodesAtSelection, + $selectSingleNode +} from "./helpers"; + +function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null { + const x = event.clientX; + const y = event.clientY; + const dom = document.elementFromPoint(x, y); + if (!dom) { + return null; + } + + return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY); +} + +function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) { + const positionNode = $getNodeFromMouseEvent(event, editor); + + if (positionNode) { + $selectSingleNode(positionNode); + } + + $insertNewBlockNodesAtSelection(nodes, true); + + if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) { + positionNode?.remove(); + } +} + +async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, event: DragEvent) { + const resp = await window.$http.get(`/templates/${templateId}`); + const data = (resp.data || {html: ''}) as {html: string} + const html: string = data.html || ''; + + editor.update(() => { + const newNodes = $htmlToBlockNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); +} + +function createDropListener(editor: LexicalEditor): (event: DragEvent) => void { + return (event: DragEvent) => { + // Template handling + const templateId = event.dataTransfer?.getData('bookstack/template') || ''; + if (templateId) { + event.preventDefault(); + insertTemplateToEditor(editor, templateId, event); + return; + } + }; +} + +export function handleDropEvents(editor: LexicalEditor) { + const dropListener = createDropListener(editor); + + editor.registerRootListener((rootElement, prevRootElement) => { + rootElement?.addEventListener('drop', dropListener); + prevRootElement?.removeEventListener('drop', dropListener); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 6a55c429c..07755f449 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -3,12 +3,14 @@ import { $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isTextNode, $setSelection, - BaseSelection, ElementFormatType, ElementNode, + BaseSelection, ElementFormatType, ElementNode, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; +import {$createCustomParagraphNode} from "./nodes/custom-paragraph"; +import {$generateNodesFromDOM} from "@lexical/html"; export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { const el = document.createElement(tag); @@ -30,6 +32,28 @@ export function el(tag: string, attrs: Record = {}, childre return el; } +function htmlToDom(html: string): Document { + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +} + +function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { + return nodes.map(node => { + if ($isTextNode(node)) { + const paragraph = $createCustomParagraphNode(); + paragraph.append(node); + return paragraph; + } + return node; + }); +} + +export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] { + const dom = htmlToDom(html); + const nodes = $generateNodesFromDOM(editor, dom); + return wrapTextNodes(nodes); +} + export function $selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { return $getNodeFromSelection(selection, matcher) !== null; } @@ -88,17 +112,25 @@ export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creat } export function $insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: boolean = true) { + $insertNewBlockNodesAtSelection([node], insertAfter); +} + +export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfter: boolean = true) { const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; if (blockElement) { if (insertAfter) { - blockElement.insertAfter(node); + for (let i = nodes.length - 1; i >= 0; i--) { + blockElement.insertAfter(nodes[i]); + } } else { - blockElement.insertBefore(node); + for (const node of nodes) { + blockElement.insertBefore(node); + } } } else { - $getRoot().append(node); + $getRoot().append(...nodes); } } @@ -151,4 +183,25 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection|null): } return Array.from(blockNodes.values()); +} + +/** + * Get the nearest root/block level node for the given position. + */ +export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, y: number): LexicalNode|null { + // TODO - Take into account x for floated blocks? + const rootNodes = $getRoot().getChildren(); + for (const node of rootNodes) { + const nodeDom = editor.getElementByKey(node.__key); + if (!nodeDom) { + continue; + } + + const bounds = nodeDom.getBoundingClientRect(); + if (y <= bounds.bottom) { + return node; + } + } + + return null; } \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index e53b9b057..fee536572 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -9,6 +9,7 @@ import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {el} from "./helpers"; import {EditorUiContext} from "./ui/framework/core"; import {listen as listenToCommonEvents} from "./common-events"; +import {handleDropEvents} from "./drop-handling"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -49,6 +50,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st ); listenToCommonEvents(editor); + handleDropEvents(editor); setEditorContentFromHtml(editor, htmlContent); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 49f685bea..5d495e7d8 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -13,7 +13,6 @@ - Keyboard shortcuts support - Draft/change management (connect with page editor component) - Add ID support to all block types -- Template drag & drop / insert - Video attachment drop / insert - Task list render/import from existing format - Link popup menu for cross-content reference @@ -28,4 +27,5 @@ - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link -- `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. \ No newline at end of file +- `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. +- Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file From d86837ac07fdb5e68b02a57ca6d75fd6047bdfee Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 Jul 2024 21:14:42 +0100 Subject: [PATCH 057/107] Lexical: Got working with attachment insert/drop --- resources/js/wysiwyg/drop-handling.ts | 18 +++++++++++++----- resources/js/wysiwyg/todo.md | 4 +--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/resources/js/wysiwyg/drop-handling.ts b/resources/js/wysiwyg/drop-handling.ts index 92dc758d8..5d541365a 100644 --- a/resources/js/wysiwyg/drop-handling.ts +++ b/resources/js/wysiwyg/drop-handling.ts @@ -1,7 +1,4 @@ import { - $getNearestNodeFromDOMNode, - $getRoot, - $insertNodes, $isDecoratorNode, LexicalEditor, LexicalNode @@ -9,7 +6,7 @@ import { import { $getNearestBlockNodeForCoords, $htmlToBlockNodes, - $insertNewBlockNodeAtSelection, $insertNewBlockNodesAtSelection, + $insertNewBlockNodesAtSelection, $selectSingleNode } from "./helpers"; @@ -54,8 +51,19 @@ function createDropListener(editor: LexicalEditor): (event: DragEvent) => void { // Template handling const templateId = event.dataTransfer?.getData('bookstack/template') || ''; if (templateId) { - event.preventDefault(); insertTemplateToEditor(editor, templateId, event); + event.preventDefault(); + return; + } + + // HTML contents drop + const html = event.dataTransfer?.getData('text/html') || ''; + if (html) { + editor.update(() => { + const newNodes = $htmlToBlockNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); + event.preventDefault(); return; } }; diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 5d495e7d8..73521df9b 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -// +- Draft/change management (connect with page editor component) ## Main Todo @@ -11,9 +11,7 @@ - Table features - Image paste upload - Keyboard shortcuts support -- Draft/change management (connect with page editor component) - Add ID support to all block types -- Video attachment drop / insert - Task list render/import from existing format - Link popup menu for cross-content reference - Link heading-based ID reference menu From fe05cff64f87681c796bd8c0cd8a7f0de2ac8874 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 Jul 2024 21:43:20 +0100 Subject: [PATCH 058/107] Lexical: Linked up change/draft management --- resources/js/wysiwyg/index.ts | 17 +++++++++++++++-- resources/js/wysiwyg/todo.md | 2 +- resources/js/wysiwyg/ui/framework/manager.ts | 1 - 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index fee536572..4130f41e8 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -58,8 +58,21 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st if (debugView) { debugView.hidden = true; } - editor.registerUpdateListener(({editorState}) => { - console.log('editorState', editorState.toJSON()); + + let changeFromLoading = true; + editor.registerUpdateListener(({editorState, dirtyElements, dirtyLeaves}) => { + + // Emit change event to component system (for draft detection) on actual user content change + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { + if (changeFromLoading) { + changeFromLoading = false; + } else { + window.$events.emit('editor-html-change', ''); + } + } + + // Debug logic + // console.log('editorState', editorState.toJSON()); if (debugView) { debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 73521df9b..5e6cdd2cc 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Draft/change management (connect with page editor component) +// ## Main Todo diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index c0357c3ea..29d959910 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -175,7 +175,6 @@ export class EditorUIManager { protected setupEditor(editor: LexicalEditor) { // Update button states on editor selection change editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { - console.log('select change', arguments); this.triggerStateUpdate({ editor: editor, selection: $getSelection(), From 13f8f39dd5b77644839c092d10a0ad549b19600a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 30 Jul 2024 14:42:19 +0100 Subject: [PATCH 059/107] Lexical: Updated task list to use/support old format --- resources/js/wysiwyg/index.ts | 2 + .../js/wysiwyg/nodes/custom-list-item.ts | 92 +++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 9 +- resources/js/wysiwyg/todo.md | 1 - .../ui/framework/helpers/task-list-handler.ts | 59 ++++++++++++ resources/sass/_editor.scss | 31 +++++++ 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/custom-list-item.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 4130f41e8..1e9dd25df 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -10,6 +10,7 @@ import {el} from "./helpers"; 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"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -47,6 +48,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerTableResizer(editor, editWrap), + registerTaskListHandler(editor, editArea), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts new file mode 100644 index 000000000..53467e10b --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -0,0 +1,92 @@ +import {$isListNode, ListItemNode, ListNode, SerializedListItemNode} from "@lexical/list"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; +import {el} from "../helpers"; + +function updateListItemChecked( + dom: HTMLElement, + listItemNode: ListItemNode, +): void { + // Only set task list attrs for leaf list items + const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild()); + dom.classList.toggle('task-list-item', shouldBeTaskItem); + if (listItemNode.__checked) { + dom.setAttribute('checked', 'checked'); + } else { + dom.removeAttribute('checked'); + } +} + + +export class CustomListItemNode extends ListItemNode { + static getType(): string { + return 'custom-list-item'; + } + + static clone(node: CustomListItemNode): CustomListItemNode { + return new CustomListItemNode(node.__value, node.__checked, node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('li'); + const parent = this.getParent(); + + if ($isListNode(parent) && parent.getListType() === 'check') { + updateListItemChecked(element, this); + } + + element.value = this.__value; + + return element; + } + + updateDOM( + prevNode: ListItemNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + const parent = this.getParent(); + if ($isListNode(parent) && parent.getListType() === 'check') { + updateListItemChecked(dom, this); + } + // @ts-expect-error - this is always HTMLListItemElement + dom.value = this.__value; + + return false; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config); + element.style.textAlign = this.getFormatType(); + + if (element.classList.contains('task-list-item')) { + const input = el('input', { + type: 'checkbox', + disabled: 'disabled', + }); + if (element.hasAttribute('checked')) { + input.setAttribute('checked', 'checked'); + element.removeAttribute('checked'); + } + + element.prepend(input); + } + + return { + element, + }; + } + + exportJSON(): SerializedListItemNode { + return { + ...super.exportJSON(), + type: 'custom-list-item', + }; + } +} + +export function $isCustomListItemNode( + node: LexicalNode | null | undefined, +): node is CustomListItemNode { + return node instanceof CustomListItemNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 669ffe6dd..f0df08fcb 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -19,6 +19,7 @@ import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; import {EditorUiContext} from "../ui/framework/core"; import {MediaNode} from "./media"; +import {CustomListItemNode} from "./custom-list-item"; /** * Load the nodes for lexical. @@ -29,7 +30,7 @@ export function getNodesForPageEditor(): (KlassConstructor | HeadingNode, // Todo - Create custom QuoteNode, // Todo - Create custom ListNode, // Todo - Create custom - ListItemNode, + CustomListItemNode, CustomTableNode, TableRowNode, TableCellNode, @@ -53,6 +54,12 @@ export function getNodesForPageEditor(): (KlassConstructor | return new CustomTableNode(); } }, + { + replace: ListItemNode, + with: (node: ListItemNode) => { + return new CustomListItemNode(node.__value, node.__checked); + } + } ]; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 5e6cdd2cc..dda05f1da 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -12,7 +12,6 @@ - Image paste upload - Keyboard shortcuts support - Add ID support to all block types -- Task list render/import from existing format - Link popup menu for cross-content reference - Link heading-based ID reference menu - Image gallery integration for insert diff --git a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts new file mode 100644 index 000000000..da8c0eae3 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts @@ -0,0 +1,59 @@ +import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; +import {$isCustomListItemNode} from "../../../nodes/custom-list-item"; + +class TaskListHandler { + protected editorContainer: HTMLElement; + protected editor: LexicalEditor; + + constructor(editor: LexicalEditor, editorContainer: HTMLElement) { + this.editor = editor; + this.editorContainer = editorContainer; + this.setupListeners(); + } + + protected setupListeners() { + this.handleClick = this.handleClick.bind(this); + this.editorContainer.addEventListener('click', this.handleClick); + } + + handleClick(event: MouseEvent) { + const target = event.target; + if (target instanceof HTMLElement && target.nodeName === 'LI' && target.classList.contains('task-list-item')) { + this.handleTaskListItemClick(target, event); + event.preventDefault(); + } + } + + handleTaskListItemClick(listItem: HTMLElement, event: MouseEvent) { + const bounds = listItem.getBoundingClientRect(); + const withinBounds = event.clientX <= bounds.right + && event.clientX >= bounds.left + && event.clientY >= bounds.top + && event.clientY <= bounds.bottom; + + // Outside task list item bounds means we're probably clicking the pseudo-element + if (withinBounds) { + return; + } + + this.editor.update(() => { + const node = $getNearestNodeFromDOMNode(listItem); + if ($isCustomListItemNode(node)) { + node.setChecked(!node.getChecked()); + } + }); + } + + teardown() { + this.editorContainer.removeEventListener('click', this.handleClick); + } +} + + +export function registerTaskListHandler(editor: LexicalEditor, editorContainer: HTMLElement): (() => void) { + const handler = new TaskListHandler(editor, editorContainer); + + return () => { + handler.teardown(); + }; +} \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 1e52ad6a9..4ffff3cc0 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -324,6 +324,37 @@ body.editor-is-fullscreen { outline: 2px dashed var(--editor-color-primary); } +/** + * Fake task list checkboxes + */ +.editor-content-area .task-list-item { + margin-left: 0; + position: relative; +} +.editor-content-area .task-list-item > input[type="checkbox"] { + display: none; +} +.editor-content-area .task-list-item:before { + content: ''; + display: inline-block; + border: 2px solid #CCC; + width: 12px; + height: 12px; + border-radius: 2px; + margin-right: 8px; + vertical-align: text-top; + cursor: pointer; + position: absolute; + left: -24px; + top: 4px; +} +.editor-content-area .task-list-item[checked]:before { + background-color: #CCC; + background-image: url('data:image/svg+xml;utf8,'); + background-position: 50% 50%; + background-size: 100% 100%; +} + // Editor form elements .editor-form-field-wrapper { margin-bottom: .5rem; From 6b06d490c54fb8e2950236655fc3168e922982a6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 2 Aug 2024 11:16:54 +0100 Subject: [PATCH 060/107] Lexical: Started table menu options Updated UI elements to handle new scenarios needed in more complex table menu --- .../js/wysiwyg/nodes/custom-list-item.ts | 2 +- resources/js/wysiwyg/todo.md | 5 +-- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 27 ++++++++++----- .../ui/framework/blocks/dropdown-button.ts | 28 +++++++++++----- .../ui/framework/blocks/format-menu.ts | 2 +- .../ui/framework/blocks/overflow-container.ts | 8 +++-- resources/js/wysiwyg/ui/framework/buttons.ts | 33 ++++++++++++++----- resources/js/wysiwyg/ui/toolbars.ts | 20 +++++++---- resources/sass/_editor.scss | 19 ++++++++--- 9 files changed, 101 insertions(+), 43 deletions(-) diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts index 53467e10b..21b9f6d5f 100644 --- a/resources/js/wysiwyg/nodes/custom-list-item.ts +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -1,4 +1,4 @@ -import {$isListNode, ListItemNode, ListNode, SerializedListItemNode} from "@lexical/list"; +import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list"; import {EditorConfig} from "lexical/LexicalEditor"; import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; import {el} from "../helpers"; diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index dda05f1da..1a367d0dd 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,13 +2,14 @@ ## In progress -// +- Table features + - Continued table dropdown menu ## Main Todo - Alignments: Use existing classes for blocks - Alignments: Handle inline block content (image, video) -- Table features + - Image paste upload - Keyboard shortcuts support - Add ID support to all block types diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 32fa49f88..b6b92e197 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -8,17 +8,18 @@ import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; -import {$getBlockElementNodesInSelection, $getNodeFromSelection, $getParentOfType} from "../../../helpers"; -import {$getSelection} from "lexical"; -import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table"; import { - $deleteTableColumn, $deleteTableColumn__EXPERIMENTAL, + $getNodeFromSelection, + $selectionContainsNodeType +} from "../../../helpers"; +import {$getSelection} from "lexical"; +import {$isCustomTableNode} from "../../../nodes/custom-table"; +import { + $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, - $getTableRowIndexFromTableCellNode, $insertTableColumn, $insertTableColumn__EXPERIMENTAL, - $insertTableRow, $insertTableRow__EXPERIMENTAL, - $isTableCellNode, - $isTableRowNode, - TableCellNode + $insertTableColumn__EXPERIMENTAL, + $insertTableRow__EXPERIMENTAL, + $isTableNode, } from "@lexical/table"; @@ -43,6 +44,14 @@ export const deleteTable: EditorButtonDefinition = { } }; +export const deleteTableMenuAction: EditorButtonDefinition = { + ...deleteTable, + format: 'long', + isDisabled(selection) { + return !$selectionContainsNodeType(selection, $isTableNode); + }, +}; + export const insertRowAbove: EditorButtonDefinition = { label: 'Insert row above', icon: insertRowAboveIcon, diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index a75cf64fe..24659b546 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -3,22 +3,34 @@ import {handleDropdown} from "../helpers/dropdowns"; import {EditorContainerUiElement, EditorUiElement} from "../core"; import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; +export type EditorDropdownButtonOptions = { + showOnHover?: boolean; + direction?: 'vertical'|'horizontal'; + button: EditorBasicButtonDefinition|EditorButton; +}; + +const defaultOptions: EditorDropdownButtonOptions = { + showOnHover: false, + direction: 'horizontal', + button: {label: 'Menu'}, +} + export class EditorDropdownButton extends EditorContainerUiElement { protected button: EditorButton; protected childItems: EditorUiElement[]; protected open: boolean = false; - protected showOnHover: boolean = false; + protected options: EditorDropdownButtonOptions; - constructor(button: EditorBasicButtonDefinition|EditorButton, showOnHover: boolean, children: EditorUiElement[]) { + constructor(options: EditorDropdownButtonOptions, children: EditorUiElement[]) { super(children); this.childItems = children; - this.showOnHover = showOnHover; + this.options = Object.assign(defaultOptions, options); - if (button instanceof EditorButton) { - this.button = button; + if (options.button instanceof EditorButton) { + this.button = options.button; } else { this.button = new EditorButton({ - ...button, + ...options.button, action() { return false; }, @@ -41,7 +53,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { const childElements: HTMLElement[] = this.childItems.map(child => child.getDOMElement()); const menu = el('div', { - class: 'editor-dropdown-menu', + class: `editor-dropdown-menu editor-dropdown-menu-${this.options.direction}`, hidden: 'true', }, childElements); @@ -50,7 +62,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { }, [button, menu]); handleDropdown({toggle : button, menu : menu, - showOnHover: this.showOnHover, + showOnHover: this.options.showOnHover, onOpen : () => { this.open = true; this.getContext().manager.triggerStateUpdateForElement(this.button); diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts index 52a9c3809..b0834fe4d 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -7,7 +7,7 @@ export class EditorFormatMenu extends EditorContainerUiElement { buildDOM(): HTMLElement { const childElements: HTMLElement[] = this.getChildren().map(child => child.getDOMElement()); const menu = el('div', { - class: 'editor-format-menu-dropdown editor-dropdown-menu editor-menu-list', + class: 'editor-format-menu-dropdown editor-dropdown-menu editor-dropdown-menu-vertical', hidden: 'true', }, childElements); diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts index 83f394d9d..108992db8 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -15,9 +15,11 @@ export class EditorOverflowContainer extends EditorContainerUiElement { this.size = size; this.content = children; this.overflowButton = new EditorDropdownButton({ - label: 'More', - icon: moreHorizontal, - }, false, []); + button: { + label: 'More', + icon: moreHorizontal, + }, + }, []); this.addChildren(this.overflowButton); } diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 4418be623..9a23edfb7 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -5,11 +5,13 @@ import {el} from "../../helpers"; export interface EditorBasicButtonDefinition { label: string; icon?: string|undefined; + format?: 'small' | 'long'; } export interface EditorButtonDefinition extends EditorBasicButtonDefinition { action: (context: EditorUiContext, button: EditorButton) => void; isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean; + isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean; setup?: (context: EditorUiContext, button: EditorButton) => void; } @@ -47,20 +49,27 @@ export class EditorButton extends EditorUiElement { } protected buildDOM(): HTMLButtonElement { - const label = this.getLabel(); - let child: string|HTMLElement = label; - if (this.definition.icon) { - child = el('div', {class: 'editor-button-icon'}); - child.innerHTML = this.definition.icon; + const format = this.definition.format || 'small'; + const children: (string|HTMLElement)[] = []; + + if (this.definition.icon || format === 'long') { + const icon = el('div', {class: 'editor-button-icon'}); + icon.innerHTML = this.definition.icon || ''; + children.push(icon); + } + + if (!this.definition.icon ||format === 'long') { + const text = el('div', {class: 'editor-button-text'}, [label]); + children.push(text); } const button = el('button', { type: 'button', - class: 'editor-button', + class: `editor-button editor-button-${format}`, title: this.definition.icon ? label : null, disabled: this.disabled ? 'true' : null, - }, [child]) as HTMLButtonElement; + }, children) as HTMLButtonElement; button.addEventListener('click', this.onClick.bind(this)); @@ -71,11 +80,18 @@ export class EditorButton extends EditorUiElement { this.definition.action(this.getContext(), this); } - updateActiveState(selection: BaseSelection|null) { + protected updateActiveState(selection: BaseSelection|null) { const isActive = this.definition.isActive(selection, this.getContext()); this.setActiveState(isActive); } + protected updateDisabledState(selection: BaseSelection|null) { + if (this.definition.isDisabled) { + const isDisabled = this.definition.isDisabled(selection, this.getContext()); + this.toggleDisabled(isDisabled); + } + } + setActiveState(active: boolean) { this.active = active; this.dom?.classList.toggle('editor-button-active', this.active); @@ -83,6 +99,7 @@ export class EditorButton extends EditorUiElement { updateState(state: EditorUiStateUpdate): void { this.updateActiveState(state.selection); + this.updateDisabledState(state.selection); } isActive(): boolean { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index ae6a292a2..d2b179eb6 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,6 +1,6 @@ import {EditorButton} from "./framework/buttons"; import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core"; -import {el} from "../helpers"; +import {$selectionContainsNodeType, el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; @@ -11,7 +11,7 @@ import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; import { deleteColumn, deleteRow, - deleteTable, insertColumnAfter, + deleteTable, deleteTableMenuAction, insertColumnAfter, insertColumnBefore, insertRowAbove, insertRowBelow, @@ -50,6 +50,7 @@ import { link, media, unlink } from "./defaults/buttons/objects"; +import {$isTableNode} from "@lexical/table"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -68,7 +69,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new FormatPreviewButton(el('h5'), h5), new FormatPreviewButton(el('blockquote'), blockquote), new FormatPreviewButton(el('p'), paragraph), - new EditorDropdownButton({label: 'Callouts'}, true, [ + new EditorDropdownButton({button: {label: 'Callouts'}, showOnHover: true, direction: 'vertical'}, [ new FormatPreviewButton(el('p', {class: 'callout info'}), infoCallout), new FormatPreviewButton(el('p', {class: 'callout success'}), successCallout), new FormatPreviewButton(el('p', {class: 'callout warning'}), warningCallout), @@ -81,10 +82,10 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(bold), new EditorButton(italic), new EditorButton(underline), - new EditorDropdownButton(new EditorColorButton(textColor, 'color'), false, [ + new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [ new EditorColorPicker('color'), ]), - new EditorDropdownButton(new EditorColorButton(highlightColor, 'background-color'), false, [ + new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [ new EditorColorPicker('background-color'), ]), new EditorButton(strikethrough), @@ -112,9 +113,14 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { // Insert types new EditorOverflowContainer(8, [ new EditorButton(link), - new EditorDropdownButton(table, false, [ - new EditorTableCreator(), + + new EditorDropdownButton({button: table, direction: 'vertical'}, [ + new EditorDropdownButton({button: {...table, format: 'long'}, showOnHover: true}, [ + new EditorTableCreator(), + ]), + new EditorButton(deleteTableMenuAction), ]), + new EditorButton(image), new EditorButton(horizontalRule), new EditorButton(codeBlock), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 4ffff3cc0..0cf145559 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -59,6 +59,18 @@ body.editor-is-fullscreen { background-color: #ceebff; color: #000; } +.editor-button-long { + display: flex !important; + flex-direction: row; + align-items: center; + justify-content: start; + gap: .5rem; +} +.editor-button-text { + font-weight: 400; + color: #000; + font-size: 12.2px; +} .editor-button-format-preview { padding: 4px 6px; display: block; @@ -84,21 +96,20 @@ body.editor-is-fullscreen { display: flex; flex-direction: row; } -.editor-menu-list { +.editor-dropdown-menu-vertical { display: flex; flex-direction: column; align-items: stretch; } -.editor-menu-list .editor-button { +.editor-dropdown-menu-vertical .editor-button { border-bottom: 0; text-align: start; display: block; width: 100%; } -.editor-menu-list > .editor-dropdown-menu-container .editor-dropdown-menu { +.editor-dropdown-menu-vertical > .editor-dropdown-menu-container .editor-dropdown-menu { inset-inline-start: 100%; top: 0; - flex-direction: column; } .editor-format-menu-toggle { From a27a325af77e31a184cdb33dc05cb658de697e0b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 2 Aug 2024 15:28:54 +0100 Subject: [PATCH 061/107] Lexical: Started on table actions Started building table cell form/actions --- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/nodes/custom-table.ts | 2 +- resources/js/wysiwyg/todo.md | 8 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 62 +++++++++- .../js/wysiwyg/ui/defaults/forms/controls.ts | 18 +++ .../{form-definitions.ts => forms/objects.ts} | 103 +++++++--------- .../js/wysiwyg/ui/defaults/forms/tables.ts | 112 ++++++++++++++++++ resources/js/wysiwyg/ui/defaults/modals.ts | 27 +++++ .../helpers/table-selection-handler.ts | 80 +++++++++++++ resources/js/wysiwyg/ui/index.ts | 21 +--- resources/js/wysiwyg/ui/toolbars.ts | 8 +- 11 files changed, 361 insertions(+), 82 deletions(-) create mode 100644 resources/js/wysiwyg/ui/defaults/forms/controls.ts rename resources/js/wysiwyg/ui/defaults/{form-definitions.ts => forms/objects.ts} (87%) create mode 100644 resources/js/wysiwyg/ui/defaults/forms/tables.ts create mode 100644 resources/js/wysiwyg/ui/defaults/modals.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 1e9dd25df..71a007f59 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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 = {}): 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), ); diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 1107f0a90..7dda24a7a 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -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; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 1a367d0dd..0354b7935 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -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 @@ -21,6 +23,10 @@ - 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. diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index b6b92e197..e3f7bb570 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -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 index 000000000..bcb2f5bad --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -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 diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts similarity index 87% rename from resources/js/wysiwyg/ui/defaults/form-definitions.ts rename to resources/js/wysiwyg/ui/defaults/forms/objects.ts index 6c0a54f23..7a388751b 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -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 index 000000000..a045ba55d --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -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 index 000000000..30351602c --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -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 = { + 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 index 000000000..0557b37e5 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -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(); + 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(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 diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index a3f150e52..5fbaec91b 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -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): 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', { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index d2b179eb6..43f00c001 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -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), ]), From e94ad78ea76b67e0019387afaad0d4e03a8c4346 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 3 Aug 2024 18:01:54 +0100 Subject: [PATCH 062/107] Lexical: Completed out table menu elements, logic pending --- resources/js/wysiwyg/todo.md | 9 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 225 ++++++++++++++++-- .../js/wysiwyg/ui/defaults/forms/tables.ts | 178 +++++++++++--- resources/js/wysiwyg/ui/defaults/modals.ts | 10 +- .../ui/framework/blocks/dropdown-button.ts | 4 +- .../wysiwyg/ui/framework/helpers/dropdowns.ts | 2 +- resources/js/wysiwyg/ui/toolbars.ts | 47 +++- 7 files changed, 409 insertions(+), 66 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 0354b7935..a0ea2e1eb 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,15 +3,18 @@ ## In progress - Table features - - Continued table dropdown menu - - Connect up cell properties form + - Cell properties form logic - Merge cell action + - Row properties form logic + - Table properties form logic + - Caption text support + - Resize to contents button + - Remove formatting button ## Main Todo - Alignments: Use existing classes for blocks - Alignments: Handle inline block content (image, video) - - Image paste upload - Keyboard shortcuts support - Add ID support to all block types diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index e3f7bb570..b0f0bf346 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -9,25 +9,93 @@ import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; import { - $getNodeFromSelection, + $getNodeFromSelection, $getParentOfType, $selectionContainsNodeType } from "../../../helpers"; -import {$getSelection} from "lexical"; +import {$getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { + $createTableRowNode, $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL, $isTableCellNode, - $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, + $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, TableNode, } from "@lexical/table"; +const neverActive = (): boolean => false; +const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isTableCellNode); export const table: EditorBasicButtonDefinition = { label: 'Table', icon: tableIcon, }; +export const tableProperties: EditorButtonDefinition = { + label: 'Table properties', + icon: tableIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { + return; + } + + const table = $getParentOfType(cell, $isTableNode); + const modalForm = context.manager.createModal('table_properties'); + modalForm.show({}); + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const clearTableFormatting: EditorButtonDefinition = { + label: 'Clear table formatting', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { + return; + } + + const table = $getParentOfType(cell, $isTableNode); + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const resizeTableToContents: EditorButtonDefinition = { + label: 'Resize to contents', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { + return; + } + + const table = $getParentOfType(cell, $isCustomTableNode); + if (!$isCustomTableNode(table)) { + return; + } + + for (const row of table.getChildren()) { + if ($isTableRowNode(row)) { + // TODO - Come back later as this may depend on if we + // are using a custom table row + } + } + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + export const deleteTable: EditorButtonDefinition = { label: 'Delete table', icon: deleteIcon, @@ -53,29 +121,27 @@ export const deleteTableMenuAction: EditorButtonDefinition = { }; export const insertRowAbove: EditorButtonDefinition = { - label: 'Insert row above', + label: 'Insert row before', icon: insertRowAboveIcon, action(context: EditorUiContext) { context.editor.update(() => { $insertTableRow__EXPERIMENTAL(false); }); }, - isActive() { - return false; - } + isActive: neverActive, + isDisabled: cellNotSelected, }; export const insertRowBelow: EditorButtonDefinition = { - label: 'Insert row below', + label: 'Insert row after', icon: insertRowBelowIcon, action(context: EditorUiContext) { context.editor.update(() => { $insertTableRow__EXPERIMENTAL(true); }); }, - isActive() { - return false; - } + isActive: neverActive, + isDisabled: cellNotSelected, }; export const deleteRow: EditorButtonDefinition = { @@ -86,9 +152,124 @@ export const deleteRow: EditorButtonDefinition = { $deleteTableRow__EXPERIMENTAL(); }); }, - isActive() { - return false; - } + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const rowProperties: EditorButtonDefinition = { + label: 'Row properties', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { + return; + } + + const row = $getParentOfType(cell, $isTableRowNode); + const modalForm = context.manager.createModal('row_properties'); + modalForm.show({}); + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const cutRow: EditorButtonDefinition = { + label: 'Cut row', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const copyRow: EditorButtonDefinition = { + label: 'Copy row', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const pasteRowBefore: EditorButtonDefinition = { + label: 'Paste row before', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const pasteRowAfter: EditorButtonDefinition = { + label: 'Paste row after', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const cutColumn: EditorButtonDefinition = { + label: 'Cut column', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const copyColumn: EditorButtonDefinition = { + label: 'Copy column', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const pasteColumnBefore: EditorButtonDefinition = { + label: 'Paste column before', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, +}; + +export const pasteColumnAfter: EditorButtonDefinition = { + label: 'Paste column after', + format: 'long', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + // TODO + }); + }, + isActive: neverActive, + isDisabled: cellNotSelected, }; export const insertColumnBefore: EditorButtonDefinition = { @@ -142,12 +323,8 @@ export const cellProperties: EditorButtonDefinition = { } }); }, - isActive() { - return false; - }, - isDisabled(selection) { - return !$selectionContainsNodeType(selection, $isTableCellNode); - } + isActive: neverActive, + isDisabled: cellNotSelected, }; export const mergeCells: EditorButtonDefinition = { @@ -159,9 +336,7 @@ export const mergeCells: EditorButtonDefinition = { // https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx#L299 }); }, - isActive() { - return false; - }, + isActive: neverActive, isDisabled(selection) { return !$isTableSelection(selection); } @@ -174,9 +349,7 @@ export const splitCell: EditorButtonDefinition = { $unmergeCell(); }); }, - isActive() { - return false; - }, + isActive: neverActive, isDisabled(selection) { const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode|null; if (cell) { diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index a045ba55d..9951bfe7f 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -5,12 +5,54 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {setEditorContentFromHtml} from "../../../actions"; + +const borderStyleInput: EditorSelectFormFieldDefinition = { + 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', + } +}; + +const borderColorInput: EditorFormFieldDefinition = { + label: 'Border color', + name: 'border_color', + type: 'text', +}; + +const backgroundColorInput: EditorFormFieldDefinition = { + label: 'Background color', + name: 'background_color', + type: 'text', +}; + +const alignmentInput: EditorSelectFormFieldDefinition = { + label: 'Alignment', + name: 'align', + type: 'select', + valuesByLabel: { + 'None': '', + 'Left': 'left', + 'Center': 'center', + 'Right': 'right', + } +}; export const cellProperties: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { - setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); + // TODO return true; }, fields: [ @@ -37,16 +79,10 @@ export const cellProperties: EditorFormDefinition = { } } as EditorSelectFormFieldDefinition, { + ...alignmentInput, label: 'Horizontal align', name: 'h_align', - type: 'select', - valuesByLabel: { - 'None': '', - 'Left': 'left', - 'Center': 'center', - 'Right': 'right', - } - } as EditorSelectFormFieldDefinition, + }, { label: 'Vertical align', name: 'v_align', @@ -66,34 +102,122 @@ export const cellProperties: EditorFormDefinition = { name: 'border_width', type: 'text', }, + borderStyleInput, + borderColorInput, + backgroundColorInput, + ]; + + return new EditorFormTabs([ { - label: 'Border style', - name: 'border_style', + label: 'General', + contents: generalFields, + }, + { + label: 'Advanced', + contents: advancedFields, + } + ]) + } + }, + ], +}; + +export const rowProperties: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + // TODO + return true; + }, + fields: [ + { + build() { + const generalFields: EditorFormFieldDefinition[] = [ + { + label: 'Row type', + name: 'type', type: 'select', valuesByLabel: { - 'Select...': '', - "Solid": 'solid', - "Dotted": 'dotted', - "Dashed": 'dashed', - "Double": 'double', - "Groove": 'groove', - "Ridge": 'ridge', - "Inset": 'inset', - "Outset": 'outset', - "None": 'none', - "Hidden": 'hidden', + 'Body': 'body', + 'Header': 'header', + 'Footer': 'footer', } } as EditorSelectFormFieldDefinition, + alignmentInput, { - label: 'Border color', - name: 'border_color', + label: 'Height', + name: 'height', + type: 'text', + }, + ]; + + const advancedFields: EditorFormFieldDefinition[] = [ + borderStyleInput, + borderColorInput, + backgroundColorInput, + ]; + + return new EditorFormTabs([ + { + label: 'General', + contents: generalFields, + }, + { + label: 'Advanced', + contents: advancedFields, + } + ]) + } + }, + ], +}; + +export const tableProperties: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + // TODO + return true; + }, + fields: [ + { + build() { + const generalFields: EditorFormFieldDefinition[] = [ + { + label: 'Width', + name: 'width', type: 'text', }, { - label: 'Background color', - name: 'background_color', + label: 'Height', + name: 'height', type: 'text', }, + { + label: 'Cell spacing', + name: 'cell_spacing', + type: 'text', + }, + { + label: 'Cell padding', + name: 'cell_padding', + type: 'text', + }, + { + label: 'Border width', + name: 'border_width', + type: 'text', + }, + { + label: 'caption', + name: 'height', + type: 'text', // TODO - + }, + alignmentInput, + ]; + + const advancedFields: EditorFormFieldDefinition[] = [ + borderStyleInput, + borderColorInput, + backgroundColorInput, ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index 30351602c..44d4e0360 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,7 +1,7 @@ import {EditorFormModalDefinition} from "../framework/modals"; import {image, link, media} from "./forms/objects"; import {source} from "./forms/controls"; -import {cellProperties} from "./forms/tables"; +import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; export const modals: Record = { link: { @@ -24,4 +24,12 @@ export const modals: Record = { title: 'Cell Properties', form: cellProperties, }, + row_properties: { + title: 'Row Properties', + form: rowProperties, + }, + table_properties: { + title: 'Table Properties', + form: tableProperties, + }, }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index 24659b546..da0d3e5d0 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -24,7 +24,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { constructor(options: EditorDropdownButtonOptions, children: EditorUiElement[]) { super(children); this.childItems = children; - this.options = Object.assign(defaultOptions, options); + this.options = Object.assign({}, defaultOptions, options); if (options.button instanceof EditorButton) { this.button = options.button; @@ -61,7 +61,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { class: 'editor-dropdown-menu-container', }, [button, menu]); - handleDropdown({toggle : button, menu : menu, + handleDropdown({toggle: button, menu : menu, showOnHover: this.options.showOnHover, onOpen : () => { this.open = true; diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index 45c3f39d1..e8cef3c8d 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -44,5 +44,5 @@ export function handleDropdown(options: HandleDropdownParams) { toggle.addEventListener('mouseenter', toggleShowing); } - menu.addEventListener('mouseleave', hide); + menu.parentElement?.addEventListener('mouseleave', hide); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 43f00c001..3346e0a07 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -9,14 +9,27 @@ import {EditorTableCreator} from "./framework/blocks/table-creator"; import {EditorColorButton} from "./framework/blocks/color-button"; import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; import { - cellProperties, + cellProperties, clearTableFormatting, + copyColumn, + copyRow, + cutColumn, + cutRow, deleteColumn, deleteRow, - deleteTable, deleteTableMenuAction, insertColumnAfter, + deleteTable, + deleteTableMenuAction, + insertColumnAfter, insertColumnBefore, insertRowAbove, - insertRowBelow, mergeCells, splitCell, - table + insertRowBelow, + mergeCells, + pasteColumnAfter, + pasteColumnBefore, + pasteRowAfter, + pasteRowBefore, resizeTableToContents, + rowProperties, + splitCell, + table, tableProperties } from "./defaults/buttons/tables"; import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; import { @@ -119,11 +132,33 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorDropdownButton({button: {...table, format: 'long'}, showOnHover: true}, [ new EditorTableCreator(), ]), - new EditorDropdownButton({button: {label: 'Cell'}}, [ + new EditorDropdownButton({button: {label: 'Cell'}, direction: 'vertical', showOnHover: true}, [ new EditorButton(cellProperties), new EditorButton(mergeCells), new EditorButton(splitCell), ]), + new EditorDropdownButton({button: {label: 'Row'}, direction: 'vertical', showOnHover: true}, [ + new EditorButton({...insertRowAbove, format: 'long'}), + new EditorButton({...insertRowBelow, format: 'long'}), + new EditorButton({...deleteRow, format: 'long'}), + new EditorButton(rowProperties), + new EditorButton(cutRow), + new EditorButton(copyRow), + new EditorButton(pasteRowBefore), + new EditorButton(pasteRowAfter), + ]), + new EditorDropdownButton({button: {label: 'Column'}, direction: 'vertical', showOnHover: true}, [ + new EditorButton({...insertColumnBefore, format: 'long'}), + new EditorButton({...insertColumnAfter, format: 'long'}), + new EditorButton({...deleteColumn, format: 'long'}), + new EditorButton(cutColumn), + new EditorButton(copyColumn), + new EditorButton(pasteColumnBefore), + new EditorButton(pasteColumnAfter), + ]), + new EditorButton({...tableProperties, format: 'long'}), + new EditorButton(clearTableFormatting), + new EditorButton(resizeTableToContents), new EditorButton(deleteTableMenuAction), ]), @@ -176,7 +211,7 @@ export function getCodeToolbarContent(): EditorUiElement[] { export function getTableToolbarContent(): EditorUiElement[] { return [ new EditorOverflowContainer(2, [ - // Todo - Table properties + new EditorButton(tableProperties), new EditorButton(deleteTable), ]), new EditorOverflowContainer(3, [ From efec752985465227958c48eb5fb90faf95b66fb3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 3 Aug 2024 18:14:01 +0100 Subject: [PATCH 063/107] Lexical: Split helpers to utils, refactored files --- resources/js/wysiwyg/index.ts | 8 +- resources/js/wysiwyg/nodes/code-block.ts | 2 +- .../js/wysiwyg/nodes/custom-list-item.ts | 3 +- resources/js/wysiwyg/nodes/custom-table.ts | 3 +- resources/js/wysiwyg/nodes/details.ts | 3 +- resources/js/wysiwyg/nodes/diagram.ts | 2 +- resources/js/wysiwyg/nodes/image.ts | 2 +- resources/js/wysiwyg/nodes/media.ts | 3 +- .../wysiwyg/{ => services}/common-events.ts | 2 +- .../wysiwyg/{ => services}/drop-handling.ts | 8 +- .../js/wysiwyg/ui/decorators/code-block.ts | 2 +- resources/js/wysiwyg/ui/decorators/diagram.ts | 2 +- resources/js/wysiwyg/ui/decorators/image.ts | 3 +- .../wysiwyg/ui/defaults/buttons/alignments.ts | 2 +- .../ui/defaults/buttons/block-formats.ts | 2 +- .../wysiwyg/ui/defaults/buttons/controls.ts | 2 +- .../ui/defaults/buttons/inline-formats.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/lists.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 6 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 6 +- .../js/wysiwyg/ui/defaults/forms/controls.ts | 2 +- .../js/wysiwyg/ui/defaults/forms/objects.ts | 2 +- .../ui/framework/blocks/color-picker.ts | 2 +- .../ui/framework/blocks/dropdown-button.ts | 2 +- .../ui/framework/blocks/format-menu.ts | 2 +- .../framework/blocks/format-preview-button.ts | 2 +- .../ui/framework/blocks/overflow-container.ts | 2 +- .../ui/framework/blocks/table-creator.ts | 3 +- resources/js/wysiwyg/ui/framework/buttons.ts | 3 +- resources/js/wysiwyg/ui/framework/core.ts | 3 +- resources/js/wysiwyg/ui/framework/forms.ts | 2 +- .../ui/framework/helpers/table-resizer.ts | 2 +- resources/js/wysiwyg/ui/framework/modals.ts | 2 +- resources/js/wysiwyg/ui/framework/toolbars.ts | 3 +- resources/js/wysiwyg/ui/toolbars.ts | 3 +- resources/js/wysiwyg/{ => utils}/actions.ts | 4 +- resources/js/wysiwyg/utils/dom.ts | 24 ++++ resources/js/wysiwyg/utils/nodes.ts | 53 +++++++++ .../{helpers.ts => utils/selection.ts} | 107 ++++-------------- 39 files changed, 152 insertions(+), 136 deletions(-) rename resources/js/wysiwyg/{ => services}/common-events.ts (97%) rename resources/js/wysiwyg/{ => services}/drop-handling.ts (93%) rename resources/js/wysiwyg/{ => utils}/actions.ts (97%) create mode 100644 resources/js/wysiwyg/utils/dom.ts create mode 100644 resources/js/wysiwyg/utils/nodes.ts rename resources/js/wysiwyg/{helpers.ts => utils/selection.ts} (53%) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 71a007f59..9da646a77 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -4,14 +4,14 @@ import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; import {buildEditorUI} from "./ui"; -import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions"; +import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; -import {el} from "./helpers"; import {EditorUiContext} from "./ui/framework/core"; -import {listen as listenToCommonEvents} from "./common-events"; -import {handleDropEvents} from "./drop-handling"; +import {listen as listenToCommonEvents} from "./services/common-events"; +import {handleDropEvents} from "./services/drop-handling"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; +import {el} from "./utils/dom"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts index 2478ba249..e240a3887 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -8,9 +8,9 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../helpers"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; import {CodeEditor} from "../../components"; +import {el} from "../utils/dom"; export type SerializedCodeBlockNode = Spread<{ language: string; diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts index 21b9f6d5f..2b4d74146 100644 --- a/resources/js/wysiwyg/nodes/custom-list-item.ts +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -1,7 +1,8 @@ import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list"; import {EditorConfig} from "lexical/LexicalEditor"; import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; -import {el} from "../helpers"; + +import {el} from "../utils/dom"; function updateListItemChecked( dom: HTMLElement, diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 7dda24a7a..32f3ec4fa 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -1,7 +1,8 @@ import {SerializedTableNode, TableNode, TableRowNode} from "@lexical/table"; import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalEditor, LexicalNode, Spread} from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../helpers"; + +import {el} from "../utils/dom"; export type SerializedCustomTableNode = Spread<{ id: string; diff --git a/resources/js/wysiwyg/nodes/details.ts b/resources/js/wysiwyg/nodes/details.ts index a18c4d858..8071d5e8f 100644 --- a/resources/js/wysiwyg/nodes/details.ts +++ b/resources/js/wysiwyg/nodes/details.ts @@ -7,7 +7,8 @@ import { SerializedElementNode, } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../helpers"; + +import {el} from "../utils/dom"; export class DetailsNode extends ElementNode { diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts index 1aff06400..76d939248 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -8,11 +8,11 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../helpers"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; import * as DrawIO from '../../services/drawio'; import {EditorUiContext} from "../ui/framework/core"; import {HttpError} from "../../services/http"; +import {el} from "../utils/dom"; export type SerializedDiagramNode = Spread<{ id: string; diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index 92d5518db..ef6bf3572 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -8,8 +8,8 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../helpers"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; +import {el} from "../utils/dom"; export interface ImageNodeOptions { alt?: string; diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index 751f420fa..aba4f6c37 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -7,7 +7,8 @@ import { SerializedElementNode, Spread } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../helpers"; + +import {el} from "../utils/dom"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { diff --git a/resources/js/wysiwyg/common-events.ts b/resources/js/wysiwyg/services/common-events.ts similarity index 97% rename from resources/js/wysiwyg/common-events.ts rename to resources/js/wysiwyg/services/common-events.ts index 7355d977b..16522d66b 100644 --- a/resources/js/wysiwyg/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -5,7 +5,7 @@ import { insertHtmlIntoEditor, prependHtmlToEditor, setEditorContentFromHtml -} from "./actions"; +} from "../utils/actions"; type EditorEventContent = { html: string; diff --git a/resources/js/wysiwyg/drop-handling.ts b/resources/js/wysiwyg/services/drop-handling.ts similarity index 93% rename from resources/js/wysiwyg/drop-handling.ts rename to resources/js/wysiwyg/services/drop-handling.ts index 5d541365a..7c9bb2713 100644 --- a/resources/js/wysiwyg/drop-handling.ts +++ b/resources/js/wysiwyg/services/drop-handling.ts @@ -3,12 +3,8 @@ import { LexicalEditor, LexicalNode } from "lexical"; -import { - $getNearestBlockNodeForCoords, - $htmlToBlockNodes, - $insertNewBlockNodesAtSelection, - $selectSingleNode -} from "./helpers"; +import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; +import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null { const x = event.clientX; diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index d6947ea75..650bd64c5 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,8 +1,8 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {$selectionContainsNode, $selectSingleNode} from "../../helpers"; import {BaseSelection} from "lexical"; +import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; export class CodeBlockDecorator extends EditorDecorator { diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 0f1263f38..7c79f9f41 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,8 +1,8 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; -import {$selectionContainsNode, $selectSingleNode} from "../../helpers"; import {BaseSelection} from "lexical"; import {$openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram"; +import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; export class DiagramDecorator extends EditorDecorator { diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index 2046260a0..d110bc499 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -1,9 +1,10 @@ import {EditorDecorator} from "../framework/decorator"; -import {el, $selectSingleNode} from "../../helpers"; import {$createNodeSelection, $setSelection} from "lexical"; import {EditorUiContext} from "../framework/core"; import {ImageNode} from "../../nodes/image"; import {MouseDragTracker, MouseDragTrackerDistance} from "../framework/helpers/mouse-drag-tracker"; +import {$selectSingleNode} from "../../utils/selection"; +import {el} from "../../utils/dom"; export class ImageDecorator extends EditorDecorator { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 2b441e5da..40d9c89dc 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,11 +1,11 @@ import {$getSelection, BaseSelection, ElementFormatType} from "lexical"; -import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../helpers"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; import alignCenterIcon from "@icons/editor/align-center.svg"; import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; +import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../utils/selection"; function setAlignmentForSection(alignment: ElementFormatType): void { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index 0eb07ecf1..eba903263 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -1,7 +1,6 @@ import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../helpers"; import {$createParagraphNode, $isParagraphNode, BaseSelection, LexicalNode} from "lexical"; import { $createHeadingNode, @@ -11,6 +10,7 @@ import { HeadingNode, HeadingTagType } from "@lexical/rich-text"; +import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection"; function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index ad69d69d1..2a2fecc40 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -11,7 +11,7 @@ import { } from "lexical"; import redoIcon from "@icons/editor/redo.svg"; import sourceIcon from "@icons/editor/source-view.svg"; -import {getEditorContentAsHtml} from "../../../actions"; +import {getEditorContentAsHtml} from "../../../utils/actions"; import fullscreenIcon from "@icons/editor/fullscreen.svg"; export const undo: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index d04f72a2e..a967ecb2f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -1,7 +1,6 @@ import {$getSelection, $isTextNode, BaseSelection, FORMAT_TEXT_COMMAND, TextFormatType} from "lexical"; import {EditorBasicButtonDefinition, EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {$selectionContainsTextFormat} from "../../../helpers"; import boldIcon from "@icons/editor/bold.svg"; import italicIcon from "@icons/editor/italic.svg"; import underlinedIcon from "@icons/editor/underlined.svg"; @@ -12,6 +11,7 @@ import superscriptIcon from "@icons/editor/superscript.svg"; import subscriptIcon from "@icons/editor/subscript.svg"; import codeIcon from "@icons/editor/code.svg"; import formatClearIcon from "@icons/editor/format-clear.svg"; +import {$selectionContainsTextFormat} from "../../../utils/selection"; function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index ecda290a1..10500eb67 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -2,10 +2,10 @@ import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/ import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; import {$getSelection, BaseSelection, LexicalNode} from "lexical"; -import {$selectionContainsNodeType} from "../../../helpers"; import listBulletIcon from "@icons/editor/list-bullet.svg"; import listNumberedIcon from "@icons/editor/list-numbered.svg"; import listCheckIcon from "@icons/editor/list-check.svg"; +import {$selectionContainsNodeType} from "../../../utils/selection"; function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 3c14052ba..0eac497fc 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -10,7 +10,6 @@ import { BaseSelection, ElementNode } from "lexical"; -import {$getNodeFromSelection, $insertNewBlockNodeAtSelection, $selectionContainsNodeType} from "../../../helpers"; import {$isLinkNode, LinkNode} from "@lexical/link"; import unlinkIcon from "@icons/editor/unlink.svg"; import imageIcon from "@icons/editor/image.svg"; @@ -26,6 +25,11 @@ import detailsIcon from "@icons/editor/details.svg"; import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details"; import {$isMediaNode, MediaNode} from "../../../nodes/media"; +import { + $getNodeFromSelection, + $insertNewBlockNodeAtSelection, + $selectionContainsNodeType +} from "../../../utils/selection"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index b0f0bf346..2cc2e701b 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -8,10 +8,6 @@ import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; -import { - $getNodeFromSelection, $getParentOfType, - $selectionContainsNodeType -} from "../../../helpers"; import {$getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { @@ -22,6 +18,8 @@ import { $insertTableRow__EXPERIMENTAL, $isTableCellNode, $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, TableNode, } from "@lexical/table"; +import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; +import {$getParentOfType} from "../../../utils/nodes"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isTableCellNode); diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts index bcb2f5bad..fc461f662 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -1,6 +1,6 @@ import {EditorFormDefinition} from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {setEditorContentFromHtml} from "../../../actions"; +import {setEditorContentFromHtml} from "../../../utils/actions"; export const source: EditorFormDefinition = { submitText: 'Save', diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 7a388751b..dbb89b18f 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -4,8 +4,8 @@ import {$createTextNode, $getSelection} from "lexical"; import {$createImageNode} from "../../../nodes/image"; import {$createLinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; -import {$getNodeFromSelection} from "../../../helpers"; import {$insertNodeToNearestRoot} from "@lexical/utils"; +import {$getNodeFromSelection} from "../../../utils/selection"; export const image: EditorFormDefinition = { submitText: 'Apply', diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index 6972d7a8e..48e313f5c 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -1,7 +1,7 @@ -import {el} from "../../../helpers"; import {EditorUiElement} from "../core"; import {$getSelection} from "lexical"; import {$patchStyleText} from "@lexical/selection"; +import {el} from "../../../utils/dom"; const colorChoices = [ '#000000', diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index da0d3e5d0..a7905a6dd 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -1,7 +1,7 @@ -import {el} from "../../../helpers"; import {handleDropdown} from "../helpers/dropdowns"; import {EditorContainerUiElement, EditorUiElement} from "../core"; import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; +import {el} from "../../../utils/dom"; export type EditorDropdownButtonOptions = { showOnHover?: boolean; diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts index b0834fe4d..d666954bf 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -1,7 +1,7 @@ -import {el} from "../../../helpers"; import {EditorUiStateUpdate, EditorContainerUiElement} from "../core"; import {EditorButton} from "../buttons"; import {handleDropdown} from "../helpers/dropdowns"; +import {el} from "../../../utils/dom"; export class EditorFormatMenu extends EditorContainerUiElement { buildDOM(): HTMLElement { diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts index f83035aa6..2371983dd 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/format-preview-button.ts @@ -1,5 +1,5 @@ -import {el} from "../../../helpers"; import {EditorButton, EditorButtonDefinition} from "../buttons"; +import {el} from "../../../utils/dom"; export class FormatPreviewButton extends EditorButton { protected previewSampleElement: HTMLElement; diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts index 108992db8..cd0780534 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -1,7 +1,7 @@ import {EditorContainerUiElement, EditorUiElement} from "../core"; -import {el} from "../../../helpers"; import {EditorDropdownButton} from "./dropdown-button"; import moreHorizontal from "@icons/editor/more-horizontal.svg" +import {el} from "../../../utils/dom"; export class EditorOverflowContainer extends EditorContainerUiElement { diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts index 1981fcb86..a8a142df5 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -1,7 +1,8 @@ -import {el, $insertNewBlockNodeAtSelection} from "../../../helpers"; import {EditorUiElement} from "../core"; import {$createTableNodeWithDimensions} from "@lexical/table"; import {CustomTableNode} from "../../../nodes/custom-table"; +import {$insertNewBlockNodeAtSelection} from "../../../utils/selection"; +import {el} from "../../../utils/dom"; export class EditorTableCreator extends EditorUiElement { diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 9a23edfb7..cf114aa02 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -1,6 +1,7 @@ import {BaseSelection} from "lexical"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; -import {el} from "../../helpers"; + +import {el} from "../../utils/dom"; export interface EditorBasicButtonDefinition { label: string; diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index f644bc37a..3e9f1e3d9 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -1,6 +1,7 @@ import {BaseSelection, LexicalEditor} from "lexical"; import {EditorUIManager} from "./manager"; -import {el} from "../../helpers"; + +import {el} from "../../utils/dom"; export type EditorUiStateUpdate = { editor: LexicalEditor; diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index b225a3de2..a2db34dd7 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -5,8 +5,8 @@ import { EditorUiBuilderDefinition, isUiBuilderDefinition } from "./core"; -import {el} from "../../helpers"; import {uniqueId} from "../../../services/util"; +import {el} from "../../utils/dom"; export interface EditorFormFieldDefinition { label: string; diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index 2d995883a..f312294c5 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -1,8 +1,8 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; -import {el} from "../../../helpers"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {$getTableColumnWidth, $setTableColumnWidth, CustomTableNode} from "../../../nodes/custom-table"; import {TableRowNode} from "@lexical/table"; +import {el} from "../../../utils/dom"; type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index 6b09accdc..1768f6f54 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -1,7 +1,7 @@ import {EditorForm, EditorFormDefinition} from "./forms"; -import {el} from "../../helpers"; import {EditorContainerUiElement} from "./core"; import closeIcon from "@icons/close.svg"; +import {el} from "../../utils/dom"; export interface EditorModalDefinition { title: string; diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index d7c481934..b4e49af95 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -1,5 +1,6 @@ import {EditorContainerUiElement, EditorUiElement} from "./core"; -import {el} from "../../helpers"; + +import {el} from "../../utils/dom"; export type EditorContextToolbarDefinition = { selector: string; diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 3346e0a07..48e11837c 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,6 +1,5 @@ import {EditorButton} from "./framework/buttons"; import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core"; -import {$selectionContainsNodeType, el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; @@ -65,6 +64,8 @@ import { unlink } from "./defaults/buttons/objects"; import {$isTableNode} from "@lexical/table"; +import {$selectionContainsNodeType} from "../utils/selection"; +import {el} from "../utils/dom"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ diff --git a/resources/js/wysiwyg/actions.ts b/resources/js/wysiwyg/utils/actions.ts similarity index 97% rename from resources/js/wysiwyg/actions.ts rename to resources/js/wysiwyg/utils/actions.ts index a3d2f0ef6..ae829bae3 100644 --- a/resources/js/wysiwyg/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -1,8 +1,6 @@ import {$getRoot, $getSelection, LexicalEditor} from "lexical"; import {$generateHtmlFromNodes} from "@lexical/html"; -import {$htmlToBlockNodes} from "./helpers"; - - +import {$htmlToBlockNodes} from "./nodes"; export function setEditorContentFromHtml(editor: LexicalEditor, html: string) { editor.update(() => { diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts new file mode 100644 index 000000000..dc0872e89 --- /dev/null +++ b/resources/js/wysiwyg/utils/dom.ts @@ -0,0 +1,24 @@ +export function el(tag: string, attrs: Record = {}, children: (string | HTMLElement)[] = []): HTMLElement { + const el = document.createElement(tag); + const attrKeys = Object.keys(attrs); + for (const attr of attrKeys) { + if (attrs[attr] !== null) { + el.setAttribute(attr, attrs[attr] as string); + } + } + + for (const child of children) { + if (typeof child === 'string') { + el.append(document.createTextNode(child)); + } else { + el.append(child); + } + } + + return el; +} + +export function htmlToDom(html: string): Document { + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts new file mode 100644 index 000000000..8e6c66610 --- /dev/null +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -0,0 +1,53 @@ +import {$getRoot, $isTextNode, LexicalEditor, LexicalNode} from "lexical"; +import {LexicalNodeMatcher} from "../nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$generateNodesFromDOM} from "@lexical/html"; +import {htmlToDom} from "./dom"; + +function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { + return nodes.map(node => { + if ($isTextNode(node)) { + const paragraph = $createCustomParagraphNode(); + paragraph.append(node); + return paragraph; + } + return node; + }); +} + +export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] { + const dom = htmlToDom(html); + const nodes = $generateNodesFromDOM(editor, dom); + return wrapTextNodes(nodes); +} + +export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null { + for (const parent of node.getParents()) { + if (matcher(parent)) { + return parent; + } + } + + return null; +} + +/** + * Get the nearest root/block level node for the given position. + */ +export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, y: number): LexicalNode | null { + // TODO - Take into account x for floated blocks? + const rootNodes = $getRoot().getChildren(); + for (const node of rootNodes) { + const nodeDom = editor.getElementByKey(node.__key); + if (!nodeDom) { + continue; + } + + const bounds = nodeDom.getBoundingClientRect(); + if (y <= bounds.bottom) { + return node; + } + } + + return null; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/utils/selection.ts similarity index 53% rename from resources/js/wysiwyg/helpers.ts rename to resources/js/wysiwyg/utils/selection.ts index 07755f449..e34afbe36 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -1,64 +1,28 @@ import { $createNodeSelection, - $createParagraphNode, $getRoot, - $getSelection, $isElementNode, - $isTextNode, $setSelection, - BaseSelection, ElementFormatType, ElementNode, LexicalEditor, - LexicalNode, TextFormatType + $createParagraphNode, + $getRoot, + $getSelection, + $isElementNode, + $isTextNode, + $setSelection, + BaseSelection, + ElementFormatType, + ElementNode, + LexicalNode, + TextFormatType } from "lexical"; -import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; -import {$createCustomParagraphNode} from "./nodes/custom-paragraph"; -import {$generateNodesFromDOM} from "@lexical/html"; -export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { - const el = document.createElement(tag); - const attrKeys = Object.keys(attrs); - for (const attr of attrKeys) { - if (attrs[attr] !== null) { - el.setAttribute(attr, attrs[attr] as string); - } - } +import {$getParentOfType} from "./nodes"; - for (const child of children) { - if (typeof child === 'string') { - el.append(document.createTextNode(child)); - } else { - el.append(child); - } - } - - return el; -} - -function htmlToDom(html: string): Document { - const parser = new DOMParser(); - return parser.parseFromString(html, 'text/html'); -} - -function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { - return nodes.map(node => { - if ($isTextNode(node)) { - const paragraph = $createCustomParagraphNode(); - paragraph.append(node); - return paragraph; - } - return node; - }); -} - -export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] { - const dom = htmlToDom(html); - const nodes = $generateNodesFromDOM(editor, dom); - return wrapTextNodes(nodes); -} - -export function $selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { +export function $selectionContainsNodeType(selection: BaseSelection | null, matcher: LexicalNodeMatcher): boolean { return $getNodeFromSelection(selection, matcher) !== null; } -export function $getNodeFromSelection(selection: BaseSelection|null, matcher: LexicalNodeMatcher): LexicalNode|null { +export function $getNodeFromSelection(selection: BaseSelection | null, matcher: LexicalNodeMatcher): LexicalNode | null { if (!selection) { return null; } @@ -77,17 +41,7 @@ export function $getNodeFromSelection(selection: BaseSelection|null, matcher: Le return null; } -export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode|null { - for (const parent of node.getParents()) { - if (matcher(parent)) { - return parent; - } - } - - return null; -} - -export function $selectionContainsTextFormat(selection: BaseSelection|null, format: TextFormatType): boolean { +export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean { if (!selection) { return false; } @@ -140,7 +94,7 @@ export function $selectSingleNode(node: LexicalNode) { $setSelection(nodeSelection); } -export function $selectionContainsNode(selection: BaseSelection|null, node: LexicalNode): boolean { +export function $selectionContainsNode(selection: BaseSelection | null, node: LexicalNode): boolean { if (!selection) { return false; } @@ -155,7 +109,7 @@ export function $selectionContainsNode(selection: BaseSelection|null, node: Lexi return false; } -export function $selectionContainsElementFormat(selection: BaseSelection|null, format: ElementFormatType): boolean { +export function $selectionContainsElementFormat(selection: BaseSelection | null, format: ElementFormatType): boolean { const nodes = $getBlockElementNodesInSelection(selection); for (const node of nodes) { if (node.getFormatType() === format) { @@ -166,7 +120,7 @@ export function $selectionContainsElementFormat(selection: BaseSelection|null, f return false; } -export function $getBlockElementNodesInSelection(selection: BaseSelection|null): ElementNode[] { +export function $getBlockElementNodesInSelection(selection: BaseSelection | null): ElementNode[] { if (!selection) { return []; } @@ -175,7 +129,7 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection|null): for (const node of selection.getNodes()) { const blockElement = $findMatchingParent(node, (node) => { return $isElementNode(node) && !node.isInline(); - }) as ElementNode|null; + }) as ElementNode | null; if (blockElement) { blockNodes.set(blockElement.getKey(), blockElement); @@ -183,25 +137,4 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection|null): } return Array.from(blockNodes.values()); -} - -/** - * Get the nearest root/block level node for the given position. - */ -export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, y: number): LexicalNode|null { - // TODO - Take into account x for floated blocks? - const rootNodes = $getRoot().getChildren(); - for (const node of rootNodes) { - const nodeDom = editor.getElementByKey(node.__key); - if (!nodeDom) { - continue; - } - - const bounds = nodeDom.getBoundingClientRect(); - if (y <= bounds.bottom) { - return node; - } - } - - return null; } \ No newline at end of file From 8939f310db4eb8c825226f090bd1ab7c37c3654d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 5 Aug 2024 15:08:52 +0100 Subject: [PATCH 064/107] Lexical: Started linking up cell properties form --- .../js/wysiwyg/nodes/custom-paragraph.ts | 2 +- .../wysiwyg/nodes/custom-table-cell-node.ts | 90 +++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 17 +++- resources/js/wysiwyg/todo.md | 2 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 35 ++++---- .../js/wysiwyg/ui/defaults/forms/tables.ts | 66 +++++++++++--- 6 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/custom-table-cell-node.ts diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts index f13cef56f..97647bf5e 100644 --- a/resources/js/wysiwyg/nodes/custom-paragraph.ts +++ b/resources/js/wysiwyg/nodes/custom-paragraph.ts @@ -93,6 +93,6 @@ export function $createCustomParagraphNode() { return new CustomParagraphNode(); } -export function $isCustomParagraphNode(node: LexicalNode | null | undefined) { +export function $isCustomParagraphNode(node: LexicalNode | null | undefined): node is CustomParagraphNode { return node instanceof CustomParagraphNode; } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-table-cell-node.ts b/resources/js/wysiwyg/nodes/custom-table-cell-node.ts new file mode 100644 index 000000000..693ef5f5b --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-table-cell-node.ts @@ -0,0 +1,90 @@ +import {EditorConfig} from "lexical/LexicalEditor"; +import {DOMExportOutput, LexicalEditor, LexicalNode, Spread} from "lexical"; + +import {SerializedTableCellNode, TableCellHeaderStates, TableCellNode} from "@lexical/table"; +import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; + +export type SerializedCustomTableCellNode = Spread<{ + styles: Record, +}, SerializedTableCellNode> + +export class CustomTableCellNode extends TableCellNode { + __styles: Map = new Map; + + static getType(): string { + return 'custom-table-cell'; + } + + static clone(node: CustomTableCellNode): CustomTableCellNode { + const cellNode = new CustomTableCellNode( + node.__headerState, + node.__colSpan, + node.__width, + node.__key, + ); + cellNode.__rowSpan = node.__rowSpan; + cellNode.__styles = new Map(node.__styles); + return cellNode; + } + + getStyles(): Map { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: Map): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + + updateTag(tag: string): void { + const isHeader = tag.toLowerCase() === 'th'; + const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS; + const self = this.getWritable(); + self.__headerState = state; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = super.createDOM(config); + + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + + return element; + } + + // TODO - Import DOM + + updateDOM(prevNode: CustomTableCellNode): boolean { + return super.updateDOM(prevNode) + || this.__styles !== prevNode.__styles; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config); + return { + element + }; + } + + exportJSON(): SerializedCustomTableCellNode { + return { + ...super.exportJSON(), + type: 'custom-table-cell', + styles: Object.fromEntries(this.__styles), + }; + } +} + +export function $createCustomTableCellNode( + headerState: TableCellHeaderState, + colSpan = 1, + width?: number, +): CustomTableCellNode { + return new CustomTableCellNode(headerState, colSpan, width); +} + +export function $isCustomTableCellNode(node: LexicalNode | null | undefined): node is CustomTableCellNode { + return node instanceof CustomTableCellNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index f0df08fcb..92f6d2336 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -20,6 +20,7 @@ import {DiagramNode} from "./diagram"; import {EditorUiContext} from "../ui/framework/core"; import {MediaNode} from "./media"; import {CustomListItemNode} from "./custom-list-item"; +import {CustomTableCellNode} from "./custom-table-cell-node"; /** * Load the nodes for lexical. @@ -33,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor | CustomListItemNode, CustomTableNode, TableRowNode, - TableCellNode, + CustomTableCellNode, ImageNode, HorizontalRuleNode, DetailsNode, SummaryNode, @@ -59,7 +60,19 @@ export function getNodesForPageEditor(): (KlassConstructor | with: (node: ListItemNode) => { return new CustomListItemNode(node.__value, node.__checked); } - } + }, + { + replace: TableCellNode, + with: (node: TableCellNode) => { + const cell = new CustomTableCellNode( + node.__headerState, + node.__colSpan, + node.__width, + ); + cell.__rowSpan = node.__rowSpan; + return cell; + } + }, ]; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index a0ea2e1eb..d925711e1 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -13,7 +13,7 @@ ## Main Todo -- Alignments: Use existing classes for blocks +- Alignments: Use existing classes for blocks (including table cells) - Alignments: Handle inline block content (image, video) - Image paste upload - Keyboard shortcuts support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 2cc2e701b..3b431141f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -11,18 +11,19 @@ import {EditorUiContext} from "../../framework/core"; import {$getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { - $createTableRowNode, $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, - $insertTableRow__EXPERIMENTAL, $isTableCellNode, - $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, TableNode, + $insertTableRow__EXPERIMENTAL, + $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, } from "@lexical/table"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; +import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell-node"; +import {showCellPropertiesForm} from "../forms/tables"; const neverActive = (): boolean => false; -const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isTableCellNode); +const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); export const table: EditorBasicButtonDefinition = { label: 'Table', @@ -34,8 +35,8 @@ export const tableProperties: EditorButtonDefinition = { icon: tableIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); - if (!$isTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if (!$isCustomTableCellNode(cell)) { return; } @@ -54,8 +55,8 @@ export const clearTableFormatting: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); - if (!$isTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if (!$isCustomTableCellNode(cell)) { return; } @@ -72,8 +73,8 @@ export const resizeTableToContents: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); - if (!$isTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if (!$isCustomTableCellNode(cell)) { return; } @@ -159,8 +160,8 @@ export const rowProperties: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); - if (!$isTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if (!$isCustomTableCellNode(cell)) { return; } @@ -313,11 +314,9 @@ 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({}); + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if ($isCustomTableCellNode(cell)) { + showCellPropertiesForm(cell, context); } }); }, @@ -349,7 +348,7 @@ export const splitCell: EditorButtonDefinition = { }, isActive: neverActive, isDisabled(selection) { - const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode|null; + const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as TableCellNode|null; if (cell) { const merged = cell.getRowSpan() > 1 || cell.getColSpan() > 1; return !merged; diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 9951bfe7f..291b355e7 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -5,6 +5,11 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../../../nodes/custom-table-cell-node"; +import {EditorFormModal} from "../../framework/modals"; +import {$getNodeFromSelection} from "../../../utils/selection"; +import {$getSelection, ElementFormatType} from "lexical"; +import {TableCellHeaderStates} from "@lexical/table"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -49,10 +54,46 @@ const alignmentInput: EditorSelectFormFieldDefinition = { } }; +export function showCellPropertiesForm(cell: CustomTableCellNode, context: EditorUiContext): EditorFormModal { + const styles = cell.getStyles(); + const modalForm = context.manager.createModal('cell_properties'); + modalForm.show({ + width: '', // TODO + height: styles.get('height') || '', + type: cell.getTag(), + h_align: '', // TODO + v_align: styles.get('vertical-align') || '', + border_width: styles.get('border-width') || '', + border_style: styles.get('border-style') || '', + border_color: styles.get('border-color') || '', + background_color: styles.get('background-color') || '', + }); + return modalForm; +} + export const cellProperties: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { - // TODO + // TODO - Set for cell selection range + context.editor.update(() => { + const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); + if ($isCustomTableCellNode(cell)) { + // TODO - Set width + cell.setFormat((formData.get('h_align')?.toString() || '') as ElementFormatType); + cell.updateTag(formData.get('type')?.toString() || ''); + + const styles = cell.getStyles(); + styles.set('height', formData.get('height')?.toString() || ''); + styles.set('vertical-align', formData.get('v_align')?.toString() || ''); + styles.set('border-width', formData.get('border_width')?.toString() || ''); + styles.set('border-style', formData.get('border_style')?.toString() || ''); + styles.set('border-color', formData.get('border_color')?.toString() || ''); + styles.set('background-color', formData.get('background_color')?.toString() || ''); + + cell.setStyles(styles); + } + }); + return true; }, fields: [ @@ -60,31 +101,31 @@ export const cellProperties: EditorFormDefinition = { build() { const generalFields: EditorFormFieldDefinition[] = [ { - label: 'Width', + label: 'Width', // Colgroup width name: 'width', type: 'text', }, { - label: 'Height', + label: 'Height', // inline-style: height name: 'height', type: 'text', }, { - label: 'Cell type', + label: 'Cell type', // element name: 'type', type: 'select', valuesByLabel: { - 'Cell': 'cell', - 'Header cell': 'header', + 'Cell': 'td', + 'Header cell': 'th', } } as EditorSelectFormFieldDefinition, { - ...alignmentInput, + ...alignmentInput, // class: 'align-right/left/center' label: 'Horizontal align', name: 'h_align', }, { - label: 'Vertical align', + label: 'Vertical align', // inline-style: vertical-align name: 'v_align', type: 'select', valuesByLabel: { @@ -98,13 +139,13 @@ export const cellProperties: EditorFormDefinition = { const advancedFields: EditorFormFieldDefinition[] = [ { - label: 'Border width', + label: 'Border width', // inline-style: border-width name: 'border_width', type: 'text', }, - borderStyleInput, - borderColorInput, - backgroundColorInput, + borderStyleInput, // inline-style: border-style + borderColorInput, // inline-style: border-color + backgroundColorInput, // inline-style: background-color ]; return new EditorFormTabs([ @@ -170,7 +211,6 @@ export const rowProperties: EditorFormDefinition = { }, ], }; - export const tableProperties: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { From b3d3b14f79552299ce558083383cf05c2f1a7d90 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 5 Aug 2024 18:49:17 +0100 Subject: [PATCH 065/107] Lexical: Finished off core cell properties functionality --- resources/js/wysiwyg/nodes/custom-table.ts | 90 +----------- resources/js/wysiwyg/todo.md | 2 +- .../js/wysiwyg/ui/defaults/forms/tables.ts | 23 +-- .../ui/framework/helpers/table-resizer.ts | 3 +- resources/js/wysiwyg/utils/dom.ts | 8 ++ resources/js/wysiwyg/utils/tables.ts | 134 ++++++++++++++++++ 6 files changed, 160 insertions(+), 100 deletions(-) create mode 100644 resources/js/wysiwyg/utils/tables.ts diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 32f3ec4fa..99351d852 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -1,8 +1,9 @@ -import {SerializedTableNode, TableNode, TableRowNode} from "@lexical/table"; -import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalEditor, LexicalNode, Spread} from "lexical"; +import {SerializedTableNode, TableNode} from "@lexical/table"; +import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../utils/dom"; +import {getTableColumnWidths} from "../utils/tables"; export type SerializedCustomTableNode = Spread<{ id: string; @@ -111,49 +112,6 @@ export class CustomTableNode extends TableNode { } } -function getTableColumnWidths(table: HTMLTableElement): string[] { - const maxColRow = getMaxColRowFromTable(table); - - const colGroup = table.querySelector('colgroup'); - let widths: string[] = []; - if (colGroup && (colGroup.childElementCount === maxColRow?.childElementCount || !maxColRow)) { - widths = extractWidthsFromRow(colGroup); - } - if (widths.filter(Boolean).length === 0 && maxColRow) { - widths = extractWidthsFromRow(maxColRow); - } - - return widths; -} - -function getMaxColRowFromTable(table: HTMLTableElement): HTMLTableRowElement|null { - const rows = table.querySelectorAll('tr'); - let maxColCount: number = 0; - let maxColRow: HTMLTableRowElement|null = null; - - for (const row of rows) { - if (row.childElementCount > maxColCount) { - maxColRow = row; - maxColCount = row.childElementCount; - } - } - - return maxColRow; -} - -function extractWidthsFromRow(row: HTMLTableRowElement|HTMLTableColElement) { - return [...row.children].map(child => extractWidthFromElement(child as HTMLElement)) -} - -function extractWidthFromElement(element: HTMLElement): string { - let width = element.style.width || element.getAttribute('width'); - if (width && !Number.isNaN(Number(width))) { - width = width + 'px'; - } - - return width || ''; -} - export function $createCustomTableNode(): CustomTableNode { return new CustomTableNode(); } @@ -161,45 +119,3 @@ export function $createCustomTableNode(): CustomTableNode { export function $isCustomTableNode(node: LexicalNode | null | undefined): node is CustomTableNode { return node instanceof CustomTableNode; } - -export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number): void { - const rows = node.getChildren() as TableRowNode[]; - let maxCols = 0; - for (const row of rows) { - const cellCount = row.getChildren().length; - if (cellCount > maxCols) { - maxCols = cellCount; - } - } - - let colWidths = node.getColWidths(); - if (colWidths.length === 0 || colWidths.length < maxCols) { - colWidths = Array(maxCols).fill(''); - } - - if (columnIndex + 1 > colWidths.length) { - console.error(`Attempted to set table column width for column [${columnIndex}] but only ${colWidths.length} columns found`); - } - - colWidths[columnIndex] = width + 'px'; - node.setColWidths(colWidths); -} - -export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number { - const colWidths = node.getColWidths(); - if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) { - return Number(colWidths[columnIndex].replace('px', '')); - } - - // Otherwise, get from table element - const table = editor.getElementByKey(node.__key) as HTMLTableElement|null; - if (table) { - const maxColRow = getMaxColRowFromTable(table); - if (maxColRow && maxColRow.children.length > columnIndex) { - const cell = maxColRow.children[columnIndex]; - return cell.clientWidth; - } - } - - return 0; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index d925711e1..086ca1462 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,7 +3,7 @@ ## In progress - Table features - - Cell properties form logic + - CustomTableCellNode importDOM logic - Merge cell action - Row properties form logic - Table properties form logic diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 291b355e7..1d637b0ee 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -5,11 +5,11 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {$isCustomTableCellNode, CustomTableCellNode} from "../../../nodes/custom-table-cell-node"; +import {CustomTableCellNode} from "../../../nodes/custom-table-cell-node"; import {EditorFormModal} from "../../framework/modals"; -import {$getNodeFromSelection} from "../../../utils/selection"; import {$getSelection, ElementFormatType} from "lexical"; -import {TableCellHeaderStates} from "@lexical/table"; +import {$getTableCellsFromSelection, $setTableCellColumnWidth} from "../../../utils/tables"; +import {formatSizeValue} from "../../../utils/dom"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -61,7 +61,7 @@ export function showCellPropertiesForm(cell: CustomTableCellNode, context: Edito width: '', // TODO height: styles.get('height') || '', type: cell.getTag(), - h_align: '', // TODO + h_align: cell.getFormatType(), v_align: styles.get('vertical-align') || '', border_width: styles.get('border-width') || '', border_style: styles.get('border-style') || '', @@ -74,18 +74,19 @@ export function showCellPropertiesForm(cell: CustomTableCellNode, context: Edito export const cellProperties: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { - // TODO - Set for cell selection range context.editor.update(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if ($isCustomTableCellNode(cell)) { - // TODO - Set width - cell.setFormat((formData.get('h_align')?.toString() || '') as ElementFormatType); + const cells = $getTableCellsFromSelection($getSelection()); + for (const cell of cells) { + const width = formData.get('width')?.toString() || ''; + + $setTableCellColumnWidth(cell, width); cell.updateTag(formData.get('type')?.toString() || ''); + cell.setFormat((formData.get('h_align')?.toString() || '') as ElementFormatType); const styles = cell.getStyles(); - styles.set('height', formData.get('height')?.toString() || ''); + styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); styles.set('vertical-align', formData.get('v_align')?.toString() || ''); - styles.set('border-width', formData.get('border_width')?.toString() || ''); + styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || '')); styles.set('border-style', formData.get('border_style')?.toString() || ''); styles.set('border-color', formData.get('border_color')?.toString() || ''); styles.set('background-color', formData.get('background_color')?.toString() || ''); diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index f312294c5..37f1b6f01 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -1,8 +1,9 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; -import {$getTableColumnWidth, $setTableColumnWidth, CustomTableNode} from "../../../nodes/custom-table"; +import {CustomTableNode} from "../../../nodes/custom-table"; import {TableRowNode} from "@lexical/table"; import {el} from "../../../utils/dom"; +import {$getTableColumnWidth, $setTableColumnWidth} from "../../../utils/tables"; type MarkerDomRecord = {x: HTMLElement, y: HTMLElement}; diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts index dc0872e89..7426ac592 100644 --- a/resources/js/wysiwyg/utils/dom.ts +++ b/resources/js/wysiwyg/utils/dom.ts @@ -21,4 +21,12 @@ export function el(tag: string, attrs: Record = {}, child export function htmlToDom(html: string): Document { const parser = new DOMParser(); return parser.parseFromString(html, 'text/html'); +} + +export function formatSizeValue(size: number | string, defaultSuffix: string = 'px'): string { + if (typeof size === 'number' || /^-?\d+$/.test(size)) { + return `${size}${defaultSuffix}`; + } + + return size; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts new file mode 100644 index 000000000..959c8a423 --- /dev/null +++ b/resources/js/wysiwyg/utils/tables.ts @@ -0,0 +1,134 @@ +import {BaseSelection, LexicalEditor} from "lexical"; +import {$isTableRowNode, $isTableSelection, TableRowNode} from "@lexical/table"; +import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; +import {$getParentOfType} from "./nodes"; +import {$getNodeFromSelection} from "./selection"; +import {formatSizeValue} from "./dom"; + +function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null { + return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null; +} + +export function getTableColumnWidths(table: HTMLTableElement): string[] { + const maxColRow = getMaxColRowFromTable(table); + + const colGroup = table.querySelector('colgroup'); + let widths: string[] = []; + if (colGroup && (colGroup.childElementCount === maxColRow?.childElementCount || !maxColRow)) { + widths = extractWidthsFromRow(colGroup); + } + if (widths.filter(Boolean).length === 0 && maxColRow) { + widths = extractWidthsFromRow(maxColRow); + } + + return widths; +} + +function getMaxColRowFromTable(table: HTMLTableElement): HTMLTableRowElement | null { + const rows = table.querySelectorAll('tr'); + let maxColCount: number = 0; + let maxColRow: HTMLTableRowElement | null = null; + + for (const row of rows) { + if (row.childElementCount > maxColCount) { + maxColRow = row; + maxColCount = row.childElementCount; + } + } + + return maxColRow; +} + +function extractWidthsFromRow(row: HTMLTableRowElement | HTMLTableColElement) { + return [...row.children].map(child => extractWidthFromElement(child as HTMLElement)) +} + +function extractWidthFromElement(element: HTMLElement): string { + let width = element.style.width || element.getAttribute('width'); + if (width && !Number.isNaN(Number(width))) { + width = width + 'px'; + } + + return width || ''; +} + +export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number|string): void { + const rows = node.getChildren() as TableRowNode[]; + let maxCols = 0; + for (const row of rows) { + const cellCount = row.getChildren().length; + if (cellCount > maxCols) { + maxCols = cellCount; + } + } + + let colWidths = node.getColWidths(); + if (colWidths.length === 0 || colWidths.length < maxCols) { + colWidths = Array(maxCols).fill(''); + } + + if (columnIndex + 1 > colWidths.length) { + console.error(`Attempted to set table column width for column [${columnIndex}] but only ${colWidths.length} columns found`); + } + + colWidths[columnIndex] = formatSizeValue(width); + node.setColWidths(colWidths); +} + +export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number { + const colWidths = node.getColWidths(); + if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) { + return Number(colWidths[columnIndex].replace('px', '')); + } + + // Otherwise, get from table element + const table = editor.getElementByKey(node.__key) as HTMLTableElement | null; + if (table) { + const maxColRow = getMaxColRowFromTable(table); + if (maxColRow && maxColRow.children.length > columnIndex) { + const cell = maxColRow.children[columnIndex]; + return cell.clientWidth; + } + } + + return 0; +} + +function $getCellColumnIndex(node: CustomTableCellNode): number { + const row = node.getParent(); + if (!$isTableRowNode(row)) { + return -1; + } + + let index = 0; + const cells = row.getChildren(); + for (const cell of cells) { + let colSpan = cell.getColSpan() || 1; + index += colSpan; + if (cell.getKey() === node.getKey()) { + break; + } + } + + return index - 1; +} + +export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: string): void { + const table = $getTableFromCell(cell) + const index = $getCellColumnIndex(cell); + + if (table && index >= 0) { + $setTableColumnWidth(table, index, width); + } +} + +export function $getTableCellsFromSelection(selection: BaseSelection|null): CustomTableCellNode[] { + if ($isTableSelection(selection)) { + const nodes = selection.getNodes(); + return nodes.filter(n => $isCustomTableCellNode(n)); + } + + const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode; + return cell ? [cell] : []; +} \ No newline at end of file From fcc1c2968d09fee8491bdc1d239539a7f37b41c3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 6 Aug 2024 09:36:37 +0100 Subject: [PATCH 066/107] Lexical: Added table cell node import logic --- .../wysiwyg/nodes/custom-table-cell-node.ts | 144 +++++++++++++++++- resources/js/wysiwyg/todo.md | 1 - 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/resources/js/wysiwyg/nodes/custom-table-cell-node.ts b/resources/js/wysiwyg/nodes/custom-table-cell-node.ts index 693ef5f5b..31504374a 100644 --- a/resources/js/wysiwyg/nodes/custom-table-cell-node.ts +++ b/resources/js/wysiwyg/nodes/custom-table-cell-node.ts @@ -1,7 +1,24 @@ -import {EditorConfig} from "lexical/LexicalEditor"; -import {DOMExportOutput, LexicalEditor, LexicalNode, Spread} from "lexical"; +import { + $createParagraphNode, + $isElementNode, + $isLineBreakNode, + $isTextNode, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + Spread +} from "lexical"; -import {SerializedTableCellNode, TableCellHeaderStates, TableCellNode} from "@lexical/table"; +import { + $createTableCellNode, + $isTableCellNode, + SerializedTableCellNode, + TableCellHeaderStates, + TableCellNode +} from "@lexical/table"; import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; export type SerializedCustomTableCellNode = Spread<{ @@ -54,13 +71,24 @@ export class CustomTableCellNode extends TableCellNode { return element; } - // TODO - Import DOM - updateDOM(prevNode: CustomTableCellNode): boolean { return super.updateDOM(prevNode) || this.__styles !== prevNode.__styles; } + static importDOM(): DOMConversionMap | null { + return { + td: (node: Node) => ({ + conversion: $convertCustomTableCellNodeElement, + priority: 0, + }), + th: (node: Node) => ({ + conversion: $convertCustomTableCellNodeElement, + priority: 0, + }), + }; + } + exportDOM(editor: LexicalEditor): DOMExportOutput { const element = this.createDOM(editor._config); return { @@ -68,6 +96,18 @@ export class CustomTableCellNode extends TableCellNode { }; } + static importJSON(serializedNode: SerializedCustomTableCellNode): CustomTableCellNode { + const node = $createCustomTableCellNode( + serializedNode.headerState, + serializedNode.colSpan, + serializedNode.width, + ); + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + + return node; + } + exportJSON(): SerializedCustomTableCellNode { return { ...super.exportJSON(), @@ -77,6 +117,100 @@ export class CustomTableCellNode extends TableCellNode { } } +function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput { + const output = $convertTableCellNodeElement(domNode); + + if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { + const styleMap = new Map(); + const styleNames = Array.from(domNode.style); + for (const style of styleNames) { + styleMap.set(style, domNode.style.getPropertyValue(style)); + } + output.node.setStyles(styleMap); + } + + return output; +} + +/** + * Function taken from: + * https://github.com/facebook/lexical/blob/e1881a6e409e1541c10dd0b5378f3a38c9dc8c9e/packages/lexical-table/src/LexicalTableCellNode.ts#L289 + * Copyright (c) Meta Platforms, Inc. and affiliates. + * MIT LICENSE + * Modified since copy. + */ +export function $convertTableCellNodeElement( + domNode: Node, +): DOMConversionOutput { + const domNode_ = domNode as HTMLTableCellElement; + const nodeName = domNode.nodeName.toLowerCase(); + + let width: number | undefined = undefined; + + + const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/; + if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) { + width = parseFloat(domNode_.style.width); + } + + const tableCellNode = $createTableCellNode( + nodeName === 'th' + ? TableCellHeaderStates.ROW + : TableCellHeaderStates.NO_STATUS, + domNode_.colSpan, + width, + ); + + tableCellNode.__rowSpan = domNode_.rowSpan; + + const style = domNode_.style; + const textDecoration = style.textDecoration.split(' '); + const hasBoldFontWeight = + style.fontWeight === '700' || style.fontWeight === 'bold'; + const hasLinethroughTextDecoration = textDecoration.includes('line-through'); + const hasItalicFontStyle = style.fontStyle === 'italic'; + const hasUnderlineTextDecoration = textDecoration.includes('underline'); + return { + after: (childLexicalNodes) => { + if (childLexicalNodes.length === 0) { + childLexicalNodes.push($createParagraphNode()); + } + return childLexicalNodes; + }, + forChild: (lexicalNode, parentLexicalNode) => { + if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) { + const paragraphNode = $createParagraphNode(); + if ( + $isLineBreakNode(lexicalNode) && + lexicalNode.getTextContent() === '\n' + ) { + return null; + } + if ($isTextNode(lexicalNode)) { + if (hasBoldFontWeight) { + lexicalNode.toggleFormat('bold'); + } + if (hasLinethroughTextDecoration) { + lexicalNode.toggleFormat('strikethrough'); + } + if (hasItalicFontStyle) { + lexicalNode.toggleFormat('italic'); + } + if (hasUnderlineTextDecoration) { + lexicalNode.toggleFormat('underline'); + } + } + paragraphNode.append(lexicalNode); + return paragraphNode; + } + + return lexicalNode; + }, + node: tableCellNode, + }; +} + + export function $createCustomTableCellNode( headerState: TableCellHeaderState, colSpan = 1, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 086ca1462..ef86bfe53 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,7 +3,6 @@ ## In progress - Table features - - CustomTableCellNode importDOM logic - Merge cell action - Row properties form logic - Table properties form logic From e8532ef4de7d641fabfe86ff313d379711f2209a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 7 Aug 2024 20:32:54 +0100 Subject: [PATCH 067/107] Lexical: Added merge cell logic --- resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/buttons/tables.ts | 8 +- .../helpers/table-selection-handler.ts | 1 - resources/js/wysiwyg/utils/table-map.ts | 96 +++++++++++++++++++ resources/js/wysiwyg/utils/tables.ts | 63 +++++++++++- 5 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 resources/js/wysiwyg/utils/table-map.ts diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index ef86bfe53..2ca9b97dc 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,7 +3,6 @@ ## In progress - Table features - - Merge cell action - Row properties form logic - Table properties form logic - Caption text support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 3b431141f..69d811ce2 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -21,6 +21,7 @@ import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/ import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell-node"; import {showCellPropertiesForm} from "../forms/tables"; +import {$mergeTableCellsInSelection} from "../../../utils/tables"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -328,9 +329,10 @@ 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 + const selection = $getSelection(); + if ($isTableSelection(selection)) { + $mergeTableCellsInSelection(selection); + } }); }, isActive: neverActive, diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts index 0557b37e5..f631fb804 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -1,7 +1,6 @@ import {$getNodeByKey, LexicalEditor} from "lexical"; import {NodeKey} from "lexical/LexicalNode"; import { - $isTableNode, applyTableHandlers, HTMLTableElementWithWithTableSelectionState, TableNode, diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts new file mode 100644 index 000000000..77c4eba45 --- /dev/null +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -0,0 +1,96 @@ +import {CustomTableNode} from "../nodes/custom-table"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; +import {$isTableRowNode} from "@lexical/table"; + +export class TableMap { + + rowCount: number = 0; + columnCount: number = 0; + + // Represents an array (rows*columns in length) of cell nodes from top-left to + // bottom right. Cells may repeat where merged and covering multiple spaces. + cells: CustomTableCellNode[] = []; + + constructor(table: CustomTableNode) { + this.buildCellMap(table); + } + + protected buildCellMap(table: CustomTableNode) { + const rowsAndCells: CustomTableCellNode[][] = []; + const setCell = (x: number, y: number, cell: CustomTableCellNode) => { + if (typeof rowsAndCells[y] === 'undefined') { + rowsAndCells[y] = []; + } + + rowsAndCells[y][x] = cell; + }; + const cellFilled = (x: number, y: number): boolean => !!(rowsAndCells[y] && rowsAndCells[y][x]); + + const rowNodes = table.getChildren().filter(r => $isTableRowNode(r)); + for (let rowIndex = 0; rowIndex < rowNodes.length; rowIndex++) { + const rowNode = rowNodes[rowIndex]; + const cellNodes = rowNode.getChildren().filter(c => $isCustomTableCellNode(c)); + let targetColIndex: number = 0; + for (let cellIndex = 0; cellIndex < cellNodes.length; cellIndex++) { + const cellNode = cellNodes[cellIndex]; + const colspan = cellNode.getColSpan() || 1; + const rowSpan = cellNode.getRowSpan() || 1; + for (let x = targetColIndex; x < targetColIndex + colspan; x++) { + for (let y = rowIndex; y < rowIndex + rowSpan; y++) { + while (cellFilled(x, y)) { + targetColIndex += 1; + x += 1; + } + + setCell(x, y, cellNode); + } + } + targetColIndex += colspan; + } + } + + this.rowCount = rowsAndCells.length; + this.columnCount = Math.max(...rowsAndCells.map(r => r.length)); + + const cells = []; + let lastCell: CustomTableCellNode = rowsAndCells[0][0]; + for (let y = 0; y < this.rowCount; y++) { + for (let x = 0; x < this.columnCount; x++) { + if (!rowsAndCells[y] || !rowsAndCells[y][x]) { + cells.push(lastCell); + } else { + cells.push(rowsAndCells[y][x]); + lastCell = rowsAndCells[y][x]; + } + } + } + + this.cells = cells; + } + + public getCellAtPosition(x: number, y: number): CustomTableCellNode { + const position = (y * this.columnCount) + x; + if (position >= this.cells.length) { + throw new Error(`TableMap Error: Attempted to get cell ${position+1} of ${this.cells.length}`); + } + + return this.cells[position]; + } + + public getCellsInRange(fromX: number, fromY: number, toX: number, toY: number): CustomTableCellNode[] { + const minX = Math.max(Math.min(fromX, toX), 0); + const maxX = Math.min(Math.max(fromX, toX), this.columnCount - 1); + const minY = Math.max(Math.min(fromY, toY), 0); + const maxY = Math.min(Math.max(fromY, toY), this.rowCount - 1); + + const cells = new Set(); + + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + cells.add(this.getCellAtPosition(x, y)); + } + } + + return [...cells.values()]; + } +} diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index 959c8a423..d4ef80f7f 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -1,10 +1,11 @@ import {BaseSelection, LexicalEditor} from "lexical"; -import {$isTableRowNode, $isTableSelection, TableRowNode} from "@lexical/table"; +import {$isTableRowNode, $isTableSelection, TableRowNode, TableSelection, TableSelectionShape} from "@lexical/table"; import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table"; import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; import {$getParentOfType} from "./nodes"; import {$getNodeFromSelection} from "./selection"; import {formatSizeValue} from "./dom"; +import {TableMap} from "./table-map"; function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null { return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null; @@ -131,4 +132,62 @@ export function $getTableCellsFromSelection(selection: BaseSelection|null): Cust const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode; return cell ? [cell] : []; -} \ No newline at end of file +} + +export function $mergeTableCellsInSelection(selection: TableSelection): void { + const selectionShape = selection.getShape(); + const cells = $getTableCellsFromSelection(selection); + if (cells.length === 0) { + return; + } + + const table = $getTableFromCell(cells[0]); + if (!table) { + return; + } + + const tableMap = new TableMap(table); + const headCell = tableMap.getCellAtPosition(selectionShape.toX, selectionShape.toY); + if (!headCell) { + return; + } + + // We have to adjust the shape since it won't take into account spans for the head corner position. + const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1); + const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1); + + const mergeCells = tableMap.getCellsInRange( + selectionShape.fromX, + selectionShape.fromY, + fixedToX, + fixedToY, + ); + + if (mergeCells.length === 0) { + return; + } + + const firstCell = mergeCells[0]; + const newWidth = Math.abs(selectionShape.fromX - fixedToX) + 1; + const newHeight = Math.abs(selectionShape.fromY - fixedToY) + 1; + + for (let i = 1; i < mergeCells.length; i++) { + const mergeCell = mergeCells[i]; + firstCell.append(...mergeCell.getChildren()); + mergeCell.remove(); + } + + firstCell.setColSpan(newWidth); + firstCell.setRowSpan(newHeight); +} + + + + + + + + + + + From da54e1d87c054ad572b5ce20acc153e274a0b46c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 9 Aug 2024 11:24:25 +0100 Subject: [PATCH 068/107] Lexical: Added cell width fetching, Created custom row node --- ...able-cell-node.ts => custom-table-cell.ts} | 16 +-- .../js/wysiwyg/nodes/custom-table-row.ts | 113 ++++++++++++++++++ resources/js/wysiwyg/nodes/index.ts | 17 ++- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 6 +- .../js/wysiwyg/ui/defaults/forms/tables.ts | 55 +++------ resources/js/wysiwyg/utils/styles.ts | 11 ++ resources/js/wysiwyg/utils/table-map.ts | 2 +- resources/js/wysiwyg/utils/tables.ts | 13 +- 8 files changed, 172 insertions(+), 61 deletions(-) rename resources/js/wysiwyg/nodes/{custom-table-cell-node.ts => custom-table-cell.ts} (92%) create mode 100644 resources/js/wysiwyg/nodes/custom-table-row.ts create mode 100644 resources/js/wysiwyg/utils/styles.ts diff --git a/resources/js/wysiwyg/nodes/custom-table-cell-node.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts similarity index 92% rename from resources/js/wysiwyg/nodes/custom-table-cell-node.ts rename to resources/js/wysiwyg/nodes/custom-table-cell.ts index 31504374a..b73a21807 100644 --- a/resources/js/wysiwyg/nodes/custom-table-cell-node.ts +++ b/resources/js/wysiwyg/nodes/custom-table-cell.ts @@ -20,13 +20,14 @@ import { TableCellNode } from "@lexical/table"; import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; +import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; export type SerializedCustomTableCellNode = Spread<{ styles: Record, }, SerializedTableCellNode> export class CustomTableCellNode extends TableCellNode { - __styles: Map = new Map; + __styles: StyleMap = new Map; static getType(): string { return 'custom-table-cell'; @@ -44,12 +45,12 @@ export class CustomTableCellNode extends TableCellNode { return cellNode; } - getStyles(): Map { + getStyles(): StyleMap { const self = this.getLatest(); return new Map(self.__styles); } - setStyles(styles: Map): void { + setStyles(styles: StyleMap): void { const self = this.getWritable(); self.__styles = new Map(styles); } @@ -103,7 +104,7 @@ export class CustomTableCellNode extends TableCellNode { serializedNode.width, ); - node.setStyles(new Map(Object.entries(serializedNode.styles))); + node.setStyles(new Map(Object.entries(serializedNode.styles))); return node; } @@ -121,12 +122,7 @@ function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput const output = $convertTableCellNodeElement(domNode); if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { - const styleMap = new Map(); - const styleNames = Array.from(domNode.style); - for (const style of styleNames) { - styleMap.set(style, domNode.style.getPropertyValue(style)); - } - output.node.setStyles(styleMap); + output.node.setStyles(createStyleMapFromDomStyles(domNode.style)); } return output; diff --git a/resources/js/wysiwyg/nodes/custom-table-row.ts b/resources/js/wysiwyg/nodes/custom-table-row.ts new file mode 100644 index 000000000..effaaa50d --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-table-row.ts @@ -0,0 +1,113 @@ +import { + $createParagraphNode, + $isElementNode, + $isLineBreakNode, + $isTextNode, + DOMConversionMap, + DOMConversionOutput, + EditorConfig, + LexicalNode, + Spread +} from "lexical"; + +import { + $createTableCellNode, + $isTableCellNode, + SerializedTableRowNode, + TableCellHeaderStates, + TableRowNode +} from "@lexical/table"; +import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; +import {NodeKey} from "lexical/LexicalNode"; + +export type SerializedCustomTableRowNode = Spread<{ + styles: Record, +}, SerializedTableRowNode> + +export class CustomTableRowNode extends TableRowNode { + __styles: StyleMap = new Map(); + + constructor(key?: NodeKey) { + super(0, key); + } + + static getType(): string { + return 'custom-table-row'; + } + + static clone(node: CustomTableRowNode): CustomTableRowNode { + const cellNode = new CustomTableRowNode(node.__key); + + cellNode.__styles = new Map(node.__styles); + return cellNode; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = super.createDOM(config); + + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + + return element; + } + + updateDOM(prevNode: CustomTableRowNode): boolean { + return super.updateDOM(prevNode) + || this.__styles !== prevNode.__styles; + } + + static importDOM(): DOMConversionMap | null { + return { + tr: (node: Node) => ({ + conversion: $convertTableRowElement, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedCustomTableRowNode): CustomTableRowNode { + const node = $createCustomTableRowNode(); + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + + return node; + } + + exportJSON(): SerializedCustomTableRowNode { + return { + ...super.exportJSON(), + height: 0, + type: 'custom-table-row', + styles: Object.fromEntries(this.__styles), + }; + } +} + +export function $convertTableRowElement(domNode: Node): DOMConversionOutput { + const rowNode = $createCustomTableRowNode(); + + if (domNode instanceof HTMLElement) { + rowNode.setStyles(createStyleMapFromDomStyles(domNode.style)); + } + + return {node: rowNode}; +} + +export function $createCustomTableRowNode(): CustomTableRowNode { + return new CustomTableRowNode(); +} + +export function $isCustomTableRowNode(node: LexicalNode | null | undefined): node is CustomTableRowNode { + return node instanceof CustomTableRowNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 92f6d2336..81a0c1a0d 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -20,7 +20,8 @@ import {DiagramNode} from "./diagram"; import {EditorUiContext} from "../ui/framework/core"; import {MediaNode} from "./media"; import {CustomListItemNode} from "./custom-list-item"; -import {CustomTableCellNode} from "./custom-table-cell-node"; +import {CustomTableCellNode} from "./custom-table-cell"; +import {CustomTableRowNode} from "./custom-table-row"; /** * Load the nodes for lexical. @@ -33,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor | ListNode, // Todo - Create custom CustomListItemNode, CustomTableNode, - TableRowNode, + CustomTableRowNode, CustomTableCellNode, ImageNode, HorizontalRuleNode, @@ -49,6 +50,12 @@ export function getNodesForPageEditor(): (KlassConstructor | return new CustomParagraphNode(); } }, + { + replace: ListItemNode, + with: (node: ListItemNode) => { + return new CustomListItemNode(node.__value, node.__checked); + } + }, { replace: TableNode, with(node: TableNode) { @@ -56,9 +63,9 @@ export function getNodesForPageEditor(): (KlassConstructor | } }, { - replace: ListItemNode, - with: (node: ListItemNode) => { - return new CustomListItemNode(node.__value, node.__checked); + replace: TableRowNode, + with(node: TableRowNode) { + return new CustomTableRowNode(); } }, { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 69d811ce2..88ea56186 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -19,8 +19,8 @@ import { } from "@lexical/table"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; -import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell-node"; -import {showCellPropertiesForm} from "../forms/tables"; +import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; +import {$showCellPropertiesForm} from "../forms/tables"; import {$mergeTableCellsInSelection} from "../../../utils/tables"; const neverActive = (): boolean => false; @@ -317,7 +317,7 @@ export const cellProperties: EditorButtonDefinition = { context.editor.getEditorState().read(() => { const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); if ($isCustomTableCellNode(cell)) { - showCellPropertiesForm(cell, context); + $showCellPropertiesForm(cell, context); } }); }, diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 1d637b0ee..1c577b72a 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -5,10 +5,10 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {CustomTableCellNode} from "../../../nodes/custom-table-cell-node"; +import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; import {EditorFormModal} from "../../framework/modals"; import {$getSelection, ElementFormatType} from "lexical"; -import {$getTableCellsFromSelection, $setTableCellColumnWidth} from "../../../utils/tables"; +import {$getTableCellColumnWidth, $getTableCellsFromSelection, $setTableCellColumnWidth} from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; const borderStyleInput: EditorSelectFormFieldDefinition = { @@ -54,11 +54,11 @@ const alignmentInput: EditorSelectFormFieldDefinition = { } }; -export function showCellPropertiesForm(cell: CustomTableCellNode, context: EditorUiContext): EditorFormModal { +export function $showCellPropertiesForm(cell: CustomTableCellNode, context: EditorUiContext): EditorFormModal { const styles = cell.getStyles(); const modalForm = context.manager.createModal('cell_properties'); modalForm.show({ - width: '', // TODO + width: $getTableCellColumnWidth(context.editor, cell), height: styles.get('height') || '', type: cell.getTag(), h_align: cell.getFormatType(), @@ -171,45 +171,18 @@ export const rowProperties: EditorFormDefinition = { return true; }, fields: [ + // Removed fields: + // Removed 'Row Type' as we don't currently support thead/tfoot elements + // TinyMCE would move rows up/down into these parents when set + // Removed 'Alignment' since this was broken in our editor (applied alignment class to whole parent table) { - build() { - const generalFields: EditorFormFieldDefinition[] = [ - { - label: 'Row type', - name: 'type', - type: 'select', - valuesByLabel: { - 'Body': 'body', - 'Header': 'header', - 'Footer': 'footer', - } - } as EditorSelectFormFieldDefinition, - alignmentInput, - { - label: 'Height', - name: 'height', - type: 'text', - }, - ]; - - const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, - borderColorInput, - backgroundColorInput, - ]; - - return new EditorFormTabs([ - { - label: 'General', - contents: generalFields, - }, - { - label: 'Advanced', - contents: advancedFields, - } - ]) - } + label: 'Height', // style on tr: height + name: 'height', + type: 'text', }, + borderStyleInput, // style on tr: height + borderColorInput, // style on tr: height + backgroundColorInput, // style on tr: height ], }; export const tableProperties: EditorFormDefinition = { diff --git a/resources/js/wysiwyg/utils/styles.ts b/resources/js/wysiwyg/utils/styles.ts new file mode 100644 index 000000000..8767a7998 --- /dev/null +++ b/resources/js/wysiwyg/utils/styles.ts @@ -0,0 +1,11 @@ + +export type StyleMap = Map; + +export function createStyleMapFromDomStyles(domStyles: CSSStyleDeclaration): StyleMap { + const styleMap: StyleMap = new Map(); + const styleNames: string[] = Array.from(domStyles); + for (const style of styleNames) { + styleMap.set(style, domStyles.getPropertyValue(style)); + } + return styleMap; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts index 77c4eba45..2b7eba62c 100644 --- a/resources/js/wysiwyg/utils/table-map.ts +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -1,5 +1,5 @@ import {CustomTableNode} from "../nodes/custom-table"; -import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; import {$isTableRowNode} from "@lexical/table"; export class TableMap { diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index d4ef80f7f..d92f56c82 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -1,7 +1,7 @@ import {BaseSelection, LexicalEditor} from "lexical"; import {$isTableRowNode, $isTableSelection, TableRowNode, TableSelection, TableSelectionShape} from "@lexical/table"; import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table"; -import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell-node"; +import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; import {$getParentOfType} from "./nodes"; import {$getNodeFromSelection} from "./selection"; import {formatSizeValue} from "./dom"; @@ -124,6 +124,17 @@ export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: strin } } +export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTableCellNode): string { + const table = $getTableFromCell(cell) + const index = $getCellColumnIndex(cell); + if (!table) { + return ''; + } + + const widths = table.getColWidths(); + return (widths.length > index) ? widths[index] : ''; +} + export function $getTableCellsFromSelection(selection: BaseSelection|null): CustomTableCellNode[] { if ($isTableSelection(selection)) { const nodes = selection.getNodes(); From db4208a7eb0e9a18ea3dd8950b59b5727b6e3671 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 9 Aug 2024 12:42:04 +0100 Subject: [PATCH 069/107] Lexical: Linked row properties form up --- resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/buttons/tables.ts | 11 ++++--- .../js/wysiwyg/ui/defaults/forms/tables.ts | 32 +++++++++++++++++-- resources/js/wysiwyg/utils/tables.ts | 14 ++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 2ca9b97dc..cf24ad677 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,7 +3,6 @@ ## In progress - Table features - - Row properties form logic - Table properties form logic - Caption text support - Resize to contents button diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 88ea56186..50353961f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -20,8 +20,9 @@ import { import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; -import {$showCellPropertiesForm} from "../forms/tables"; +import {$showCellPropertiesForm, $showRowPropertiesForm} from "../forms/tables"; import {$mergeTableCellsInSelection} from "../../../utils/tables"; +import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -166,10 +167,10 @@ export const rowProperties: EditorButtonDefinition = { return; } - const row = $getParentOfType(cell, $isTableRowNode); - const modalForm = context.manager.createModal('row_properties'); - modalForm.show({}); - // TODO + const row = $getParentOfType(cell, $isCustomTableRowNode); + if ($isCustomTableRowNode(row)) { + $showRowPropertiesForm(row, context); + } }); }, isActive: neverActive, diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 1c577b72a..c4879efae 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -8,8 +8,14 @@ import {EditorUiContext} from "../../framework/core"; import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; import {EditorFormModal} from "../../framework/modals"; import {$getSelection, ElementFormatType} from "lexical"; -import {$getTableCellColumnWidth, $getTableCellsFromSelection, $setTableCellColumnWidth} from "../../../utils/tables"; +import { + $getTableCellColumnWidth, + $getTableCellsFromSelection, + $getTableRowsFromSelection, + $setTableCellColumnWidth +} from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; +import {CustomTableRowNode} from "../../../nodes/custom-table-row"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -164,10 +170,32 @@ export const cellProperties: EditorFormDefinition = { ], }; +export function $showRowPropertiesForm(row: CustomTableRowNode, context: EditorUiContext): EditorFormModal { + const styles = row.getStyles(); + const modalForm = context.manager.createModal('row_properties'); + modalForm.show({ + height: styles.get('height') || '', + border_style: styles.get('border-style') || '', + border_color: styles.get('border-color') || '', + background_color: styles.get('background-color') || '', + }); + return modalForm; +} + export const rowProperties: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { - // TODO + context.editor.update(() => { + const rows = $getTableRowsFromSelection($getSelection()); + for (const row of rows) { + const styles = row.getStyles(); + styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); + styles.set('border-style', formData.get('border_style')?.toString() || ''); + styles.set('border-color', formData.get('border_color')?.toString() || ''); + styles.set('background-color', formData.get('background_color')?.toString() || ''); + row.setStyles(styles); + } + }); return true; }, fields: [ diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index d92f56c82..e808fd595 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -6,6 +6,7 @@ import {$getParentOfType} from "./nodes"; import {$getNodeFromSelection} from "./selection"; import {formatSizeValue} from "./dom"; import {TableMap} from "./table-map"; +import {$isCustomTableRowNode, CustomTableRowNode} from "../nodes/custom-table-row"; function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null { return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null; @@ -192,6 +193,19 @@ export function $mergeTableCellsInSelection(selection: TableSelection): void { firstCell.setRowSpan(newHeight); } +export function $getTableRowsFromSelection(selection: BaseSelection|null): CustomTableRowNode[] { + const cells = $getTableCellsFromSelection(selection); + const rowsByKey: Record = {}; + for (const cell of cells) { + const row = cell.getParent(); + if ($isCustomTableRowNode(row)) { + rowsByKey[row.getKey()] = row; + } + } + + return Object.values(rowsByKey); +} + From abbfd42a6c33d1c5e90517448add0f5909051d0e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 9 Aug 2024 21:58:45 +0100 Subject: [PATCH 070/107] Lexical: Kinda made row copy/paste work --- .../js/wysiwyg/services/node-clipboard.ts | 56 +++++++++++++++++++ resources/js/wysiwyg/todo.md | 5 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 46 +++++++++++---- 3 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 resources/js/wysiwyg/services/node-clipboard.ts diff --git a/resources/js/wysiwyg/services/node-clipboard.ts b/resources/js/wysiwyg/services/node-clipboard.ts new file mode 100644 index 000000000..7d880db98 --- /dev/null +++ b/resources/js/wysiwyg/services/node-clipboard.ts @@ -0,0 +1,56 @@ +import {$isElementNode, LexicalEditor, LexicalNode, SerializedLexicalNode} from "lexical"; + +type SerializedLexicalNodeWithChildren = { + node: SerializedLexicalNode, + children: SerializedLexicalNodeWithChildren[], +}; + +function serializeNodeRecursive(node: LexicalNode): SerializedLexicalNodeWithChildren { + const childNodes = $isElementNode(node) ? node.getChildren() : []; + return { + node: node.exportJSON(), + children: childNodes.map(n => serializeNodeRecursive(n)), + }; +} + +function unserializeNodeRecursive(editor: LexicalEditor, {node, children}: SerializedLexicalNodeWithChildren): LexicalNode|null { + const instance = editor._nodes.get(node.type)?.klass.importJSON(node); + if (!instance) { + return null; + } + + const childNodes = children.map(child => unserializeNodeRecursive(editor, child)); + for (const child of childNodes) { + if (child && $isElementNode(instance)) { + instance.append(child); + } + } + + return instance; +} + +export class NodeClipboard { + nodeClass: {importJSON: (s: SerializedLexicalNode) => T}; + protected store: SerializedLexicalNodeWithChildren[] = []; + + constructor(nodeClass: {importJSON: (s: any) => T}) { + this.nodeClass = nodeClass; + } + + set(...nodes: LexicalNode[]): void { + this.store.splice(0, this.store.length); + for (const node of nodes) { + this.store.push(serializeNodeRecursive(node)); + } + } + + get(editor: LexicalEditor): LexicalNode[] { + return this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => { + return node !== null; + }); + } + + size(): number { + return this.store.length; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index cf24ad677..b6325688e 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -7,6 +7,7 @@ - Caption text support - Resize to contents button - Remove formatting button + - Cut/Copy/Paste column ## Main Todo @@ -32,4 +33,6 @@ - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. -- Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file +- Template drag/drop not handled when outside core editor area (ignored in margin area). +- Table row copy/paste does not handle merged cells + - TinyMCE fills gaps with the cells that would be visually in the row \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 50353961f..c98f6c02f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -8,7 +8,7 @@ import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; -import {$getSelection, BaseSelection} from "lexical"; +import {$createNodeSelection, $createRangeSelection, $getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { $deleteTableColumn__EXPERIMENTAL, @@ -21,8 +21,11 @@ import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/ import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; import {$showCellPropertiesForm, $showRowPropertiesForm} from "../forms/tables"; -import {$mergeTableCellsInSelection} from "../../../utils/tables"; -import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; +import {$getTableRowsFromSelection, $mergeTableCellsInSelection} from "../../../utils/tables"; +import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row"; +import {NodeClipboard} from "../../../services/node-clipboard"; +import {r} from "@codemirror/legacy-modes/mode/r"; +import {$generateHtmlFromNodes} from "@lexical/html"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -177,12 +180,18 @@ export const rowProperties: EditorButtonDefinition = { isDisabled: cellNotSelected, }; +const rowClipboard: NodeClipboard = new NodeClipboard(CustomTableRowNode); + export const cutRow: EditorButtonDefinition = { label: 'Cut row', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + const rows = $getTableRowsFromSelection($getSelection()); + rowClipboard.set(...rows); + for (const row of rows) { + row.remove(); + } }); }, isActive: neverActive, @@ -194,7 +203,8 @@ export const copyRow: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - // TODO + const rows = $getTableRowsFromSelection($getSelection()); + rowClipboard.set(...rows); }); }, isActive: neverActive, @@ -205,24 +215,36 @@ export const pasteRowBefore: EditorButtonDefinition = { label: 'Paste row before', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + const rows = $getTableRowsFromSelection($getSelection()); + const lastRow = rows[rows.length - 1]; + if (lastRow) { + for (const row of rowClipboard.get(context.editor)) { + lastRow.insertBefore(row); + } + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0, }; export const pasteRowAfter: EditorButtonDefinition = { label: 'Paste row after', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + const rows = $getTableRowsFromSelection($getSelection()); + const lastRow = rows[rows.length - 1]; + if (lastRow) { + for (const row of rowClipboard.get(context.editor).reverse()) { + lastRow.insertAfter(row); + } + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0, }; export const cutColumn: EditorButtonDefinition = { From ebf95f637a199fa4493013933fabf073d4113bb4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 10 Aug 2024 13:14:55 +0100 Subject: [PATCH 071/107] Lexical: Wired table properties, and other buttons --- .../js/wysiwyg/nodes/custom-table-cell.ts | 9 +- .../js/wysiwyg/nodes/custom-table-row.ts | 11 +- resources/js/wysiwyg/nodes/custom-table.ts | 22 +++- resources/js/wysiwyg/todo.md | 9 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 56 ++++------ .../js/wysiwyg/ui/defaults/forms/tables.ts | 76 ++++++++++--- resources/js/wysiwyg/utils/dom.ts | 25 +++++ resources/js/wysiwyg/utils/styles.ts | 11 -- resources/js/wysiwyg/utils/tables.ts | 103 +++++++++++++++++- 9 files changed, 243 insertions(+), 79 deletions(-) delete mode 100644 resources/js/wysiwyg/utils/styles.ts diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts index b73a21807..c8fe58c77 100644 --- a/resources/js/wysiwyg/nodes/custom-table-cell.ts +++ b/resources/js/wysiwyg/nodes/custom-table-cell.ts @@ -20,7 +20,7 @@ import { TableCellNode } from "@lexical/table"; import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; -import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; +import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; export type SerializedCustomTableCellNode = Spread<{ styles: Record, @@ -45,6 +45,11 @@ export class CustomTableCellNode extends TableCellNode { return cellNode; } + clearWidth(): void { + const self = this.getWritable(); + self.__width = undefined; + } + getStyles(): StyleMap { const self = this.getLatest(); return new Map(self.__styles); @@ -122,7 +127,7 @@ function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput const output = $convertTableCellNodeElement(domNode); if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { - output.node.setStyles(createStyleMapFromDomStyles(domNode.style)); + output.node.setStyles(extractStyleMapFromElement(domNode)); } return output; diff --git a/resources/js/wysiwyg/nodes/custom-table-row.ts b/resources/js/wysiwyg/nodes/custom-table-row.ts index effaaa50d..f4702f36d 100644 --- a/resources/js/wysiwyg/nodes/custom-table-row.ts +++ b/resources/js/wysiwyg/nodes/custom-table-row.ts @@ -1,8 +1,4 @@ import { - $createParagraphNode, - $isElementNode, - $isLineBreakNode, - $isTextNode, DOMConversionMap, DOMConversionOutput, EditorConfig, @@ -11,14 +7,11 @@ import { } from "lexical"; import { - $createTableCellNode, - $isTableCellNode, SerializedTableRowNode, - TableCellHeaderStates, TableRowNode } from "@lexical/table"; -import {createStyleMapFromDomStyles, StyleMap} from "../utils/styles"; import {NodeKey} from "lexical/LexicalNode"; +import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; export type SerializedCustomTableRowNode = Spread<{ styles: Record, @@ -98,7 +91,7 @@ export function $convertTableRowElement(domNode: Node): DOMConversionOutput { const rowNode = $createCustomTableRowNode(); if (domNode instanceof HTMLElement) { - rowNode.setStyles(createStyleMapFromDomStyles(domNode.style)); + rowNode.setStyles(extractStyleMapFromElement(domNode)); } return {node: rowNode}; diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 99351d852..1d95b7896 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -2,17 +2,19 @@ import {SerializedTableNode, TableNode} from "@lexical/table"; import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../utils/dom"; +import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom"; import {getTableColumnWidths} from "../utils/tables"; export type SerializedCustomTableNode = Spread<{ id: string; colWidths: string[]; + styles: Record, }, SerializedTableNode> export class CustomTableNode extends TableNode { __id: string = ''; __colWidths: string[] = []; + __styles: StyleMap = new Map; static getType() { return 'custom-table'; @@ -38,10 +40,21 @@ export class CustomTableNode extends TableNode { return self.__colWidths; } + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + static clone(node: CustomTableNode) { const newNode = new CustomTableNode(node.__key); newNode.__id = node.__id; newNode.__colWidths = node.__colWidths; + newNode.__styles = new Map(node.__styles); return newNode; } @@ -65,6 +78,10 @@ export class CustomTableNode extends TableNode { dom.append(colgroup); } + for (const [name, value] of this.__styles.entries()) { + dom.style.setProperty(name, value); + } + return dom; } @@ -79,6 +96,7 @@ export class CustomTableNode extends TableNode { version: 1, id: this.__id, colWidths: this.__colWidths, + styles: Object.fromEntries(this.__styles), }; } @@ -86,6 +104,7 @@ export class CustomTableNode extends TableNode { const node = $createCustomTableNode(); node.setId(serializedNode.id); node.setColWidths(serializedNode.colWidths); + node.setStyles(new Map(Object.entries(serializedNode.styles))); return node; } @@ -102,6 +121,7 @@ export class CustomTableNode extends TableNode { const colWidths = getTableColumnWidths(element as HTMLTableElement); node.setColWidths(colWidths); + node.setStyles(extractStyleMapFromElement(element)); return {node}; }, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index b6325688e..9e501fb24 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,13 +2,6 @@ ## In progress -- Table features - - Table properties form logic - - Caption text support - - Resize to contents button - - Remove formatting button - - Cut/Copy/Paste column - ## Main Todo - Alignments: Use existing classes for blocks (including table cells) @@ -23,6 +16,8 @@ - Drawing gallery integration - 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) +- Table caption text support +- Table Cut/Copy/Paste column ## Secondary Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index c98f6c02f..6242f0b1d 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -8,24 +8,27 @@ import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; -import {$createNodeSelection, $createRangeSelection, $getSelection, BaseSelection} from "lexical"; +import {$getSelection, BaseSelection} from "lexical"; import {$isCustomTableNode} from "../../../nodes/custom-table"; import { $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, $insertTableRow__EXPERIMENTAL, - $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, + $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, } from "@lexical/table"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; -import {$showCellPropertiesForm, $showRowPropertiesForm} from "../forms/tables"; -import {$getTableRowsFromSelection, $mergeTableCellsInSelection} from "../../../utils/tables"; +import {$showCellPropertiesForm, $showRowPropertiesForm, $showTablePropertiesForm} from "../forms/tables"; +import { + $clearTableFormatting, + $clearTableSizes, $getTableFromSelection, + $getTableRowsFromSelection, + $mergeTableCellsInSelection +} from "../../../utils/tables"; import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row"; import {NodeClipboard} from "../../../services/node-clipboard"; -import {r} from "@codemirror/legacy-modes/mode/r"; -import {$generateHtmlFromNodes} from "@lexical/html"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -40,15 +43,10 @@ export const tableProperties: EditorButtonDefinition = { icon: tableIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { - return; + const table = $getTableFromSelection($getSelection()); + if ($isCustomTableNode(table)) { + $showTablePropertiesForm(table, context); } - - const table = $getParentOfType(cell, $isTableNode); - const modalForm = context.manager.createModal('table_properties'); - modalForm.show({}); - // TODO }); }, isActive: neverActive, @@ -59,14 +57,16 @@ export const clearTableFormatting: EditorButtonDefinition = { label: 'Clear table formatting', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { + context.editor.update(() => { const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); if (!$isCustomTableCellNode(cell)) { return; } const table = $getParentOfType(cell, $isTableNode); - // TODO + if ($isCustomTableNode(table)) { + $clearTableFormatting(table); + } }); }, isActive: neverActive, @@ -77,22 +77,15 @@ export const resizeTableToContents: EditorButtonDefinition = { label: 'Resize to contents', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { + context.editor.update(() => { const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); if (!$isCustomTableCellNode(cell)) { return; } const table = $getParentOfType(cell, $isCustomTableNode); - if (!$isCustomTableNode(table)) { - return; - } - - for (const row of table.getChildren()) { - if ($isTableRowNode(row)) { - // TODO - Come back later as this may depend on if we - // are using a custom table row - } + if ($isCustomTableNode(table)) { + $clearTableSizes(table); } }); }, @@ -165,14 +158,9 @@ export const rowProperties: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { - return; - } - - const row = $getParentOfType(cell, $isCustomTableRowNode); - if ($isCustomTableRowNode(row)) { - $showRowPropertiesForm(row, context); + const rows = $getTableRowsFromSelection($getSelection()); + if ($isCustomTableRowNode(rows[0])) { + $showRowPropertiesForm(rows[0], context); } }); }, diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index c4879efae..5a41c85b3 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -9,13 +9,15 @@ import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; import {EditorFormModal} from "../../framework/modals"; import {$getSelection, ElementFormatType} from "lexical"; import { + $forEachTableCell, $getCellPaddingForTable, $getTableCellColumnWidth, - $getTableCellsFromSelection, + $getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection, $setTableCellColumnWidth } from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; import {CustomTableRowNode} from "../../../nodes/custom-table-row"; +import {CustomTableNode} from "../../../nodes/custom-table"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -213,10 +215,58 @@ export const rowProperties: EditorFormDefinition = { backgroundColorInput, // style on tr: height ], }; + +export function $showTablePropertiesForm(table: CustomTableNode, context: EditorUiContext): EditorFormModal { + const styles = table.getStyles(); + const modalForm = context.manager.createModal('table_properties'); + modalForm.show({ + width: styles.get('width') || '', + height: styles.get('height') || '', + cell_spacing: styles.get('cell-spacing') || '', + cell_padding: $getCellPaddingForTable(table), + border_width: styles.get('border-width') || '', + border_style: styles.get('border-style') || '', + border_color: styles.get('border-color') || '', + background_color: styles.get('background-color') || '', + // caption: '', TODO + align: table.getFormatType(), + }); + return modalForm; +} + export const tableProperties: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { - // TODO + context.editor.update(() => { + const table = $getTableFromSelection($getSelection()); + if (!table) { + return; + } + + const styles = table.getStyles(); + styles.set('width', formatSizeValue(formData.get('width')?.toString() || '')); + styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); + styles.set('cell-spacing', formatSizeValue(formData.get('cell_spacing')?.toString() || '')); + styles.set('border-width', formatSizeValue(formData.get('border_width')?.toString() || '')); + styles.set('border-style', formData.get('border_style')?.toString() || ''); + styles.set('border-color', formData.get('border_color')?.toString() || ''); + styles.set('background-color', formData.get('background_color')?.toString() || ''); + table.setStyles(styles); + + table.setFormat(formData.get('align') as ElementFormatType); + + const cellPadding = (formData.get('cell_padding')?.toString() || ''); + if (cellPadding) { + const cellPaddingFormatted = formatSizeValue(cellPadding); + $forEachTableCell(table, (cell: CustomTableCellNode) => { + const styles = cell.getStyles(); + styles.set('padding', cellPaddingFormatted); + cell.setStyles(styles); + }); + } + + // TODO - cell caption + }); return true; }, fields: [ @@ -224,42 +274,42 @@ export const tableProperties: EditorFormDefinition = { build() { const generalFields: EditorFormFieldDefinition[] = [ { - label: 'Width', + label: 'Width', // Style - width name: 'width', type: 'text', }, { - label: 'Height', + label: 'Height', // Style - height name: 'height', type: 'text', }, { - label: 'Cell spacing', + label: 'Cell spacing', // Style - border-spacing name: 'cell_spacing', type: 'text', }, { - label: 'Cell padding', + label: 'Cell padding', // Style - padding on child cells? name: 'cell_padding', type: 'text', }, { - label: 'Border width', + label: 'Border width', // Style - border-width name: 'border_width', type: 'text', }, { - label: 'caption', - name: 'height', + label: 'caption', // Caption element + name: 'caption', type: 'text', // TODO - }, - alignmentInput, + alignmentInput, // alignment class ]; const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, - borderColorInput, - backgroundColorInput, + borderStyleInput, // Style - border-style + borderColorInput, // Style - border-color + backgroundColorInput, // Style - background-color ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts index 7426ac592..a307bdd75 100644 --- a/resources/js/wysiwyg/utils/dom.ts +++ b/resources/js/wysiwyg/utils/dom.ts @@ -29,4 +29,29 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = ' } return size; +} + +export type StyleMap = Map; + +/** + * Creates a map from an element's styles. + * Uses direct attribute value string handling since attempting to iterate + * over .style will expand out any shorthand properties (like 'padding') making + * rather than being representative of the actual properties set. + */ +export function extractStyleMapFromElement(element: HTMLElement): StyleMap { + const map: StyleMap = new Map(); + const styleText= element.getAttribute('style') || ''; + + const rules = styleText.split(';'); + for (const rule of rules) { + const [name, value] = rule.split(':'); + if (!name || !value) { + continue; + } + + map.set(name.trim().toLowerCase(), value.trim()); + } + + return map; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/styles.ts b/resources/js/wysiwyg/utils/styles.ts deleted file mode 100644 index 8767a7998..000000000 --- a/resources/js/wysiwyg/utils/styles.ts +++ /dev/null @@ -1,11 +0,0 @@ - -export type StyleMap = Map; - -export function createStyleMapFromDomStyles(domStyles: CSSStyleDeclaration): StyleMap { - const styleMap: StyleMap = new Map(); - const styleNames: string[] = Array.from(domStyles); - for (const style of styleNames) { - styleMap.set(style, domStyles.getPropertyValue(style)); - } - return styleMap; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index e808fd595..d0fd17e2c 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -206,8 +206,107 @@ export function $getTableRowsFromSelection(selection: BaseSelection|null): Custo return Object.values(rowsByKey); } - - +export function $getTableFromSelection(selection: BaseSelection|null): CustomTableNode|null { + const cells = $getTableCellsFromSelection(selection); + if (cells.length === 0) { + return null; + } + + const table = $getParentOfType(cells[0], $isCustomTableNode); + if ($isCustomTableNode(table)) { + return table; + } + + return null; +} + +export function $clearTableSizes(table: CustomTableNode): void { + table.setColWidths([]); + + // TODO - Extra form things once table properties and extra things + // are supported + + for (const row of table.getChildren()) { + if (!$isCustomTableRowNode(row)) { + continue; + } + + const rowStyles = row.getStyles(); + rowStyles.delete('height'); + rowStyles.delete('width'); + row.setStyles(rowStyles); + + const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + for (const cell of cells) { + const cellStyles = cell.getStyles(); + cellStyles.delete('height'); + cellStyles.delete('width'); + cell.setStyles(cellStyles); + cell.clearWidth(); + } + } +} + +export function $clearTableFormatting(table: CustomTableNode): void { + table.setColWidths([]); + table.setStyles(new Map); + + for (const row of table.getChildren()) { + if (!$isCustomTableRowNode(row)) { + continue; + } + + row.setStyles(new Map); + row.setFormat(''); + + const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + for (const cell of cells) { + cell.setStyles(new Map); + cell.clearWidth(); + cell.setFormat(''); + } + } +} + +/** + * Perform the given callback for each cell in the given table. + * Returning false from the callback stops the function early. + */ +export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTableCellNode) => void|false): void { + outer: for (const row of table.getChildren()) { + if (!$isCustomTableRowNode(row)) { + continue; + } + const cells = row.getChildren(); + for (const cell of cells) { + if (!$isCustomTableCellNode(cell)) { + return; + } + const result = callback(cell); + if (result === false) { + break outer; + } + } + } +} + +export function $getCellPaddingForTable(table: CustomTableNode): string { + let padding: string|null = null; + + $forEachTableCell(table, (cell: CustomTableCellNode) => { + const cellPadding = cell.getStyles().get('padding') || '' + if (padding === null) { + padding = cellPadding; + } + + if (cellPadding !== padding) { + padding = null; + return false; + } + }); + + return padding || ''; +} From ec965f28c09bf18cab2b615716d902d31ff49cfd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 11 Aug 2024 16:08:51 +0100 Subject: [PATCH 072/107] Lexical: Added id support for all main block types --- resources/js/wysiwyg/index.ts | 5 + resources/js/wysiwyg/nodes/callout.ts | 35 ++++- resources/js/wysiwyg/nodes/code-block.ts | 22 +++- resources/js/wysiwyg/nodes/custom-heading.ts | 120 ++++++++++++++++++ resources/js/wysiwyg/nodes/custom-list.ts | 92 ++++++++++++++ .../js/wysiwyg/nodes/custom-paragraph.ts | 9 +- resources/js/wysiwyg/nodes/custom-quote.ts | 89 +++++++++++++ resources/js/wysiwyg/nodes/details.ts | 58 ++++++--- resources/js/wysiwyg/nodes/diagram.ts | 15 ++- resources/js/wysiwyg/nodes/horizontal-rule.ts | 56 ++++++-- resources/js/wysiwyg/nodes/index.ts | 31 ++++- resources/js/wysiwyg/nodes/media.ts | 10 +- resources/js/wysiwyg/todo.md | 3 +- 13 files changed, 486 insertions(+), 59 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/custom-heading.ts create mode 100644 resources/js/wysiwyg/nodes/custom-list.ts create mode 100644 resources/js/wysiwyg/nodes/custom-quote.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9da646a77..a07fbd789 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -82,6 +82,11 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st } }); + // @ts-ignore + window.debugEditorState = () => { + console.log(editor.getEditorState().toJSON()); + }; + const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); registerCommonNodeMutationListeners(context); diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index e39dcc3ee..b720b5c43 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -9,15 +9,17 @@ import { } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; import type {RangeSelection} from "lexical/LexicalSelection"; +import {el} from "../utils/dom"; export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success'; export type SerializedCalloutNode = Spread<{ category: CalloutCategory; + id: string; }, SerializedElementNode> export class CalloutNode extends ElementNode { - + __id: string = ''; __category: CalloutCategory = 'info'; static getType() { @@ -25,7 +27,9 @@ export class CalloutNode extends ElementNode { } static clone(node: CalloutNode) { - return new CalloutNode(node.__category, node.__key); + const newNode = new CalloutNode(node.__category, node.__key); + newNode.__id = node.__id; + return newNode; } constructor(category: CalloutCategory, key?: string) { @@ -43,9 +47,22 @@ export class CalloutNode extends ElementNode { return self.__category; } + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('p'); element.classList.add('callout', this.__category || ''); + if (this.__id) { + element.setAttribute('id', this.__id); + } return element; } @@ -88,8 +105,13 @@ export class CalloutNode extends ElementNode { } } + const node = new CalloutNode(category); + if (element.id) { + node.setId(element.id); + } + return { - node: new CalloutNode(category), + node, }; }, priority: 3, @@ -106,11 +128,14 @@ export class CalloutNode extends ElementNode { type: 'callout', version: 1, category: this.__category, + id: this.__id, }; } static importJSON(serializedNode: SerializedCalloutNode): CalloutNode { - return $createCalloutNode(serializedNode.category); + const node = $createCalloutNode(serializedNode.category); + node.setId(serializedNode.id); + return node; } } @@ -119,7 +144,7 @@ export function $createCalloutNode(category: CalloutCategory = 'info') { return new CalloutNode(category); } -export function $isCalloutNode(node: LexicalNode | null | undefined) { +export function $isCalloutNode(node: LexicalNode | null | undefined): node is CalloutNode { return node instanceof CalloutNode; } diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts index e240a3887..a71e21e2e 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -2,7 +2,7 @@ import { DecoratorNode, DOMConversion, DOMConversionMap, - DOMConversionOutput, + DOMConversionOutput, DOMExportOutput, LexicalEditor, LexicalNode, SerializedLexicalNode, Spread @@ -33,7 +33,9 @@ export class CodeBlockNode extends DecoratorNode { } static clone(node: CodeBlockNode): CodeBlockNode { - return new CodeBlockNode(node.__language, node.__code); + const newNode = new CodeBlockNode(node.__language, node.__code); + newNode.__id = node.__id; + return newNode; } constructor(language: string = '', code: string = '', key?: string) { @@ -118,6 +120,13 @@ export class CodeBlockNode extends DecoratorNode { return false; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const dom = this.createDOM(editor._config, editor); + return { + element: dom.querySelector('pre') as HTMLElement, + }; + } + static importDOM(): DOMConversionMap|null { return { pre(node: HTMLElement): DOMConversion|null { @@ -130,10 +139,13 @@ export class CodeBlockNode extends DecoratorNode { || ''; const code = codeEl ? (codeEl.textContent || '').trim() : (element.textContent || '').trim(); + const node = $createCodeBlockNode(language, code); - return { - node: $createCodeBlockNode(language, code), - }; + if (element.id) { + node.setId(element.id); + } + + return { node }; }, priority: 3, }; diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts new file mode 100644 index 000000000..dba49898c --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-heading.ts @@ -0,0 +1,120 @@ +import { + DOMConversionMap, + DOMConversionOutput, ElementFormatType, + LexicalNode, + Spread +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text"; + + +export type SerializedCustomHeadingNode = Spread<{ + id: string; +}, SerializedHeadingNode> + +export class CustomHeadingNode extends HeadingNode { + __id: string = ''; + + static getType() { + return 'custom-heading'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: CustomHeadingNode) { + const newNode = new CustomHeadingNode(node.__tag, node.__key); + newNode.__id = node.__id; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + if (this.__id) { + dom.setAttribute('id', this.__id); + } + + return dom; + } + + exportJSON(): SerializedCustomHeadingNode { + return { + ...super.exportJSON(), + type: 'custom-heading', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode { + const node = $createCustomHeadingNode(serializedNode.tag); + node.setId(serializedNode.id); + return node; + } + + static importDOM(): DOMConversionMap | null { + return { + h1: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h2: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h3: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h4: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h5: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h6: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + }; + } +} + +function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { + const nodeName = element.nodeName.toLowerCase(); + let node = null; + if ( + nodeName === 'h1' || + nodeName === 'h2' || + nodeName === 'h3' || + nodeName === 'h4' || + nodeName === 'h5' || + nodeName === 'h6' + ) { + node = $createCustomHeadingNode(nodeName); + if (element.style !== null) { + node.setFormat(element.style.textAlign as ElementFormatType); + } + if (element.id) { + node.setId(element.id); + } + } + return {node}; +} + +export function $createCustomHeadingNode(tag: HeadingTagType) { + return new CustomHeadingNode(tag); +} + +export function $isCustomHeadingNode(node: LexicalNode | null | undefined): node is CustomHeadingNode { + return node instanceof CustomHeadingNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-list.ts b/resources/js/wysiwyg/nodes/custom-list.ts new file mode 100644 index 000000000..953bcb8cd --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-list.ts @@ -0,0 +1,92 @@ +import { + DOMConversionFn, + DOMConversionMap, + LexicalNode, + Spread +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {ListNode, ListType, SerializedListNode} from "@lexical/list"; + + +export type SerializedCustomListNode = Spread<{ + id: string; +}, SerializedListNode> + +export class CustomListNode extends ListNode { + __id: string = ''; + + static getType() { + return 'custom-list'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: CustomListNode) { + const newNode = new CustomListNode(node.__listType, 0, node.__key); + newNode.__id = node.__id; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + if (this.__id) { + dom.setAttribute('id', this.__id); + } + + return dom; + } + + exportJSON(): SerializedCustomListNode { + return { + ...super.exportJSON(), + type: 'custom-list', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedCustomListNode): CustomListNode { + const node = $createCustomListNode(serializedNode.listType); + node.setId(serializedNode.id); + return node; + } + + static importDOM(): DOMConversionMap | null { + // @ts-ignore + const converter = super.importDOM().ol().conversion as DOMConversionFn; + const customConvertFunction = (element: HTMLElement) => { + const baseResult = converter(element); + if (element.id && baseResult?.node) { + (baseResult.node as CustomListNode).setId(element.id); + } + return baseResult; + }; + + return { + ol: () => ({ + conversion: customConvertFunction, + priority: 0, + }), + ul: () => ({ + conversion: customConvertFunction, + priority: 0, + }), + }; + } +} + +export function $createCustomListNode(type: ListType): CustomListNode { + return new CustomListNode(type, 0); +} + +export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode { + return node instanceof CustomListNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts index 97647bf5e..cb936a559 100644 --- a/resources/js/wysiwyg/nodes/custom-paragraph.ts +++ b/resources/js/wysiwyg/nodes/custom-paragraph.ts @@ -31,7 +31,7 @@ export class CustomParagraphNode extends ParagraphNode { return self.__id; } - static clone(node: CustomParagraphNode) { + static clone(node: CustomParagraphNode): CustomParagraphNode { const newNode = new CustomParagraphNode(node.__key); newNode.__id = node.__id; return newNode; @@ -39,9 +39,8 @@ export class CustomParagraphNode extends ParagraphNode { createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); - const id = this.getId(); - if (id) { - dom.setAttribute('id', id); + if (this.__id) { + dom.setAttribute('id', this.__id); } return dom; @@ -89,7 +88,7 @@ export class CustomParagraphNode extends ParagraphNode { } } -export function $createCustomParagraphNode() { +export function $createCustomParagraphNode(): CustomParagraphNode { return new CustomParagraphNode(); } diff --git a/resources/js/wysiwyg/nodes/custom-quote.ts b/resources/js/wysiwyg/nodes/custom-quote.ts new file mode 100644 index 000000000..58c62f769 --- /dev/null +++ b/resources/js/wysiwyg/nodes/custom-quote.ts @@ -0,0 +1,89 @@ +import { + DOMConversionMap, + DOMConversionOutput, ElementFormatType, + LexicalNode, + Spread +} from "lexical"; +import {EditorConfig} from "lexical/LexicalEditor"; +import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text"; + + +export type SerializedCustomQuoteNode = Spread<{ + id: string; +}, SerializedQuoteNode> + +export class CustomQuoteNode extends QuoteNode { + __id: string = ''; + + static getType() { + return 'custom-quote'; + } + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: CustomQuoteNode) { + const newNode = new CustomQuoteNode(node.__key); + newNode.__id = node.__id; + return newNode; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + if (this.__id) { + dom.setAttribute('id', this.__id); + } + + return dom; + } + + exportJSON(): SerializedCustomQuoteNode { + return { + ...super.exportJSON(), + type: 'custom-quote', + version: 1, + id: this.__id, + }; + } + + static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode { + const node = $createCustomQuoteNode(); + node.setId(serializedNode.id); + return node; + } + + static importDOM(): DOMConversionMap | null { + return { + blockquote: (node: Node) => ({ + conversion: $convertBlockquoteElement, + priority: 0, + }), + }; + } +} + +function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { + const node = $createCustomQuoteNode(); + if (element.style !== null) { + node.setFormat(element.style.textAlign as ElementFormatType); + } + if (element.id) { + node.setId(element.id); + } + return {node}; +} + +export function $createCustomQuoteNode() { + return new CustomQuoteNode(); +} + +export function $isCustomQuoteNode(node: LexicalNode | null | undefined): node is CustomQuoteNode { + return node instanceof CustomQuoteNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/details.ts b/resources/js/wysiwyg/nodes/details.ts index 8071d5e8f..119619da6 100644 --- a/resources/js/wysiwyg/nodes/details.ts +++ b/resources/js/wysiwyg/nodes/details.ts @@ -4,28 +4,50 @@ import { ElementNode, LexicalEditor, LexicalNode, - SerializedElementNode, + SerializedElementNode, Spread, } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../utils/dom"; +export type SerializedDetailsNode = Spread<{ + id: string; +}, SerializedElementNode> + export class DetailsNode extends ElementNode { + __id: string = ''; static getType() { return 'details'; } - static clone(node: DetailsNode) { - return new DetailsNode(node.__key); + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + static clone(node: DetailsNode): DetailsNode { + const newNode = new DetailsNode(node.__key); + newNode.__id = node.__id; + return newNode; } createDOM(_config: EditorConfig, _editor: LexicalEditor) { - return el('details'); + const el = document.createElement('details'); + if (this.__id) { + el.setAttribute('id', this.__id); + } + + return el; } updateDOM(prevNode: DetailsNode, dom: HTMLElement) { - return false; + return prevNode.__id !== this.__id; } static importDOM(): DOMConversionMap|null { @@ -33,9 +55,12 @@ export class DetailsNode extends ElementNode { details(node: HTMLElement): DOMConversion|null { return { conversion: (element: HTMLElement): DOMConversionOutput|null => { - return { - node: new DetailsNode(), - }; + const node = new DetailsNode(); + if (element.id) { + node.setId(element.id); + } + + return {node}; }, priority: 3, }; @@ -43,16 +68,19 @@ export class DetailsNode extends ElementNode { }; } - exportJSON(): SerializedElementNode { + exportJSON(): SerializedDetailsNode { return { ...super.exportJSON(), type: 'details', version: 1, + id: this.__id, }; } - static importJSON(serializedNode: SerializedElementNode): DetailsNode { - return $createDetailsNode(); + static importJSON(serializedNode: SerializedDetailsNode): DetailsNode { + const node = $createDetailsNode(); + node.setId(serializedNode.id); + return node; } } @@ -61,7 +89,7 @@ export function $createDetailsNode() { return new DetailsNode(); } -export function $isDetailsNode(node: LexicalNode | null | undefined) { +export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode { return node instanceof DetailsNode; } @@ -106,16 +134,16 @@ export class SummaryNode extends ElementNode { }; } - static importJSON(serializedNode: SerializedElementNode): DetailsNode { + static importJSON(serializedNode: SerializedElementNode): SummaryNode { return $createSummaryNode(); } } -export function $createSummaryNode() { +export function $createSummaryNode(): SummaryNode { return new SummaryNode(); } -export function $isSummaryNode(node: LexicalNode | null | undefined) { +export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode { return node instanceof SummaryNode; } diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts index 76d939248..e2ffeaadd 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -30,7 +30,9 @@ export class DiagramNode extends DecoratorNode { } static clone(node: DiagramNode): DiagramNode { - return new DiagramNode(node.__drawingId, node.__drawingUrl); + const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl); + newNode.__id = node.__id; + return newNode; } constructor(drawingId: string, drawingUrl: string, key?: string) { @@ -120,10 +122,13 @@ export class DiagramNode extends DecoratorNode { const img = element.querySelector('img'); const drawingUrl = img?.getAttribute('src') || ''; const drawingId = element.getAttribute('drawio-diagram') || ''; + const node = $createDiagramNode(drawingId, drawingUrl); - return { - node: $createDiagramNode(drawingId, drawingUrl), - }; + if (element.id) { + node.setId(element.id); + } + + return { node }; }, priority: 3, }; @@ -152,7 +157,7 @@ export function $createDiagramNode(drawingId: string = '', drawingUrl: string = return new DiagramNode(drawingId, drawingUrl); } -export function $isDiagramNode(node: LexicalNode | null | undefined) { +export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { return node instanceof DiagramNode; } diff --git a/resources/js/wysiwyg/nodes/horizontal-rule.ts b/resources/js/wysiwyg/nodes/horizontal-rule.ts index fbd019e72..e881d4688 100644 --- a/resources/js/wysiwyg/nodes/horizontal-rule.ts +++ b/resources/js/wysiwyg/nodes/horizontal-rule.ts @@ -4,26 +4,48 @@ import { ElementNode, LexicalEditor, LexicalNode, - SerializedElementNode, + SerializedElementNode, Spread, } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; +export type SerializedHorizontalRuleNode = Spread<{ + id: string; +}, SerializedElementNode> + export class HorizontalRuleNode extends ElementNode { + __id: string = ''; static getType() { return 'horizontal-rule'; } + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + static clone(node: HorizontalRuleNode): HorizontalRuleNode { - return new HorizontalRuleNode(node.__key); + const newNode = new HorizontalRuleNode(node.__key); + newNode.__id = node.__id; + return newNode; } - createDOM(_config: EditorConfig, _editor: LexicalEditor) { - return document.createElement('hr'); + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + const el = document.createElement('hr'); + if (this.__id) { + el.setAttribute('id', this.__id); + } + + return el; } - updateDOM(prevNode: unknown, dom: HTMLElement) { - return false; + updateDOM(prevNode: HorizontalRuleNode, dom: HTMLElement) { + return prevNode.__id !== this.__id; } static importDOM(): DOMConversionMap|null { @@ -31,9 +53,12 @@ export class HorizontalRuleNode extends ElementNode { hr(node: HTMLElement): DOMConversion|null { return { conversion: (element: HTMLElement): DOMConversionOutput|null => { - return { - node: new HorizontalRuleNode(), - }; + const node = new HorizontalRuleNode(); + if (element.id) { + node.setId(element.id); + } + + return {node}; }, priority: 3, }; @@ -41,24 +66,27 @@ export class HorizontalRuleNode extends ElementNode { }; } - exportJSON(): SerializedElementNode { + exportJSON(): SerializedHorizontalRuleNode { return { ...super.exportJSON(), type: 'horizontal-rule', version: 1, + id: this.__id, }; } - static importJSON(serializedNode: SerializedElementNode): HorizontalRuleNode { - return $createHorizontalRuleNode(); + static importJSON(serializedNode: SerializedHorizontalRuleNode): HorizontalRuleNode { + const node = $createHorizontalRuleNode(); + node.setId(serializedNode.id); + return node; } } -export function $createHorizontalRuleNode() { +export function $createHorizontalRuleNode(): HorizontalRuleNode { return new HorizontalRuleNode(); } -export function $isHorizontalRuleNode(node: LexicalNode | null | undefined) { +export function $isHorizontalRuleNode(node: LexicalNode | null | undefined): node is HorizontalRuleNode { return node instanceof HorizontalRuleNode; } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 81a0c1a0d..8cbec20da 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -22,16 +22,19 @@ import {MediaNode} from "./media"; import {CustomListItemNode} from "./custom-list-item"; import {CustomTableCellNode} from "./custom-table-cell"; import {CustomTableRowNode} from "./custom-table-row"; +import {CustomHeadingNode} from "./custom-heading"; +import {CustomQuoteNode} from "./custom-quote"; +import {CustomListNode} from "./custom-list"; /** * Load the nodes for lexical. */ export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ - CalloutNode, // Todo - Create custom - HeadingNode, // Todo - Create custom - QuoteNode, // Todo - Create custom - ListNode, // Todo - Create custom + CalloutNode, + CustomHeadingNode, + CustomQuoteNode, + CustomListNode, CustomListItemNode, CustomTableNode, CustomTableRowNode, @@ -42,7 +45,7 @@ export function getNodesForPageEditor(): (KlassConstructor | CodeBlockNode, DiagramNode, MediaNode, - CustomParagraphNode, + CustomParagraphNode, // TODO - ID LinkNode, { replace: ParagraphNode, @@ -50,6 +53,24 @@ export function getNodesForPageEditor(): (KlassConstructor | return new CustomParagraphNode(); } }, + { + replace: HeadingNode, + with: (node: HeadingNode) => { + return new CustomHeadingNode(node.__tag); + } + }, + { + replace: QuoteNode, + with: (node: QuoteNode) => { + return new CustomQuoteNode(); + } + }, + { + replace: ListNode, + with: (node: ListNode) => { + return new CustomListNode(node.getListType(), node.getStart()); + } + }, { replace: ListItemNode, with: (node: ListItemNode) => { diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index aba4f6c37..73208cb2e 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -66,7 +66,6 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode { } export class MediaNode extends ElementNode { - __tag: MediaNodeTag; __attributes: Record = {}; __sources: MediaNodeSource[] = []; @@ -76,7 +75,10 @@ export class MediaNode extends ElementNode { } static clone(node: MediaNode) { - return new MediaNode(node.__tag, node.__key); + const newNode = new MediaNode(node.__tag, node.__key); + newNode.__attributes = Object.assign({}, node.__attributes); + newNode.__sources = node.__sources.map(s => Object.assign({}, s)); + return newNode; } constructor(tag: MediaNodeTag, key?: string) { @@ -226,10 +228,10 @@ export function $createMediaNodeFromSrc(src: string): MediaNode { return new MediaNode(nodeTag); } -export function $isMediaNode(node: LexicalNode | null | undefined) { +export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode { return node instanceof MediaNode; } -export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag) { +export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean { return node instanceof MediaNode && (node as MediaNode).getTag() === tag; } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 9e501fb24..c8a0293d5 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,13 +2,14 @@ ## In progress +// + ## Main Todo - Alignments: Use existing classes for blocks (including table cells) - Alignments: Handle inline block content (image, video) - Image paste upload - Keyboard shortcuts support -- Add ID support to all block types - Link popup menu for cross-content reference - Link heading-based ID reference menu - Image gallery integration for insert From accf2565a06b05f4db260e761a55d4857404eed2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 13 Aug 2024 19:36:18 +0100 Subject: [PATCH 073/107] Lexical: Integrated image manager to image button/form --- resources/icons/editor/image-search.svg | 1 + resources/js/wysiwyg/nodes/diagram.ts | 71 +------------------ resources/js/wysiwyg/todo.md | 4 +- resources/js/wysiwyg/ui/decorators/diagram.ts | 3 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 34 ++++----- .../js/wysiwyg/ui/defaults/forms/objects.ts | 71 +++++++++++++++---- .../ui/framework/blocks/action-field.ts | 26 +++++++ resources/js/wysiwyg/ui/framework/manager.ts | 15 +++- resources/js/wysiwyg/ui/framework/modals.ts | 9 ++- resources/js/wysiwyg/utils/diagrams.ts | 70 ++++++++++++++++++ resources/js/wysiwyg/utils/images.ts | 26 +++++++ resources/sass/_editor.scss | 10 +++ 12 files changed, 231 insertions(+), 109 deletions(-) create mode 100644 resources/icons/editor/image-search.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/action-field.ts create mode 100644 resources/js/wysiwyg/utils/diagrams.ts create mode 100644 resources/js/wysiwyg/utils/images.ts diff --git a/resources/icons/editor/image-search.svg b/resources/icons/editor/image-search.svg new file mode 100644 index 000000000..b8cb2cfc8 --- /dev/null +++ b/resources/icons/editor/image-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/nodes/diagram.ts index e2ffeaadd..bd37b200c 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/nodes/diagram.ts @@ -3,15 +3,12 @@ import { DOMConversion, DOMConversionMap, DOMConversionOutput, - LexicalEditor, LexicalNode, + LexicalEditor, SerializedLexicalNode, Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import * as DrawIO from '../../services/drawio'; -import {EditorUiContext} from "../ui/framework/core"; -import {HttpError} from "../../services/http"; import {el} from "../utils/dom"; export type SerializedDiagramNode = Spread<{ @@ -156,69 +153,3 @@ export class DiagramNode extends DecoratorNode { export function $createDiagramNode(drawingId: string = '', drawingUrl: string = ''): DiagramNode { return new DiagramNode(drawingId, drawingUrl); } - -export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { - return node instanceof DiagramNode; -} - - -function handleUploadError(error: HttpError, context: EditorUiContext): void { - if (error.status === 413) { - window.$events.emit('error', context.options.translations.serverUploadLimitText || ''); - } else { - window.$events.emit('error', context.options.translations.imageUploadErrorText || ''); - } - console.error(error); -} - -async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise { - const drawingId = await new Promise((res, rej) => { - editor.getEditorState().read(() => { - const {id: drawingId} = node.getDrawingIdAndUrl(); - res(drawingId); - }); - }); - - return drawingId || ''; -} - -async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise { - DrawIO.close(); - - if (isNew) { - const loadingImage: string = window.baseUrl('/loading.gif'); - context.editor.update(() => { - node.setDrawingIdAndUrl('', loadingImage); - }); - } - - try { - const img = await DrawIO.upload(pngData, context.options.pageId); - context.editor.update(() => { - node.setDrawingIdAndUrl(String(img.id), img.url); - }); - } catch (err) { - if (err instanceof HttpError) { - handleUploadError(err, context); - } - - if (isNew) { - context.editor.update(() => { - node.remove(); - }); - } - - throw new Error(`Failed to save image with error: ${err}`); - } -} - -export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void { - let isNew = false; - DrawIO.show(context.options.drawioUrl, async () => { - const drawingId = await loadDiagramIdFromNode(context.editor, node); - isNew = !drawingId; - return isNew ? '' : DrawIO.load(drawingId); - }, async (pngData: string) => { - return updateDrawingNodeFromData(context, node, pngData, isNew); - }); -} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index c8a0293d5..1b10ef91b 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -// +// ## Main Todo @@ -12,8 +12,6 @@ - Keyboard shortcuts support - Link popup menu for cross-content reference - Link heading-based ID reference menu -- Image gallery integration for insert -- Image gallery integration for form - Drawing gallery integration - 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) diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 7c79f9f41..44d332939 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,8 +1,9 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {BaseSelection} from "lexical"; -import {$openDrawingEditorForNode, DiagramNode} from "../../nodes/diagram"; +import {DiagramNode} from "../../nodes/diagram"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; +import {$openDrawingEditorForNode} from "../../utils/diagrams"; export class DiagramDecorator extends EditorDecorator { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 0eac497fc..f4075a740 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -5,7 +5,7 @@ import { $createNodeSelection, $createTextNode, $getRoot, - $getSelection, + $getSelection, $insertNodes, $setSelection, BaseSelection, ElementNode @@ -20,7 +20,7 @@ import codeBlockIcon from "@icons/editor/code-block.svg"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../../nodes/code-block"; import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; -import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../../nodes/diagram"; +import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; import detailsIcon from "@icons/editor/details.svg"; import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details"; @@ -30,6 +30,9 @@ import { $insertNewBlockNodeAtSelection, $selectionContainsNodeType } from "../../../utils/selection"; +import {$isDiagramNode, $openDrawingEditorForNode} from "../../../utils/diagrams"; +import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; +import {$showImageForm} from "../forms/objects"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', @@ -94,28 +97,19 @@ export const image: EditorButtonDefinition = { label: 'Insert/Edit Image', icon: imageIcon, action(context: EditorUiContext) { - const imageModal = context.manager.createModal('image'); - const selection = context.lastSelection; - const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null; - context.editor.getEditorState().read(() => { - let formDefaults = {}; + const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode) as ImageNode | null; if (selectedImage) { - formDefaults = { - src: selectedImage.getSrc(), - alt: selectedImage.getAltText(), - height: selectedImage.getHeight(), - width: selectedImage.getWidth(), - } - - context.editor.update(() => { - const selection = $createNodeSelection(); - selection.add(selectedImage.getKey()); - $setSelection(selection); - }); + $showImageForm(selectedImage, context); + return; } - imageModal.show(formDefaults); + showImageManager((image) => { + context.editor.update(() => { + const link = $createLinkedImageNodeFromImageData(image); + $insertNodes([link]); + }); + }) }); }, isActive(selection: BaseSelection | null): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index dbb89b18f..c37696695 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -1,31 +1,78 @@ -import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../../framework/forms"; +import { + EditorFormDefinition, + EditorFormField, + EditorFormTabs, + EditorSelectFormFieldDefinition +} from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; import {$createTextNode, $getSelection} from "lexical"; -import {$createImageNode} from "../../../nodes/image"; +import {$isImageNode, ImageNode} from "../../../nodes/image"; import {$createLinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$insertNodeToNearestRoot} from "@lexical/utils"; import {$getNodeFromSelection} from "../../../utils/selection"; +import {EditorFormModal} from "../../framework/modals"; +import {EditorActionField} from "../../framework/blocks/action-field"; +import {EditorButton} from "../../framework/buttons"; +import {showImageManager} from "../../../utils/images"; +import searchImageIcon from "@icons/editor/image-search.svg"; + +export function $showImageForm(image: ImageNode, context: EditorUiContext) { + const imageModal: EditorFormModal = context.manager.createModal('image'); + const height = image.getHeight(); + const width = image.getWidth(); + + const formData = { + src: image.getSrc(), + alt: image.getAltText(), + height: height === 0 ? '' : String(height), + width: width === 0 ? '' : String(width), + }; + + imageModal.show(formData); +} 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]); + const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode); + if ($isImageNode(selectedImage)) { + selectedImage.setSrc(formData.get('src')?.toString() || ''); + selectedImage.setAltText(formData.get('alt')?.toString() || ''); + + selectedImage.setWidth(Number(formData.get('width')?.toString() || '0')); + selectedImage.setHeight(Number(formData.get('height')?.toString() || '0')); + } }); return true; }, fields: [ { - label: 'Source', - name: 'src', - type: 'text', + build() { + return new EditorActionField( + new EditorFormField({ + label: 'Source', + name: 'src', + type: 'text', + }), + new EditorButton({ + label: 'Browse files', + icon: searchImageIcon, + action(context: EditorUiContext) { + showImageManager((image) => { + const modal = context.manager.getActiveModal('image'); + if (modal) { + modal.getForm().setValues({ + src: image.thumbs?.display || image.url, + alt: image.name, + }); + } + }); + } + }), + ); + }, }, { label: 'Alternative description', diff --git a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts new file mode 100644 index 000000000..1f40c2864 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts @@ -0,0 +1,26 @@ +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorFormField} from "../forms"; +import {EditorButton} from "../buttons"; + + +export class EditorActionField extends EditorContainerUiElement { + protected input: EditorFormField; + protected action: EditorButton; + + constructor(input: EditorFormField, action: EditorButton) { + super([input, action]); + + this.input = input; + this.action = action; + } + + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-action-input-container', + }, [ + this.input.getDOMElement(), + this.action.getDOMElement(), + ]); + } +} diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 29d959910..92891b540 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -11,6 +11,7 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void; export class EditorUIManager { protected modalDefinitionsByKey: Record = {}; + protected activeModalsByKey: Record = {}; protected decoratorConstructorsByType: Record = {}; protected decoratorInstancesByNodeKey: Record = {}; protected context: EditorUiContext|null = null; @@ -50,12 +51,24 @@ export class EditorUIManager { throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`); } - const modal = new EditorFormModal(modalDefinition); + const modal = new EditorFormModal(modalDefinition, key); modal.setContext(this.getContext()); return modal; } + setModalActive(key: string, modal: EditorFormModal): void { + this.activeModalsByKey[key] = modal; + } + + setModalInactive(key: string): void { + delete this.activeModalsByKey[key]; + } + + getActiveModal(key: string): EditorFormModal|null { + return this.activeModalsByKey[key]; + } + registerDecoratorType(type: string, decorator: typeof EditorDecorator) { this.decoratorConstructorsByType[type] = decorator; } diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index 1768f6f54..ae69302f6 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -13,10 +13,12 @@ export interface EditorFormModalDefinition extends EditorModalDefinition { export class EditorFormModal extends EditorContainerUiElement { protected definition: EditorFormModalDefinition; + protected key: string; - constructor(definition: EditorFormModalDefinition) { + constructor(definition: EditorFormModalDefinition, key: string) { super([new EditorForm(definition.form)]); this.definition = definition; + this.key = key; } show(defaultValues: Record) { @@ -26,13 +28,16 @@ export class EditorFormModal extends EditorContainerUiElement { const form = this.getForm(); form.setValues(defaultValues); form.setOnCancel(this.hide.bind(this)); + + this.getContext().manager.setModalActive(this.key, this); } hide() { this.getDOMElement().remove(); + this.getContext().manager.setModalInactive(this.key); } - protected getForm(): EditorForm { + getForm(): EditorForm { return this.children[0] as EditorForm; } diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts new file mode 100644 index 000000000..50d7d5b3f --- /dev/null +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -0,0 +1,70 @@ +import {LexicalEditor, LexicalNode} from "lexical"; +import {HttpError} from "../../services/http"; +import {EditorUiContext} from "../ui/framework/core"; +import * as DrawIO from "../../services/drawio"; +import {DiagramNode} from "../nodes/diagram"; + +export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { + return node instanceof DiagramNode; +} + +function handleUploadError(error: HttpError, context: EditorUiContext): void { + if (error.status === 413) { + window.$events.emit('error', context.options.translations.serverUploadLimitText || ''); + } else { + window.$events.emit('error', context.options.translations.imageUploadErrorText || ''); + } + console.error(error); +} + +async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise { + const drawingId = await new Promise((res, rej) => { + editor.getEditorState().read(() => { + const {id: drawingId} = node.getDrawingIdAndUrl(); + res(drawingId); + }); + }); + + return drawingId || ''; +} + +async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise { + DrawIO.close(); + + if (isNew) { + const loadingImage: string = window.baseUrl('/loading.gif'); + context.editor.update(() => { + node.setDrawingIdAndUrl('', loadingImage); + }); + } + + try { + const img = await DrawIO.upload(pngData, context.options.pageId); + context.editor.update(() => { + node.setDrawingIdAndUrl(String(img.id), img.url); + }); + } catch (err) { + if (err instanceof HttpError) { + handleUploadError(err, context); + } + + if (isNew) { + context.editor.update(() => { + node.remove(); + }); + } + + throw new Error(`Failed to save image with error: ${err}`); + } +} + +export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void { + let isNew = false; + DrawIO.show(context.options.drawioUrl, async () => { + const drawingId = await loadDiagramIdFromNode(context.editor, node); + isNew = !drawingId; + return isNew ? '' : DrawIO.load(drawingId); + }, async (pngData: string) => { + return updateDrawingNodeFromData(context, node, pngData, isNew); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts new file mode 100644 index 000000000..89a4a60f0 --- /dev/null +++ b/resources/js/wysiwyg/utils/images.ts @@ -0,0 +1,26 @@ +import {ImageManager} from "../../components"; +import {$createImageNode} from "../nodes/image"; +import {$createLinkNode, LinkNode} from "@lexical/link"; + +type EditorImageData = { + url: string; + thumbs?: {display: string}; + name: string; +}; + +export function showImageManager(callback: (image: EditorImageData) => any) { + const imageManager: ImageManager = window.$components.first('image-manager') as ImageManager; + imageManager.show((image: EditorImageData) => { + callback(image); + }, 'gallery'); +} + +export function $createLinkedImageNodeFromImageData(image: EditorImageData): LinkNode { + const url = image.thumbs?.display || image.url; + const linkNode = $createLinkNode(url, {target: '_blank'}); + const imageNode = $createImageNode(url, { + alt: image.name + }); + linkNode.append(imageNode); + return linkNode; +} \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 0cf145559..379c436f4 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -479,6 +479,16 @@ textarea.editor-form-field-input { .editor-form-tab-contents { width: 360px; } +.editor-action-input-container { + display: flex; + flex-direction: row; + align-items: end; + justify-content: space-between; + gap: .1rem; + .editor-button { + margin-bottom: 12px; + } +} // Editor theme styles .editor-theme-bold { From 1ef40444196e6b3e291a402aa18ddcfca7b35511 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 16 Aug 2024 11:22:12 +0100 Subject: [PATCH 074/107] Lexical: Connected link selector to link form --- resources/js/wysiwyg/todo.md | 4 +- .../js/wysiwyg/ui/defaults/forms/objects.ts | 63 +++++++++++++++---- resources/js/wysiwyg/utils/links.ts | 16 +++++ 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 resources/js/wysiwyg/utils/links.ts diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 1b10ef91b..194832d5f 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -// +- Link heading-based ID reference menu ## Main Todo @@ -10,8 +10,6 @@ - Alignments: Handle inline block content (image, video) - Image paste upload - Keyboard shortcuts support -- Link popup menu for cross-content reference -- Link heading-based ID reference menu - Drawing gallery integration - 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) diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index c37696695..6bd265e6c 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -5,9 +5,9 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {$createTextNode, $getSelection} from "lexical"; +import {$createTextNode, $getSelection, $insertNodes} from "lexical"; import {$isImageNode, ImageNode} from "../../../nodes/image"; -import {$createLinkNode} from "@lexical/link"; +import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$insertNodeToNearestRoot} from "@lexical/utils"; import {$getNodeFromSelection} from "../../../utils/selection"; @@ -16,6 +16,8 @@ import {EditorActionField} from "../../framework/blocks/action-field"; import {EditorButton} from "../../framework/buttons"; import {showImageManager} from "../../../utils/images"; import searchImageIcon from "@icons/editor/image-search.svg"; +import searchIcon from "@icons/search.svg"; +import {showLinkSelector} from "../../../utils/links"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -97,23 +99,62 @@ export const link: EditorFormDefinition = { async action(formData, context: EditorUiContext) { context.editor.update(() => { + const url = formData.get('url')?.toString() || ''; + const title = formData.get('title')?.toString() || '' + const target = formData.get('target')?.toString() || ''; + const text = formData.get('text')?.toString() || ''; + const selection = $getSelection(); + let link = $getNodeFromSelection(selection, $isLinkNode); + if ($isLinkNode(link)) { + link.setURL(url); + link.setTarget(target); + link.setTitle(title); + } else { + link = $createLinkNode(url, { + title: title, + target: target, + }); - const linkNode = $createLinkNode(formData.get('url')?.toString() || '', { - title: formData.get('title')?.toString() || '', - target: formData.get('target')?.toString() || '', - }); - linkNode.append($createTextNode(formData.get('text')?.toString() || '')); + $insertNodes([link]); + } - selection?.insertNodes([linkNode]); + if ($isLinkNode(link)) { + for (const child of link.getChildren()) { + child.remove(true); + } + link.append($createTextNode(text)); + } }); return true; }, fields: [ { - label: 'URL', - name: 'url', - type: 'text', + build() { + return new EditorActionField( + new EditorFormField({ + label: 'URL', + name: 'url', + type: 'text', + }), + new EditorButton({ + label: 'Browse links', + icon: searchIcon, + action(context: EditorUiContext) { + showLinkSelector(entity => { + const modal = context.manager.getActiveModal('link'); + if (modal) { + modal.getForm().setValues({ + url: entity.link, + text: entity.name, + title: entity.name, + }); + } + }); + } + }), + ); + }, }, { label: 'Text to display', diff --git a/resources/js/wysiwyg/utils/links.ts b/resources/js/wysiwyg/utils/links.ts new file mode 100644 index 000000000..03c4a5ef0 --- /dev/null +++ b/resources/js/wysiwyg/utils/links.ts @@ -0,0 +1,16 @@ +import {EntitySelectorPopup} from "../../components"; + +type EditorEntityData = { + link: string; + name: string; +}; + +export function showLinkSelector(callback: (entity: EditorEntityData) => any, selectionText?: string) { + const selector: EntitySelectorPopup = window.$components.first('entity-selector-popup') as EntitySelectorPopup; + selector.show((entity: EditorEntityData) => callback(entity), { + initialValue: selectionText, + searchEndpoint: '/search/entity-selector', + entityTypes: 'page,book,chapter,bookshelf', + entityPermission: 'view', + }); +} \ No newline at end of file From ad6b26ba97b32996455d8fd7f3c3c0f4d3f480af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 16 Aug 2024 12:29:40 +0100 Subject: [PATCH 075/107] Lexical: Added basic URL field header option list May show bad option label names on chrome/safari. This was an easy first pass without loads of extra custom UI since we're using native datalists. --- resources/js/services/util.js | 11 +++ resources/js/wysiwyg/nodes/custom-heading.ts | 4 +- resources/js/wysiwyg/todo.md | 2 +- .../js/wysiwyg/ui/defaults/forms/objects.ts | 5 +- .../ui/framework/blocks/action-field.ts | 5 +- .../wysiwyg/ui/framework/blocks/link-field.ts | 96 +++++++++++++++++++ resources/js/wysiwyg/utils/nodes.ts | 22 ++++- 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/link-field.ts diff --git a/resources/js/services/util.js b/resources/js/services/util.js index 942456d9d..1264d1058 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -84,6 +84,17 @@ export function uniqueId() { return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); } +/** + * Generate a random smaller unique ID. + * + * @returns {string} + */ +export function uniqueIdSmall() { + // eslint-disable-next-line no-bitwise + const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + return S4(); +} + /** * Create a promise that resolves after the given time. * @param {int} timeMs diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts index dba49898c..f069ff160 100644 --- a/resources/js/wysiwyg/nodes/custom-heading.ts +++ b/resources/js/wysiwyg/nodes/custom-heading.ts @@ -30,9 +30,7 @@ export class CustomHeadingNode extends HeadingNode { } static clone(node: CustomHeadingNode) { - const newNode = new CustomHeadingNode(node.__tag, node.__key); - newNode.__id = node.__id; - return newNode; + return new CustomHeadingNode(node.__tag, node.__key); } createDOM(config: EditorConfig): HTMLElement { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 194832d5f..70a3744f3 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Link heading-based ID reference menu +// ## Main Todo diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 6bd265e6c..2ad27f749 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -18,6 +18,7 @@ import {showImageManager} from "../../../utils/images"; import searchImageIcon from "@icons/editor/image-search.svg"; import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; +import {LinkField} from "../../framework/blocks/link-field"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -132,11 +133,11 @@ export const link: EditorFormDefinition = { { build() { return new EditorActionField( - new EditorFormField({ + new LinkField(new EditorFormField({ label: 'URL', name: 'url', type: 'text', - }), + })), new EditorButton({ label: 'Browse links', icon: searchIcon, diff --git a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts index 1f40c2864..b7741321b 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/action-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/action-field.ts @@ -1,14 +1,13 @@ import {EditorContainerUiElement, EditorUiElement} from "../core"; import {el} from "../../../utils/dom"; -import {EditorFormField} from "../forms"; import {EditorButton} from "../buttons"; export class EditorActionField extends EditorContainerUiElement { - protected input: EditorFormField; + protected input: EditorUiElement; protected action: EditorButton; - constructor(input: EditorFormField, action: EditorButton) { + constructor(input: EditorUiElement, action: EditorButton) { super([input, action]); this.input = input; diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts new file mode 100644 index 000000000..5a64cdc30 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -0,0 +1,96 @@ +import {EditorContainerUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorFormField} from "../forms"; +import {CustomHeadingNode} from "../../../nodes/custom-heading"; +import {$getAllNodesOfType} from "../../../utils/nodes"; +import {$isHeadingNode} from "@lexical/rich-text"; +import {uniqueIdSmall} from "../../../../services/util"; + +export class LinkField extends EditorContainerUiElement { + protected input: EditorFormField; + protected headerMap = new Map(); + + constructor(input: EditorFormField) { + super([input]); + + this.input = input; + } + + buildDOM(): HTMLElement { + const listId = 'editor-form-datalist-' + this.input.getName() + '-' + Date.now(); + const inputOuterDOM = this.input.getDOMElement(); + const inputFieldDOM = inputOuterDOM.querySelector('input'); + inputFieldDOM?.setAttribute('list', listId); + inputFieldDOM?.setAttribute('autocomplete', 'off'); + const datalist = el('datalist', {id: listId}); + + const container = el('div', { + class: 'editor-link-field-container', + }, [inputOuterDOM, datalist]); + + inputFieldDOM?.addEventListener('focusin', () => { + this.updateDataList(datalist); + }); + + inputFieldDOM?.addEventListener('input', () => { + const value = inputFieldDOM.value; + const header = this.headerMap.get(value); + if (header) { + this.updateFormFromHeader(header); + } + }); + + return container; + } + + updateFormFromHeader(header: CustomHeadingNode) { + this.getHeaderIdAndText(header).then(({id, text}) => { + console.log('updating form', id, text); + const modal = this.getContext().manager.getActiveModal('link'); + if (modal) { + modal.getForm().setValues({ + url: `#${id}`, + text: text, + title: text, + }); + } + }); + } + + getHeaderIdAndText(header: CustomHeadingNode): Promise<{id: string, text: string}> { + return new Promise((res) => { + this.getContext().editor.update(() => { + let id = header.getId(); + console.log('header', id, header.__id); + if (!id) { + id = 'header-' + uniqueIdSmall(); + header.setId(id); + } + + const text = header.getTextContent(); + res({id, text}); + }); + }); + } + + updateDataList(listEl: HTMLElement) { + this.getContext().editor.getEditorState().read(() => { + const headers = $getAllNodesOfType($isHeadingNode) as CustomHeadingNode[]; + + this.headerMap.clear(); + const listEls: HTMLElement[] = []; + + for (const header of headers) { + const key = 'header-' + header.getKey(); + this.headerMap.set(key, header); + listEls.push(el('option', { + value: key, + label: header.getTextContent().substring(0, 54), + })); + } + + listEl.innerHTML = ''; + listEl.append(...listEls); + }); + } +} diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 8e6c66610..6278186ca 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -1,4 +1,4 @@ -import {$getRoot, $isTextNode, LexicalEditor, LexicalNode} from "lexical"; +import {$getRoot, $isElementNode, $isTextNode, ElementNode, LexicalEditor, LexicalNode} from "lexical"; import {LexicalNodeMatcher} from "../nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; @@ -31,6 +31,26 @@ export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher) return null; } +export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNode): LexicalNode[] { + if (!root) { + root = $getRoot(); + } + + const matches = []; + + for (const child of root.getChildren()) { + if (matcher(child)) { + matches.push(child); + } + + if ($isElementNode(child)) { + matches.push(...$getAllNodesOfType(matcher, child)); + } + } + + return matches; +} + /** * Get the nearest root/block level node for the given position. */ From 0039f893ccb6d43c7cd5bacde7163ecdd32dc3d9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 17 Aug 2024 10:48:34 +0100 Subject: [PATCH 076/107] Lexical: Integrated diagram manager, added menu split button --- resources/icons/caret-down-large.svg | 1 + resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/buttons/objects.ts | 12 ++++++- .../ui/framework/blocks/button-with-menu.ts | 31 +++++++++++++++++++ resources/js/wysiwyg/ui/toolbars.ts | 10 +++--- resources/js/wysiwyg/utils/diagrams.ts | 29 +++++++++++++++-- resources/js/wysiwyg/utils/images.ts | 3 +- resources/sass/_editor.scss | 25 +++++++++++++++ 8 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 resources/icons/caret-down-large.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts diff --git a/resources/icons/caret-down-large.svg b/resources/icons/caret-down-large.svg new file mode 100644 index 000000000..a15ec42c8 --- /dev/null +++ b/resources/icons/caret-down-large.svg @@ -0,0 +1 @@ + diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 70a3744f3..f3a8da404 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ - Alignments: Handle inline block content (image, video) - Image paste upload - Keyboard shortcuts support -- Drawing gallery integration - 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) - Table caption text support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index f4075a740..96a92ff22 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -30,7 +30,7 @@ import { $insertNewBlockNodeAtSelection, $selectionContainsNodeType } from "../../../utils/selection"; -import {$isDiagramNode, $openDrawingEditorForNode} from "../../../utils/diagrams"; +import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; import {$showImageForm} from "../forms/objects"; @@ -184,6 +184,16 @@ export const diagram: EditorButtonDefinition = { } }; +export const diagramManager: EditorButtonDefinition = { + label: 'Drawing manager', + action(context: EditorUiContext) { + showDiagramManagerForInsert(context); + }, + isActive(): boolean { + return false; + } +}; + export const media: EditorButtonDefinition = { label: 'Insert/edit Media', icon: mediaIcon, diff --git a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts new file mode 100644 index 000000000..30dd237f6 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts @@ -0,0 +1,31 @@ +import {EditorContainerUiElement, EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; +import {EditorButton} from "../buttons"; +import {EditorDropdownButton} from "./dropdown-button"; +import caretDownIcon from "@icons/caret-down-large.svg"; + +export class EditorButtonWithMenu extends EditorContainerUiElement { + protected button: EditorButton; + protected dropdownButton: EditorDropdownButton; + + constructor(button: EditorButton, menuItems: EditorUiElement[]) { + super([button]); + + this.button = button; + this.dropdownButton = new EditorDropdownButton({ + button: {label: 'Menu', icon: caretDownIcon}, + showOnHover: false, + direction: 'vertical', + }, menuItems); + this.addChildren(this.dropdownButton); + } + + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-button-with-menu-container', + }, [ + this.button.getDOMElement(), + this.dropdownButton.getDOMElement() + ]); + } +} diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 48e11837c..87ecae03e 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -56,16 +56,15 @@ import {bulletList, numberList, taskList} from "./defaults/buttons/lists"; import { codeBlock, details, - diagram, + diagram, diagramManager, editCodeBlock, horizontalRule, image, link, media, unlink } from "./defaults/buttons/objects"; -import {$isTableNode} from "@lexical/table"; -import {$selectionContainsNodeType} from "../utils/selection"; import {el} from "../utils/dom"; +import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -166,7 +165,10 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(image), new EditorButton(horizontalRule), new EditorButton(codeBlock), - new EditorButton(diagram), + new EditorButtonWithMenu( + new EditorButton(diagram), + [new EditorButton(diagramManager)], + ), new EditorButton(media), new EditorButton(details), ]), diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts index 50d7d5b3f..2dee3ab6b 100644 --- a/resources/js/wysiwyg/utils/diagrams.ts +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -1,8 +1,11 @@ -import {LexicalEditor, LexicalNode} from "lexical"; +import {$getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; import {HttpError} from "../../services/http"; import {EditorUiContext} from "../ui/framework/core"; import * as DrawIO from "../../services/drawio"; -import {DiagramNode} from "../nodes/diagram"; +import {$createDiagramNode, DiagramNode} from "../nodes/diagram"; +import {ImageManager} from "../../components"; +import {EditorImageData} from "./images"; +import {$getNodeFromSelection} from "./selection"; export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { return node instanceof DiagramNode; @@ -67,4 +70,26 @@ export function $openDrawingEditorForNode(context: EditorUiContext, node: Diagra }, async (pngData: string) => { return updateDrawingNodeFromData(context, node, pngData, isNew); }); +} + +export function showDiagramManager(callback: (image: EditorImageData) => any) { + const imageManager: ImageManager = window.$components.first('image-manager') as ImageManager; + imageManager.show((image: EditorImageData) => { + callback(image); + }, 'drawio'); +} + +export function showDiagramManagerForInsert(context: EditorUiContext) { + const selection = context.lastSelection; + showDiagramManager((image: EditorImageData) => { + context.editor.update(() => { + const diagramNode = $createDiagramNode(image.id, image.url); + const selectedDiagram = $getNodeFromSelection(selection, $isDiagramNode); + if ($isDiagramNode(selectedDiagram)) { + selectedDiagram.replace(diagramNode); + } else { + $insertNodes([diagramNode]); + } + }); + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts index 89a4a60f0..a83d55418 100644 --- a/resources/js/wysiwyg/utils/images.ts +++ b/resources/js/wysiwyg/utils/images.ts @@ -2,7 +2,8 @@ import {ImageManager} from "../../components"; import {$createImageNode} from "../nodes/image"; import {$createLinkNode, LinkNode} from "@lexical/link"; -type EditorImageData = { +export type EditorImageData = { + id: string; url: string; thumbs?: {display: string}; name: string; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 379c436f4..78e518bd5 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -82,6 +82,31 @@ body.editor-is-fullscreen { fill: currentColor; display: block; } +.editor-button-with-menu-container { + display: flex; + flex-direction: row; + gap: 0; + align-items: stretch; + border-radius: 4px; + .editor-dropdown-menu-container { + display: flex; + } + .editor-dropdown-menu-container > .editor-dropdown-menu { + top: 100%; + } + .editor-dropdown-menu-container > .editor-button { + padding-inline: 4px; + margin-inline-start: -3px; + svg { + width: 12px; + height: 12px; + } + } + &:hover { + outline: 1px solid #DDD; + outline-offset: -3px; + } +} // Containers .editor-dropdown-menu-container { From 111a313d5125bd890d7df46929802f4621388c95 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 18 Aug 2024 16:51:08 +0100 Subject: [PATCH 077/107] Lexical: Added custom alignment handling for blocks To align with pre-existing use of alignment classes. --- resources/js/wysiwyg/nodes/_common.ts | 66 +++++++++++++++++++ resources/js/wysiwyg/nodes/callout.ts | 40 +++++++---- resources/js/wysiwyg/nodes/custom-heading.ts | 46 ++++++++----- .../js/wysiwyg/nodes/custom-paragraph.ts | 48 +++++++++----- resources/js/wysiwyg/nodes/custom-quote.ts | 42 ++++++++---- .../js/wysiwyg/nodes/custom-table-cell.ts | 26 +++++++- resources/js/wysiwyg/nodes/custom-table.ts | 35 ++++++---- resources/js/wysiwyg/nodes/index.ts | 8 +-- resources/js/wysiwyg/todo.md | 2 +- .../wysiwyg/ui/defaults/buttons/alignments.ts | 10 ++- resources/js/wysiwyg/utils/nodes.ts | 5 ++ resources/sass/_content.scss | 3 + 12 files changed, 250 insertions(+), 81 deletions(-) create mode 100644 resources/js/wysiwyg/nodes/_common.ts diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts new file mode 100644 index 000000000..cc45dc910 --- /dev/null +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -0,0 +1,66 @@ +import {LexicalNode, Spread} from "lexical"; +import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; + +export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; +const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; + +export type SerializedCommonBlockNode = Spread<{ + id: string; + alignment: CommonBlockAlignment; +}, SerializedElementNode> + +export interface NodeHasAlignment { + readonly __alignment: CommonBlockAlignment; + setAlignment(alignment: CommonBlockAlignment): void; + getAlignment(): CommonBlockAlignment; +} + +export interface NodeHasId { + readonly __id: string; + setId(id: string): void; + getId(): string; +} + +interface CommonBlockInterface extends NodeHasId, NodeHasAlignment {} + +export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment { + const textAlignStyle: string = element.style.textAlign || ''; + if (validAlignments.includes(textAlignStyle as CommonBlockAlignment)) { + return textAlignStyle as CommonBlockAlignment; + } + + if (element.classList.contains('align-left')) { + return 'left'; + } else if (element.classList.contains('align-right')) { + return 'right' + } else if (element.classList.contains('align-center')) { + return 'center' + } else if (element.classList.contains('align-justify')) { + return 'justify' + } + + return ''; +} + +export function setCommonBlockPropsFromElement(element: HTMLElement, node: CommonBlockInterface): void { + if (element.id) { + node.setId(element.id); + } + + node.setAlignment(extractAlignmentFromElement(element)); +} + +export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: CommonBlockInterface): boolean { + return nodeA.__id !== nodeB.__id || + nodeA.__alignment !== nodeB.__alignment; +} + +export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void { + if (node.__id) { + element.setAttribute('id', node.__id); + } + + if (node.__alignment) { + element.classList.add('align-' + node.__alignment); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index b720b5c43..8018190c8 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -5,22 +5,27 @@ import { ElementNode, LexicalEditor, LexicalNode, - ParagraphNode, SerializedElementNode, Spread + ParagraphNode, Spread } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; import type {RangeSelection} from "lexical/LexicalSelection"; -import {el} from "../utils/dom"; +import { + CommonBlockAlignment, commonPropertiesDifferent, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success'; export type SerializedCalloutNode = Spread<{ category: CalloutCategory; - id: string; -}, SerializedElementNode> +}, SerializedCommonBlockNode> export class CalloutNode extends ElementNode { __id: string = ''; __category: CalloutCategory = 'info'; + __alignment: CommonBlockAlignment = ''; static getType() { return 'callout'; @@ -57,19 +62,26 @@ export class CalloutNode extends ElementNode { return self.__id; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('p'); element.classList.add('callout', this.__category || ''); - if (this.__id) { - element.setAttribute('id', this.__id); - } + updateElementWithCommonBlockProps(element, this); return element; } - updateDOM(prevNode: unknown, dom: HTMLElement) { - // Returning false tells Lexical that this node does not need its - // DOM element replacing with a new copy from createDOM. - return false; + updateDOM(prevNode: CalloutNode): boolean { + return prevNode.__category !== this.__category || + commonPropertiesDifferent(prevNode, this); } insertNewAfter(selection: RangeSelection, restoreSelection?: boolean): CalloutNode|ParagraphNode { @@ -106,9 +118,7 @@ export class CalloutNode extends ElementNode { } const node = new CalloutNode(category); - if (element.id) { - node.setId(element.id); - } + setCommonBlockPropsFromElement(element, node); return { node, @@ -129,12 +139,14 @@ export class CalloutNode extends ElementNode { version: 1, category: this.__category, id: this.__id, + alignment: this.__alignment, }; } static importJSON(serializedNode: SerializedCalloutNode): CalloutNode { const node = $createCalloutNode(serializedNode.category); node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); return node; } diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts index f069ff160..885622ad3 100644 --- a/resources/js/wysiwyg/nodes/custom-heading.ts +++ b/resources/js/wysiwyg/nodes/custom-heading.ts @@ -1,19 +1,24 @@ import { DOMConversionMap, - DOMConversionOutput, ElementFormatType, + DOMConversionOutput, LexicalNode, Spread } from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text"; +import { + CommonBlockAlignment, commonPropertiesDifferent, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; -export type SerializedCustomHeadingNode = Spread<{ - id: string; -}, SerializedHeadingNode> +export type SerializedCustomHeadingNode = Spread export class CustomHeadingNode extends HeadingNode { __id: string = ''; + __alignment: CommonBlockAlignment = ''; static getType() { return 'custom-heading'; @@ -29,31 +34,47 @@ export class CustomHeadingNode extends HeadingNode { return self.__id; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + static clone(node: CustomHeadingNode) { - return new CustomHeadingNode(node.__tag, node.__key); + const newNode = new CustomHeadingNode(node.__tag, node.__key); + newNode.__alignment = node.__alignment; + return newNode; } createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); - if (this.__id) { - dom.setAttribute('id', this.__id); - } - + updateElementWithCommonBlockProps(dom, this); return dom; } + updateDOM(prevNode: CustomHeadingNode, dom: HTMLElement): boolean { + return super.updateDOM(prevNode, dom) + || commonPropertiesDifferent(prevNode, this); + } + exportJSON(): SerializedCustomHeadingNode { return { ...super.exportJSON(), type: 'custom-heading', version: 1, id: this.__id, + alignment: this.__alignment, }; } static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode { const node = $createCustomHeadingNode(serializedNode.tag); node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); return node; } @@ -99,12 +120,7 @@ function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { nodeName === 'h6' ) { node = $createCustomHeadingNode(nodeName); - if (element.style !== null) { - node.setFormat(element.style.textAlign as ElementFormatType); - } - if (element.id) { - node.setId(element.id); - } + setCommonBlockPropsFromElement(element, node); } return {node}; } diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts index cb936a559..663f32dfc 100644 --- a/resources/js/wysiwyg/nodes/custom-paragraph.ts +++ b/resources/js/wysiwyg/nodes/custom-paragraph.ts @@ -1,21 +1,23 @@ import { DOMConversion, DOMConversionMap, - DOMConversionOutput, ElementFormatType, + DOMConversionOutput, LexicalNode, - ParagraphNode, - SerializedParagraphNode, - Spread + ParagraphNode, SerializedParagraphNode, Spread, } from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; +import { + CommonBlockAlignment, commonPropertiesDifferent, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; - -export type SerializedCustomParagraphNode = Spread<{ - id: string; -}, SerializedParagraphNode> +export type SerializedCustomParagraphNode = Spread export class CustomParagraphNode extends ParagraphNode { __id: string = ''; + __alignment: CommonBlockAlignment = ''; static getType() { return 'custom-paragraph'; @@ -31,33 +33,48 @@ export class CustomParagraphNode extends ParagraphNode { return self.__id; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + static clone(node: CustomParagraphNode): CustomParagraphNode { const newNode = new CustomParagraphNode(node.__key); newNode.__id = node.__id; + newNode.__alignment = node.__alignment; return newNode; } createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); - if (this.__id) { - dom.setAttribute('id', this.__id); - } - + updateElementWithCommonBlockProps(dom, this); return dom; } + updateDOM(prevNode: CustomParagraphNode, dom: HTMLElement, config: EditorConfig): boolean { + return super.updateDOM(prevNode, dom, config) + || commonPropertiesDifferent(prevNode, this); + } + exportJSON(): SerializedCustomParagraphNode { return { ...super.exportJSON(), type: 'custom-paragraph', version: 1, id: this.__id, + alignment: this.__alignment, }; } static importJSON(serializedNode: SerializedCustomParagraphNode): CustomParagraphNode { const node = $createCustomParagraphNode(); node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); return node; } @@ -67,17 +84,14 @@ export class CustomParagraphNode extends ParagraphNode { return { conversion: (element: HTMLElement): DOMConversionOutput|null => { const node = $createCustomParagraphNode(); - if (element.style) { - node.setFormat(element.style.textAlign as ElementFormatType); + if (element.style.textIndent) { const indent = parseInt(element.style.textIndent, 10) / 20; if (indent > 0) { node.setIndent(indent); } } - if (element.id) { - node.setId(element.id); - } + setCommonBlockPropsFromElement(element, node); return {node}; }, diff --git a/resources/js/wysiwyg/nodes/custom-quote.ts b/resources/js/wysiwyg/nodes/custom-quote.ts index 58c62f769..cee289dbe 100644 --- a/resources/js/wysiwyg/nodes/custom-quote.ts +++ b/resources/js/wysiwyg/nodes/custom-quote.ts @@ -1,19 +1,24 @@ import { DOMConversionMap, - DOMConversionOutput, ElementFormatType, + DOMConversionOutput, LexicalNode, Spread } from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text"; +import { + CommonBlockAlignment, commonPropertiesDifferent, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; -export type SerializedCustomQuoteNode = Spread<{ - id: string; -}, SerializedQuoteNode> +export type SerializedCustomQuoteNode = Spread export class CustomQuoteNode extends QuoteNode { __id: string = ''; + __alignment: CommonBlockAlignment = ''; static getType() { return 'custom-quote'; @@ -29,33 +34,47 @@ export class CustomQuoteNode extends QuoteNode { return self.__id; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + static clone(node: CustomQuoteNode) { const newNode = new CustomQuoteNode(node.__key); newNode.__id = node.__id; + newNode.__alignment = node.__alignment; return newNode; } createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); - if (this.__id) { - dom.setAttribute('id', this.__id); - } - + updateElementWithCommonBlockProps(dom, this); return dom; } + updateDOM(prevNode: CustomQuoteNode): boolean { + return commonPropertiesDifferent(prevNode, this); + } + exportJSON(): SerializedCustomQuoteNode { return { ...super.exportJSON(), type: 'custom-quote', version: 1, id: this.__id, + alignment: this.__alignment, }; } static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode { const node = $createCustomQuoteNode(); node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); return node; } @@ -71,12 +90,7 @@ export class CustomQuoteNode extends QuoteNode { function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { const node = $createCustomQuoteNode(); - if (element.style !== null) { - node.setFormat(element.style.textAlign as ElementFormatType); - } - if (element.id) { - node.setId(element.id); - } + setCommonBlockPropsFromElement(element, node); return {node}; } diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts index c8fe58c77..15c305dcb 100644 --- a/resources/js/wysiwyg/nodes/custom-table-cell.ts +++ b/resources/js/wysiwyg/nodes/custom-table-cell.ts @@ -21,13 +21,16 @@ import { } from "@lexical/table"; import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; export type SerializedCustomTableCellNode = Spread<{ - styles: Record, + styles: Record; + alignment: CommonBlockAlignment; }, SerializedTableCellNode> export class CustomTableCellNode extends TableCellNode { __styles: StyleMap = new Map; + __alignment: CommonBlockAlignment = ''; static getType(): string { return 'custom-table-cell'; @@ -42,6 +45,7 @@ export class CustomTableCellNode extends TableCellNode { ); cellNode.__rowSpan = node.__rowSpan; cellNode.__styles = new Map(node.__styles); + cellNode.__alignment = node.__alignment; return cellNode; } @@ -60,6 +64,16 @@ export class CustomTableCellNode extends TableCellNode { self.__styles = new Map(styles); } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + updateTag(tag: string): void { const isHeader = tag.toLowerCase() === 'th'; const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS; @@ -74,12 +88,17 @@ export class CustomTableCellNode extends TableCellNode { element.style.setProperty(name, value); } + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + return element; } updateDOM(prevNode: CustomTableCellNode): boolean { return super.updateDOM(prevNode) - || this.__styles !== prevNode.__styles; + || this.__styles !== prevNode.__styles + || this.__alignment !== prevNode.__alignment; } static importDOM(): DOMConversionMap | null { @@ -110,6 +129,7 @@ export class CustomTableCellNode extends TableCellNode { ); node.setStyles(new Map(Object.entries(serializedNode.styles))); + node.setAlignment(serializedNode.alignment); return node; } @@ -119,6 +139,7 @@ export class CustomTableCellNode extends TableCellNode { ...super.exportJSON(), type: 'custom-table-cell', styles: Object.fromEntries(this.__styles), + alignment: this.__alignment, }; } } @@ -128,6 +149,7 @@ function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { output.node.setStyles(extractStyleMapFromElement(domNode)); + output.node.setAlignment(extractAlignmentFromElement(domNode)); } return output; diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 1d95b7896..b699763d9 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -4,17 +4,23 @@ import {EditorConfig} from "lexical/LexicalEditor"; import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom"; import {getTableColumnWidths} from "../utils/tables"; +import { + CommonBlockAlignment, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; -export type SerializedCustomTableNode = Spread<{ - id: string; +export type SerializedCustomTableNode = Spread, -}, SerializedTableNode> +}, SerializedTableNode>, SerializedCommonBlockNode> export class CustomTableNode extends TableNode { __id: string = ''; __colWidths: string[] = []; __styles: StyleMap = new Map; + __alignment: CommonBlockAlignment = ''; static getType() { return 'custom-table'; @@ -30,6 +36,16 @@ export class CustomTableNode extends TableNode { return self.__id; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + setColWidths(widths: string[]) { const self = this.getWritable(); self.__colWidths = widths; @@ -55,15 +71,13 @@ export class CustomTableNode extends TableNode { newNode.__id = node.__id; newNode.__colWidths = node.__colWidths; newNode.__styles = new Map(node.__styles); + newNode.__alignment = node.__alignment; return newNode; } createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); - const id = this.getId(); - if (id) { - dom.setAttribute('id', id); - } + updateElementWithCommonBlockProps(dom, this); const colWidths = this.getColWidths(); if (colWidths.length > 0) { @@ -97,6 +111,7 @@ export class CustomTableNode extends TableNode { id: this.__id, colWidths: this.__colWidths, styles: Object.fromEntries(this.__styles), + alignment: this.__alignment, }; } @@ -105,6 +120,7 @@ export class CustomTableNode extends TableNode { node.setId(serializedNode.id); node.setColWidths(serializedNode.colWidths); node.setStyles(new Map(Object.entries(serializedNode.styles))); + node.setAlignment(serializedNode.alignment); return node; } @@ -114,10 +130,7 @@ export class CustomTableNode extends TableNode { return { conversion: (element: HTMLElement): DOMConversionOutput|null => { const node = $createCustomTableNode(); - - if (element.id) { - node.setId(element.id); - } + setCommonBlockPropsFromElement(element, node); const colWidths = getTableColumnWidths(element as HTMLTableElement); node.setColWidths(colWidths); diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 8cbec20da..b5483c500 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -35,17 +35,17 @@ export function getNodesForPageEditor(): (KlassConstructor | CustomHeadingNode, CustomQuoteNode, CustomListNode, - CustomListItemNode, + CustomListItemNode, // TODO - Alignment? CustomTableNode, CustomTableRowNode, CustomTableCellNode, - ImageNode, + ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, SummaryNode, CodeBlockNode, DiagramNode, - MediaNode, - CustomParagraphNode, // TODO - ID + MediaNode, // TODO - Alignment + CustomParagraphNode, LinkNode, { replace: ParagraphNode, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index f3a8da404..fec38271a 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,7 +6,7 @@ ## Main Todo -- Alignments: Use existing classes for blocks (including table cells) + - Alignments: Handle inline block content (image, video) - Image paste upload - Keyboard shortcuts support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 40d9c89dc..78de3c9a2 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,4 +1,4 @@ -import {$getSelection, BaseSelection, ElementFormatType} from "lexical"; +import {$getSelection, BaseSelection} from "lexical"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; @@ -6,13 +6,17 @@ import alignCenterIcon from "@icons/editor/align-center.svg"; import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../utils/selection"; +import {CommonBlockAlignment} from "../../../nodes/_common"; +import {nodeHasAlignment} from "../../../utils/nodes"; -function setAlignmentForSection(alignment: ElementFormatType): void { +function setAlignmentForSection(alignment: CommonBlockAlignment): void { const selection = $getSelection(); const elements = $getBlockElementNodesInSelection(selection); for (const node of elements) { - node.setFormat(alignment); + if (nodeHasAlignment(node)) { + node.setAlignment(alignment) + } } } diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 6278186ca..e33cfda7c 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -3,6 +3,7 @@ import {LexicalNodeMatcher} from "../nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; +import {NodeHasAlignment} from "../nodes/_common"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { return nodes.map(node => { @@ -70,4 +71,8 @@ export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, } return null; +} + +export function nodeHasAlignment(node: object): node is NodeHasAlignment { + return '__alignment' in node; } \ No newline at end of file diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 3aa4ac653..b187d6408 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -32,6 +32,9 @@ margin-left: auto; margin-right: auto; } + .align-justify { + text-align: justify; + } h1, h2, h3, h4, h5, h6, pre { clear: left; } From aa1fac62d56a25044698afcdd48298bb74299d16 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 20 Aug 2024 13:07:33 +0100 Subject: [PATCH 078/107] Lexical: Started adding editor shortcuts --- resources/js/services/events.ts | 2 +- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/services/shortcuts.ts | 91 +++++++++++++++++++ resources/js/wysiwyg/todo.md | 3 +- .../ui/defaults/buttons/block-formats.ts | 24 ++--- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 28 ++---- .../js/wysiwyg/ui/defaults/forms/objects.ts | 5 +- resources/js/wysiwyg/ui/framework/core.ts | 1 - resources/js/wysiwyg/ui/framework/manager.ts | 9 +- resources/js/wysiwyg/ui/index.ts | 1 - resources/js/wysiwyg/utils/diagrams.ts | 4 +- resources/js/wysiwyg/utils/formats.ts | 88 ++++++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 15 ++- 13 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 resources/js/wysiwyg/services/shortcuts.ts create mode 100644 resources/js/wysiwyg/utils/formats.ts diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index 32c70f5a8..be9fba7ec 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -7,7 +7,7 @@ export class EventManager { /** * Emit a custom event for any handlers to pick-up. */ - emit(eventName: string, eventData: {}): void { + emit(eventName: string, eventData: {} = {}): void { this.stack.push({name: eventName, data: eventData}); const listenersToRun = this.listeners[eventName] || []; diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index a07fbd789..0a939baf4 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -12,6 +12,7 @@ import {handleDropEvents} from "./services/drop-handling"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; +import {registerShortcuts} from "./services/shortcuts"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -48,6 +49,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(editor), registerTableResizer(editor, editWrap), registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts new file mode 100644 index 000000000..235c2788a --- /dev/null +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -0,0 +1,91 @@ +import {COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; +import { + cycleSelectionCalloutFormats, + formatCodeBlock, + toggleSelectionAsBlockquote, + toggleSelectionAsHeading, + toggleSelectionAsParagraph +} from "../utils/formats"; +import {HeadingTagType} from "@lexical/rich-text"; + +function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { + toggleSelectionAsHeading(editor, tag); + return true; +} + +function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction { + return (editor: LexicalEditor) => { + formatAction(editor); + return true; + }; +} + +function toggleInlineCode(editor: LexicalEditor): boolean { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); + return true; +} + +type ShortcutAction = (editor: LexicalEditor) => boolean; + +const actionsByKeys: Record = { + // Save draft + 'ctrl+s': () => { + window.$events.emit('editor-save-draft'); + return true; + }, + 'ctrl+enter': () => { + window.$events.emit('editor-save-page'); + return true; + }, + 'ctrl+1': (editor) => headerHandler(editor, 'h1'), + 'ctrl+2': (editor) => headerHandler(editor, 'h2'), + 'ctrl+3': (editor) => headerHandler(editor, 'h3'), + 'ctrl+4': (editor) => headerHandler(editor, 'h4'), + 'ctrl+5': wrapFormatAction(toggleSelectionAsParagraph), + 'ctrl+d': wrapFormatAction(toggleSelectionAsParagraph), + 'ctrl+6': wrapFormatAction(toggleSelectionAsBlockquote), + 'ctrl+q': wrapFormatAction(toggleSelectionAsBlockquote), + 'ctrl+7': wrapFormatAction(formatCodeBlock), + 'ctrl+e': wrapFormatAction(formatCodeBlock), + 'ctrl+8': toggleInlineCode, + 'ctrl+shift+e': toggleInlineCode, + 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats), + + // TODO Lists + // TODO Links + // TODO Link selector +}; + +function createKeyDownListener(editor: LexicalEditor): (e: KeyboardEvent) => void { + return (event: KeyboardEvent) => { + // TODO - Mac Cmd support + const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase(); + console.log(`pressed: ${combo}`); + if (actionsByKeys[combo]) { + const handled = actionsByKeys[combo](editor); + if (handled) { + event.stopPropagation(); + event.preventDefault(); + } + } + }; +} + +function overrideDefaultCommands(editor: LexicalEditor) { + // Prevent default ctrl+enter command + editor.registerCommand(KEY_ENTER_COMMAND, (event) => { + return event?.ctrlKey ? true : false + }, COMMAND_PRIORITY_HIGH); +} + +export function registerShortcuts(editor: LexicalEditor) { + const listener = createKeyDownListener(editor); + overrideDefaultCommands(editor); + + return editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { + // add the listener to the current root element + rootElement?.addEventListener('keydown', listener); + // remove the listener from the old root element + prevRootElement?.removeEventListener('keydown', listener); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index fec38271a..75263c927 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,14 +2,13 @@ ## In progress -// +- Keyboard shortcuts support ## Main Todo - Alignments: Handle inline block content (image, video) - Image paste upload -- Keyboard shortcuts support - 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) - Table caption text support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index eba903263..80e493486 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -1,16 +1,19 @@ import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {$createParagraphNode, $isParagraphNode, BaseSelection, LexicalNode} from "lexical"; +import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; import { - $createHeadingNode, - $createQuoteNode, $isHeadingNode, $isQuoteNode, HeadingNode, HeadingTagType } from "@lexical/rich-text"; import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection"; +import { + toggleSelectionAsBlockquote, + toggleSelectionAsHeading, + toggleSelectionAsParagraph +} from "../../../utils/formats"; function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { @@ -42,12 +45,7 @@ function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefin return { label: name, action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType( - (node) => isHeaderNodeOfTag(node, tag), - () => $createHeadingNode(tag), - ) - }); + toggleSelectionAsHeading(context.editor, tag); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); @@ -63,9 +61,7 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header') export const blockquote: EditorButtonDefinition = { label: 'Blockquote', action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); - }); + toggleSelectionAsBlockquote(context.editor); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, $isQuoteNode); @@ -75,9 +71,7 @@ export const blockquote: EditorButtonDefinition = { export const paragraph: EditorButtonDefinition = { label: 'Paragraph', action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); - }); + toggleSelectionAsParagraph(context.editor); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, $isParagraphNode); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 96a92ff22..3494096a2 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -28,11 +28,12 @@ import {$isMediaNode, MediaNode} from "../../../nodes/media"; import { $getNodeFromSelection, $insertNewBlockNodeAtSelection, - $selectionContainsNodeType + $selectionContainsNodeType, getLastSelection } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; import {$showImageForm} from "../forms/objects"; +import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', @@ -72,7 +73,7 @@ export const unlink: EditorButtonDefinition = { icon: unlinkIcon, action(context: EditorUiContext) { context.editor.update(() => { - const selection = context.lastSelection; + const selection = getLastSelection(context.editor); const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; const selectionPoints = selection?.getStartEndPoints(); @@ -98,7 +99,8 @@ export const image: EditorButtonDefinition = { icon: imageIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode) as ImageNode | null; + const selection = getLastSelection(context.editor); + const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null; if (selectedImage) { $showImageForm(selectedImage, context); return; @@ -134,21 +136,7 @@ export const codeBlock: EditorButtonDefinition = { label: 'Insert code block', icon: codeBlockIcon, action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); - if (codeBlock === null) { - context.editor.update(() => { - const codeBlock = $createCodeBlockNode(); - codeBlock.setCode(selection?.getTextContent() || ''); - $insertNewBlockNodeAtSelection(codeBlock, true); - $openCodeEditorForNode(context.editor, codeBlock); - codeBlock.selectStart(); - }); - } else { - $openCodeEditorForNode(context.editor, codeBlock); - } - }); + formatCodeBlock(context.editor); }, isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isCodeBlockNode); @@ -165,8 +153,8 @@ export const diagram: EditorButtonDefinition = { icon: diagramIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode | null); + const selection = getLastSelection(context.editor); + const diagramNode = $getNodeFromSelection(selection, $isDiagramNode) as (DiagramNode | null); if (diagramNode === null) { context.editor.update(() => { const diagram = $createDiagramNode(); diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 2ad27f749..2aefe5414 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -10,7 +10,7 @@ import {$isImageNode, ImageNode} from "../../../nodes/image"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$insertNodeToNearestRoot} from "@lexical/utils"; -import {$getNodeFromSelection} from "../../../utils/selection"; +import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; import {EditorFormModal} from "../../framework/modals"; import {EditorActionField} from "../../framework/blocks/action-field"; import {EditorButton} from "../../framework/buttons"; @@ -39,7 +39,8 @@ export const image: EditorFormDefinition = { submitText: 'Apply', async action(formData, context: EditorUiContext) { context.editor.update(() => { - const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode); + const selection = getLastSelection(context.editor); + const selectedImage = $getNodeFromSelection(selection, $isImageNode); if ($isImageNode(selectedImage)) { selectedImage.setSrc(formData.get('src')?.toString() || ''); selectedImage.setAltText(formData.get('alt')?.toString() || ''); diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 3e9f1e3d9..b6fe52dcd 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -15,7 +15,6 @@ export type EditorUiContext = { scrollDOM: HTMLElement; // DOM element which is the main content scroll container translate: (text: string) => string; // Translate function manager: EditorUIManager; // UI Manager instance for this editor - lastSelection: BaseSelection|null; // The last tracked selection made by the user options: Record; // General user options which may be used by sub elements }; diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 92891b540..f10e85b47 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -5,6 +5,7 @@ import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELEC import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; +import {getLastSelection, setLastSelection} from "../../utils/selection"; export type SelectionChangeHandler = (selection: BaseSelection|null) => void; @@ -108,8 +109,7 @@ export class EditorUIManager { } protected triggerStateUpdate(update: EditorUiStateUpdate): void { - const context = this.getContext(); - context.lastSelection = update.selection; + setLastSelection(update.editor, update.selection); this.toolbar?.updateState(update); this.updateContextToolbars(update); for (const toolbar of this.activeContextToolbars) { @@ -119,9 +119,10 @@ export class EditorUIManager { } triggerStateRefresh(): void { + const editor = this.getContext().editor; this.triggerStateUpdate({ - editor: this.getContext().editor, - selection: this.getContext().lastSelection, + editor, + selection: getLastSelection(editor), }); } diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 5fbaec91b..116d6e1fc 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -21,7 +21,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro scrollDOM: scrollContainer, manager, translate: (text: string): string => text, - lastSelection: null, options, }; manager.setContext(context); diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts index 2dee3ab6b..fb5543005 100644 --- a/resources/js/wysiwyg/utils/diagrams.ts +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -5,7 +5,7 @@ import * as DrawIO from "../../services/drawio"; import {$createDiagramNode, DiagramNode} from "../nodes/diagram"; import {ImageManager} from "../../components"; import {EditorImageData} from "./images"; -import {$getNodeFromSelection} from "./selection"; +import {$getNodeFromSelection, getLastSelection} from "./selection"; export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { return node instanceof DiagramNode; @@ -80,7 +80,7 @@ export function showDiagramManager(callback: (image: EditorImageData) => any) { } export function showDiagramManagerForInsert(context: EditorUiContext) { - const selection = context.lastSelection; + const selection = getLastSelection(context.editor); showDiagramManager((image: EditorImageData) => { context.editor.update(() => { const diagramNode = $createDiagramNode(image.id, image.url); diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts new file mode 100644 index 000000000..340be393d --- /dev/null +++ b/resources/js/wysiwyg/utils/formats.ts @@ -0,0 +1,88 @@ +import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; +import {$getSelection, LexicalEditor, LexicalNode} from "lexical"; +import { + $getBlockElementNodesInSelection, + $getNodeFromSelection, + $insertNewBlockNodeAtSelection, + $toggleSelectionBlockNodeType, + getLastSelection +} from "./selection"; +import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading"; +import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$createCustomQuoteNode} from "../nodes/custom-quote"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; +import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; + +const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { + return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; +}; + +export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagType) { + editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => $isHeaderNodeOfTag(node, tag), + () => $createCustomHeadingNode(tag), + ) + }); +} + +export function toggleSelectionAsParagraph(editor: LexicalEditor) { + editor.update(() => { + $toggleSelectionBlockNodeType($isCustomParagraphNode, $createCustomParagraphNode); + }); +} + +export function toggleSelectionAsBlockquote(editor: LexicalEditor) { + editor.update(() => { + $toggleSelectionBlockNodeType($isQuoteNode, $createCustomQuoteNode); + }); +} + +export function formatCodeBlock(editor: LexicalEditor) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const lastSelection = getLastSelection(editor); + const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); + if (codeBlock === null) { + editor.update(() => { + const codeBlock = $createCodeBlockNode(); + codeBlock.setCode(selection?.getTextContent() || ''); + $insertNewBlockNodeAtSelection(codeBlock, true); + $openCodeEditorForNode(editor, codeBlock); + codeBlock.selectStart(); + }); + } else { + $openCodeEditorForNode(editor, codeBlock); + } + }); +} + +export function cycleSelectionCalloutFormats(editor: LexicalEditor) { + editor.update(() => { + const selection = $getSelection(); + const blocks = $getBlockElementNodesInSelection(selection); + + let created = false; + for (const block of blocks) { + if (!$isCalloutNode(block)) { + block.replace($createCalloutNode('info'), true); + created = true; + } + } + + if (created) { + return; + } + + const types: CalloutCategory[] = ['info', 'warning', 'danger', 'success']; + for (const block of blocks) { + if ($isCalloutNode(block)) { + const type = block.getCategory(); + const typeIndex = types.indexOf(type); + const newIndex = (typeIndex + 1) % types.length; + const newType = types[newIndex]; + block.setCategory(newType); + } + } + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index e34afbe36..74dd94527 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -8,7 +8,7 @@ import { $setSelection, BaseSelection, ElementFormatType, - ElementNode, + ElementNode, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; @@ -17,6 +17,17 @@ import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; import {$getParentOfType} from "./nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; + +const lastSelectionByEditor = new WeakMap; + +export function getLastSelection(editor: LexicalEditor): BaseSelection|null { + return lastSelectionByEditor.get(editor) || null; +} + +export function setLastSelection(editor: LexicalEditor, selection: BaseSelection|null): void { + lastSelectionByEditor.set(editor, selection); +} export function $selectionContainsNodeType(selection: BaseSelection | null, matcher: LexicalNodeMatcher): boolean { return $getNodeFromSelection(selection, matcher) !== null; @@ -59,7 +70,7 @@ export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creat const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; if (selection && matcher(blockElement)) { - $setBlocksType(selection, $createParagraphNode); + $setBlocksType(selection, $createCustomParagraphNode); } else { $setBlocksType(selection, creator); } From dbb2fe3e599e29bc7fdec7230c6388294f4750c8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 20 Aug 2024 14:54:53 +0100 Subject: [PATCH 079/107] Lexical: Finished off baseline shortcut implementation --- resources/js/wysiwyg/index.ts | 4 +- resources/js/wysiwyg/services/shortcuts.ts | 51 +++++++++++----- resources/js/wysiwyg/todo.md | 3 +- .../js/wysiwyg/ui/defaults/buttons/lists.ts | 14 ++--- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 29 ++------- .../js/wysiwyg/ui/defaults/forms/objects.ts | 60 +++++++++---------- resources/js/wysiwyg/utils/formats.ts | 48 ++++++++++++++- 7 files changed, 124 insertions(+), 85 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 0a939baf4..d7f873ea5 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -45,11 +45,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st const editor = createEditor(config); editor.setRootElement(editArea); + const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(editor), + registerShortcuts(context), registerTableResizer(editor, editWrap), registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), @@ -89,7 +90,6 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st console.log(editor.getEditorState().toJSON()); }; - const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); registerCommonNodeMutationListeners(context); return new SimpleWysiwygEditorInterface(editor); diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 235c2788a..b17ec1bf7 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -1,12 +1,17 @@ -import {COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; +import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; import { cycleSelectionCalloutFormats, - formatCodeBlock, + formatCodeBlock, insertOrUpdateLink, toggleSelectionAsBlockquote, - toggleSelectionAsHeading, + toggleSelectionAsHeading, toggleSelectionAsList, toggleSelectionAsParagraph } from "../utils/formats"; import {HeadingTagType} from "@lexical/rich-text"; +import {EditorUiContext} from "../ui/framework/core"; +import {$getNodeFromSelection} from "../utils/selection"; +import {$isLinkNode, LinkNode} from "@lexical/link"; +import {$showLinkForm} from "../ui/defaults/forms/objects"; +import {showLinkSelector} from "../utils/links"; function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { toggleSelectionAsHeading(editor, tag); @@ -25,10 +30,9 @@ function toggleInlineCode(editor: LexicalEditor): boolean { return true; } -type ShortcutAction = (editor: LexicalEditor) => boolean; +type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boolean; const actionsByKeys: Record = { - // Save draft 'ctrl+s': () => { window.$events.emit('editor-save-draft'); return true; @@ -51,18 +55,35 @@ const actionsByKeys: Record = { 'ctrl+shift+e': toggleInlineCode, 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats), - // TODO Lists - // TODO Links - // TODO Link selector + 'ctrl+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), + 'ctrl+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), + 'ctrl+k': (editor, context) => { + editor.getEditorState().read(() => { + const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; + $showLinkForm(selectedLink, context); + }); + return true; + }, + 'ctrl+shift+k': (editor, context) => { + showLinkSelector(entity => { + insertOrUpdateLink(editor, { + text: entity.name, + title: entity.link, + target: '', + url: entity.link, + }); + }); + return true; + }, }; -function createKeyDownListener(editor: LexicalEditor): (e: KeyboardEvent) => void { +function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void { return (event: KeyboardEvent) => { // TODO - Mac Cmd support const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase(); - console.log(`pressed: ${combo}`); + // console.log(`pressed: ${combo}`); if (actionsByKeys[combo]) { - const handled = actionsByKeys[combo](editor); + const handled = actionsByKeys[combo](context.editor, context); if (handled) { event.stopPropagation(); event.preventDefault(); @@ -78,11 +99,11 @@ function overrideDefaultCommands(editor: LexicalEditor) { }, COMMAND_PRIORITY_HIGH); } -export function registerShortcuts(editor: LexicalEditor) { - const listener = createKeyDownListener(editor); - overrideDefaultCommands(editor); +export function registerShortcuts(context: EditorUiContext) { + const listener = createKeyDownListener(context); + overrideDefaultCommands(context.editor); - return editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { + return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { // add the listener to the current root element rootElement?.addEventListener('keydown', listener); // remove the listener from the old root element diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 75263c927..f05e79baa 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Keyboard shortcuts support +// ## Main Todo @@ -13,6 +13,7 @@ - Media resize support (like images) - Table caption text support - Table Cut/Copy/Paste column +- Mac: Shortcut support via command. ## Secondary Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index 10500eb67..edec3ea00 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -1,11 +1,12 @@ -import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isListNode, ListNode, ListType} from "@lexical/list"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {$getSelection, BaseSelection, LexicalNode} from "lexical"; +import {BaseSelection, LexicalNode} from "lexical"; import listBulletIcon from "@icons/editor/list-bullet.svg"; import listNumberedIcon from "@icons/editor/list-numbered.svg"; import listCheckIcon from "@icons/editor/list-check.svg"; import {$selectionContainsNodeType} from "../../../utils/selection"; +import {toggleSelectionAsList} from "../../../utils/formats"; function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { @@ -13,14 +14,7 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut label, icon, action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - if (this.isActive(selection, context)) { - removeList(context.editor); - } else { - insertList(context.editor, type); - } - }); + toggleSelectionAsList(context.editor, type); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 3494096a2..46556d3d1 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -2,11 +2,9 @@ import {EditorButtonDefinition} from "../../framework/buttons"; import linkIcon from "@icons/editor/link.svg"; import {EditorUiContext} from "../../framework/core"; import { - $createNodeSelection, $createTextNode, $getRoot, $getSelection, $insertNodes, - $setSelection, BaseSelection, ElementNode } from "lexical"; @@ -17,7 +15,7 @@ import {$isImageNode, ImageNode} from "../../../nodes/image"; import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; import codeBlockIcon from "@icons/editor/code-block.svg"; -import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../../nodes/code-block"; +import {$isCodeBlockNode} from "../../../nodes/code-block"; import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; @@ -32,35 +30,16 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showImageForm} from "../forms/objects"; +import {$showImageForm, $showLinkForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', icon: linkIcon, action(context: EditorUiContext) { - const linkModal = context.manager.createModal('link'); context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; - - let formDefaults = {}; - if (selectedLink) { - formDefaults = { - url: selectedLink.getURL(), - text: selectedLink.getTextContent(), - title: selectedLink.getTitle(), - target: selectedLink.getTarget(), - } - - context.editor.update(() => { - const selection = $createNodeSelection(); - selection.add(selectedLink.getKey()); - $setSelection(selection); - }); - } - - linkModal.show(formDefaults); + const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; + $showLinkForm(selectedLink, context); }); }, isActive(selection: BaseSelection | null): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 2aefe5414..714d5f64b 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -5,9 +5,9 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {$createTextNode, $getSelection, $insertNodes} from "lexical"; +import {$createNodeSelection, $createTextNode, $getSelection, $insertNodes, $setSelection} from "lexical"; import {$isImageNode, ImageNode} from "../../../nodes/image"; -import {$createLinkNode, $isLinkNode} from "@lexical/link"; +import {$createLinkNode, $isLinkNode, LinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$insertNodeToNearestRoot} from "@lexical/utils"; import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; @@ -19,6 +19,7 @@ import searchImageIcon from "@icons/editor/image-search.svg"; import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; import {LinkField} from "../../framework/blocks/link-field"; +import {insertOrUpdateLink} from "../../../utils/formats"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -96,37 +97,36 @@ export const image: EditorFormDefinition = { ], }; +export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('link'); + + let formDefaults = {}; + if (link) { + formDefaults = { + url: link.getURL(), + text: link.getTextContent(), + title: link.getTitle(), + target: link.getTarget(), + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(link.getKey()); + $setSelection(selection); + }); + } + + linkModal.show(formDefaults); +} + export const link: EditorFormDefinition = { submitText: 'Apply', async action(formData, context: EditorUiContext) { - context.editor.update(() => { - - const url = formData.get('url')?.toString() || ''; - const title = formData.get('title')?.toString() || '' - const target = formData.get('target')?.toString() || ''; - const text = formData.get('text')?.toString() || ''; - - const selection = $getSelection(); - let link = $getNodeFromSelection(selection, $isLinkNode); - if ($isLinkNode(link)) { - link.setURL(url); - link.setTarget(target); - link.setTitle(title); - } else { - link = $createLinkNode(url, { - title: title, - target: target, - }); - - $insertNodes([link]); - } - - if ($isLinkNode(link)) { - for (const child of link.getChildren()) { - child.remove(true); - } - link.append($createTextNode(text)); - } + insertOrUpdateLink(context.editor, { + url: formData.get('url')?.toString() || '', + title: formData.get('title')?.toString() || '', + target: formData.get('target')?.toString() || '', + text: formData.get('text')?.toString() || '', }); return true; }, diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 340be393d..97038f07b 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -1,9 +1,9 @@ import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; -import {$getSelection, LexicalEditor, LexicalNode} from "lexical"; +import {$createTextNode, $getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; import { $getBlockElementNodesInSelection, $getNodeFromSelection, - $insertNewBlockNodeAtSelection, + $insertNewBlockNodeAtSelection, $selectionContainsNodeType, $toggleSelectionBlockNodeType, getLastSelection } from "./selection"; @@ -12,6 +12,9 @@ import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custo import {$createCustomQuoteNode} from "../nodes/custom-quote"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; +import {insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isCustomListNode} from "../nodes/custom-list"; +import {$createLinkNode, $isLinkNode} from "@lexical/link"; const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; @@ -38,6 +41,21 @@ export function toggleSelectionAsBlockquote(editor: LexicalEditor) { }); } +export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $isCustomListNode(node) && (node as ListNode).getListType() === type; + }); + + if (listSelected) { + removeList(editor); + } else { + insertList(editor, type); + } + }); +} + export function formatCodeBlock(editor: LexicalEditor) { editor.getEditorState().read(() => { const selection = $getSelection(); @@ -85,4 +103,30 @@ export function cycleSelectionCalloutFormats(editor: LexicalEditor) { } } }); +} + +export function insertOrUpdateLink(editor: LexicalEditor, linkDetails: {text: string, title: string, target: string, url: string}) { + editor.update(() => { + const selection = $getSelection(); + let link = $getNodeFromSelection(selection, $isLinkNode); + if ($isLinkNode(link)) { + link.setURL(linkDetails.url); + link.setTarget(linkDetails.target); + link.setTitle(linkDetails.title); + } else { + link = $createLinkNode(linkDetails.url, { + title: linkDetails.title, + target: linkDetails.target, + }); + + $insertNodes([link]); + } + + if ($isLinkNode(link)) { + for (const child of link.getChildren()) { + child.remove(true); + } + link.append($createTextNode(linkDetails.text)); + } + }); } \ No newline at end of file From ddf5f2543c2347ee27b48c8a2b8db008d4892950 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 21 Aug 2024 12:59:45 +0100 Subject: [PATCH 080/107] Lexical: Added drop/paste image handling --- resources/js/wysiwyg/index.ts | 4 +- .../js/wysiwyg/services/drop-handling.ts | 75 --------- .../wysiwyg/services/drop-paste-handling.ts | 158 ++++++++++++++++++ resources/js/wysiwyg/todo.md | 2 - resources/js/wysiwyg/utils/images.ts | 17 ++ 5 files changed, 177 insertions(+), 79 deletions(-) delete mode 100644 resources/js/wysiwyg/services/drop-handling.ts create mode 100644 resources/js/wysiwyg/services/drop-paste-handling.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index d7f873ea5..fdcfa5b7e 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -8,7 +8,7 @@ import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions" import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {EditorUiContext} from "./ui/framework/core"; import {listen as listenToCommonEvents} from "./services/common-events"; -import {handleDropEvents} from "./services/drop-handling"; +import {registerDropPasteHandling} from "./services/drop-paste-handling"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; @@ -54,10 +54,10 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTableResizer(editor, editWrap), registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), + registerDropPasteHandling(context), ); listenToCommonEvents(editor); - handleDropEvents(editor); setEditorContentFromHtml(editor, htmlContent); diff --git a/resources/js/wysiwyg/services/drop-handling.ts b/resources/js/wysiwyg/services/drop-handling.ts deleted file mode 100644 index 7c9bb2713..000000000 --- a/resources/js/wysiwyg/services/drop-handling.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - $isDecoratorNode, - LexicalEditor, - LexicalNode -} from "lexical"; -import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; -import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; - -function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null { - const x = event.clientX; - const y = event.clientY; - const dom = document.elementFromPoint(x, y); - if (!dom) { - return null; - } - - return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY); -} - -function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) { - const positionNode = $getNodeFromMouseEvent(event, editor); - - if (positionNode) { - $selectSingleNode(positionNode); - } - - $insertNewBlockNodesAtSelection(nodes, true); - - if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) { - positionNode?.remove(); - } -} - -async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, event: DragEvent) { - const resp = await window.$http.get(`/templates/${templateId}`); - const data = (resp.data || {html: ''}) as {html: string} - const html: string = data.html || ''; - - editor.update(() => { - const newNodes = $htmlToBlockNodes(editor, html); - $insertNodesAtEvent(newNodes, event, editor); - }); -} - -function createDropListener(editor: LexicalEditor): (event: DragEvent) => void { - return (event: DragEvent) => { - // Template handling - const templateId = event.dataTransfer?.getData('bookstack/template') || ''; - if (templateId) { - insertTemplateToEditor(editor, templateId, event); - event.preventDefault(); - return; - } - - // HTML contents drop - const html = event.dataTransfer?.getData('text/html') || ''; - if (html) { - editor.update(() => { - const newNodes = $htmlToBlockNodes(editor, html); - $insertNodesAtEvent(newNodes, event, editor); - }); - event.preventDefault(); - return; - } - }; -} - -export function handleDropEvents(editor: LexicalEditor) { - const dropListener = createDropListener(editor); - - editor.registerRootListener((rootElement, prevRootElement) => { - rootElement?.addEventListener('drop', dropListener); - prevRootElement?.removeEventListener('drop', dropListener); - }); -} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts new file mode 100644 index 000000000..85d0235d8 --- /dev/null +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -0,0 +1,158 @@ +import { + $insertNodes, + $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND, + LexicalEditor, + LexicalNode, PASTE_COMMAND +} from "lexical"; +import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; +import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; +import {Clipboard} from "../../services/clipboard"; +import {$createImageNode} from "../nodes/image"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$createLinkNode} from "@lexical/link"; +import {EditorImageData, uploadImageFile} from "../utils/images"; +import {EditorUiContext} from "../ui/framework/core"; + +function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null { + const x = event.clientX; + const y = event.clientY; + const dom = document.elementFromPoint(x, y); + if (!dom) { + return null; + } + + return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY); +} + +function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) { + const positionNode = $getNodeFromMouseEvent(event, editor); + + if (positionNode) { + $selectSingleNode(positionNode); + } + + $insertNewBlockNodesAtSelection(nodes, true); + + if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) { + positionNode?.remove(); + } +} + +async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, event: DragEvent) { + const resp = await window.$http.get(`/templates/${templateId}`); + const data = (resp.data || {html: ''}) as {html: string} + const html: string = data.html || ''; + + editor.update(() => { + const newNodes = $htmlToBlockNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); +} + +function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean { + const clipboard = new Clipboard(data); + let handled = false; + + // Don't handle the event ourselves if no items exist of contains table-looking data + if (!clipboard.hasItems() || clipboard.containsTabularData()) { + return handled; + } + + const images = clipboard.getImages(); + if (images.length > 0) { + handled = true; + } + + context.editor.update(async () => { + for (const imageFile of images) { + const loadingImage = window.baseUrl('/loading.gif'); + const loadingNode = $createImageNode(loadingImage); + const imageWrap = $createCustomParagraphNode(); + imageWrap.append(loadingNode); + $insertNodes([imageWrap]); + + try { + const respData: EditorImageData = await uploadImageFile(imageFile, context.options.pageId); + const safeName = respData.name.replace(/"/g, ''); + context.editor.update(() => { + const finalImage = $createImageNode(respData.thumbs?.display || '', { + alt: safeName, + }); + const imageLink = $createLinkNode(respData.url, {target: '_blank'}); + imageLink.append(finalImage); + loadingNode.replace(imageLink); + }); + } catch (err: any) { + context.editor.update(() => { + loadingNode.remove(false); + }); + window.$events.error(err?.data?.message || context.options.translations.imageUploadErrorText); + console.error(err); + } + } + }); + + return handled; +} + +function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean { + const editor = context.editor; + return (event: DragEvent): boolean => { + // Template handling + const templateId = event.dataTransfer?.getData('bookstack/template') || ''; + if (templateId) { + insertTemplateToEditor(editor, templateId, event); + event.preventDefault(); + return true; + } + + // HTML contents drop + const html = event.dataTransfer?.getData('text/html') || ''; + if (html) { + editor.update(() => { + const newNodes = $htmlToBlockNodes(editor, html); + $insertNodesAtEvent(newNodes, event, editor); + }); + event.preventDefault(); + return true; + } + + if (event.dataTransfer) { + const handled = handleMediaInsert(event.dataTransfer, context); + if (handled) { + event.preventDefault(); + return true; + } + } + + return false; + }; +} + +function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) => boolean { + return (event: ClipboardEvent) => { + if (!event.clipboardData) { + return false; + } + + const handled = handleMediaInsert(event.clipboardData, context); + if (handled) { + event.preventDefault(); + } + + return handled; + }; +} + +export function registerDropPasteHandling(context: EditorUiContext): () => void { + const dropListener = createDropListener(context); + const pasteListener = createPasteListener(context); + + const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH); + const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH); + + return () => { + unregisterDrop(); + unregisterPaste(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index f05e79baa..f339a6ed4 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,9 +6,7 @@ ## Main Todo - - Alignments: Handle inline block content (image, video) -- Image paste upload - 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) - Table caption text support diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts index a83d55418..2c13427d9 100644 --- a/resources/js/wysiwyg/utils/images.ts +++ b/resources/js/wysiwyg/utils/images.ts @@ -24,4 +24,21 @@ export function $createLinkedImageNodeFromImageData(image: EditorImageData): Lin }); linkNode.append(imageNode); return linkNode; +} + +/** + * Upload an image file to the server + */ +export async function uploadImageFile(file: File, pageId: string): Promise { + if (file === null || file.type.indexOf('image') !== 0) { + throw new Error('Not an image file'); + } + + const remoteFilename = file.name || `image-${Date.now()}.png`; + const formData = new FormData(); + formData.append('file', file, remoteFilename); + formData.append('uploaded_to', pageId); + + const resp = await window.$http.post('/images/gallery', formData); + return resp.data as EditorImageData; } \ No newline at end of file From 8a13a9df8092d1f7aad84fd960705380c181763e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 22 Aug 2024 10:08:08 +0100 Subject: [PATCH 081/107] Lexical: Improved table row copy/paste Added safeguarding/matching of source/target sizes to prevent broken tables. --- .../js/wysiwyg/nodes/custom-table-cell.ts | 2 +- resources/js/wysiwyg/todo.md | 3 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 49 +++++----- resources/js/wysiwyg/ui/framework/core.ts | 1 + resources/js/wysiwyg/ui/index.ts | 5 +- .../{services => utils}/node-clipboard.ts | 4 +- .../js/wysiwyg/utils/table-copy-paste.ts | 97 +++++++++++++++++++ resources/js/wysiwyg/utils/table-map.ts | 2 +- 8 files changed, 132 insertions(+), 31 deletions(-) rename resources/js/wysiwyg/{services => utils}/node-clipboard.ts (96%) create mode 100644 resources/js/wysiwyg/utils/table-copy-paste.ts diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts index 15c305dcb..793302cfe 100644 --- a/resources/js/wysiwyg/nodes/custom-table-cell.ts +++ b/resources/js/wysiwyg/nodes/custom-table-cell.ts @@ -235,7 +235,7 @@ export function $convertTableCellNodeElement( export function $createCustomTableCellNode( - headerState: TableCellHeaderState, + headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, colSpan = 1, width?: number, ): CustomTableCellNode { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index f339a6ed4..dcc866888 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -// +- Table Cut/Copy/Paste column ## Main Todo @@ -10,7 +10,6 @@ - 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) - Table caption text support -- Table Cut/Copy/Paste column - Mac: Shortcut support via command. ## Secondary Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 6242f0b1d..1a9ffb0d3 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -27,8 +27,12 @@ import { $getTableRowsFromSelection, $mergeTableCellsInSelection } from "../../../utils/tables"; -import {$isCustomTableRowNode, CustomTableRowNode} from "../../../nodes/custom-table-row"; -import {NodeClipboard} from "../../../services/node-clipboard"; +import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; +import { + $copySelectedRowsToClipboard, + $cutSelectedRowsToClipboard, + $pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty +} from "../../../utils/table-copy-paste"; const neverActive = (): boolean => false; const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); @@ -168,17 +172,15 @@ export const rowProperties: EditorButtonDefinition = { isDisabled: cellNotSelected, }; -const rowClipboard: NodeClipboard = new NodeClipboard(CustomTableRowNode); - export const cutRow: EditorButtonDefinition = { label: 'Cut row', format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const rows = $getTableRowsFromSelection($getSelection()); - rowClipboard.set(...rows); - for (const row of rows) { - row.remove(); + try { + $cutSelectedRowsToClipboard(); + } catch (e: any) { + context.error(e.toString()); } }); }, @@ -191,8 +193,11 @@ export const copyRow: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const rows = $getTableRowsFromSelection($getSelection()); - rowClipboard.set(...rows); + try { + $copySelectedRowsToClipboard(); + } catch (e: any) { + context.error(e.toString()); + } }); }, isActive: neverActive, @@ -204,17 +209,15 @@ export const pasteRowBefore: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const rows = $getTableRowsFromSelection($getSelection()); - const lastRow = rows[rows.length - 1]; - if (lastRow) { - for (const row of rowClipboard.get(context.editor)) { - lastRow.insertBefore(row); - } + try { + $pasteClipboardRowsBefore(context.editor); + } catch (e: any) { + context.error(e.toString()); } }); }, isActive: neverActive, - isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0, + isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(), }; export const pasteRowAfter: EditorButtonDefinition = { @@ -222,17 +225,15 @@ export const pasteRowAfter: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const rows = $getTableRowsFromSelection($getSelection()); - const lastRow = rows[rows.length - 1]; - if (lastRow) { - for (const row of rowClipboard.get(context.editor).reverse()) { - lastRow.insertAfter(row); - } + try { + $pasteRowsAfter(context.editor); + } catch (e: any) { + context.error(e.toString()); } }); }, isActive: neverActive, - isDisabled: (selection) => cellNotSelected(selection) || rowClipboard.size() === 0, + isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(), }; export const cutColumn: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index b6fe52dcd..a04f3c74a 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -14,6 +14,7 @@ export type EditorUiContext = { containerDOM: HTMLElement; // DOM element which contains all editor elements scrollDOM: HTMLElement; // DOM element which is the main content scroll container translate: (text: string) => string; // Translate function + error: (text: string) => void; // Error reporting function manager: EditorUIManager; // UI Manager instance for this editor options: Record; // General user options which may be used by sub elements }; diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 116d6e1fc..bfa76bb82 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -20,7 +20,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro editorDOM: element, scrollDOM: scrollContainer, manager, - translate: (text: string): string => text, + translate: (text: string): string => text, // TODO - Implement + error(error: string): void { + window.$events.error(error); // TODO - Translate + }, options, }; manager.setContext(context); diff --git a/resources/js/wysiwyg/services/node-clipboard.ts b/resources/js/wysiwyg/utils/node-clipboard.ts similarity index 96% rename from resources/js/wysiwyg/services/node-clipboard.ts rename to resources/js/wysiwyg/utils/node-clipboard.ts index 7d880db98..385c4c46c 100644 --- a/resources/js/wysiwyg/services/node-clipboard.ts +++ b/resources/js/wysiwyg/utils/node-clipboard.ts @@ -44,10 +44,10 @@ export class NodeClipboard { } } - get(editor: LexicalEditor): LexicalNode[] { + get(editor: LexicalEditor): T[] { return this.store.map(json => unserializeNodeRecursive(editor, json)).filter((node) => { return node !== null; - }); + }) as T[]; } size(): number { diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts new file mode 100644 index 000000000..ae8ef3d35 --- /dev/null +++ b/resources/js/wysiwyg/utils/table-copy-paste.ts @@ -0,0 +1,97 @@ +import {NodeClipboard} from "./node-clipboard"; +import {CustomTableRowNode} from "../nodes/custom-table-row"; +import {$getTableFromSelection, $getTableRowsFromSelection} from "./tables"; +import {$getSelection, LexicalEditor} from "lexical"; +import {$createCustomTableCellNode, $isCustomTableCellNode} from "../nodes/custom-table-cell"; +import {CustomTableNode} from "../nodes/custom-table"; +import {TableMap} from "./table-map"; + +const rowClipboard: NodeClipboard = new NodeClipboard(CustomTableRowNode); + +export function isRowClipboardEmpty(): boolean { + return rowClipboard.size() === 0; +} + +export function validateRowsToCopy(rows: CustomTableRowNode[]): void { + let commonRowSize: number|null = null; + + for (const row of rows) { + const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + let rowSize = 0; + for (const cell of cells) { + rowSize += cell.getColSpan() || 1; + if (cell.getRowSpan() > 1) { + throw Error('Cannot copy rows with merged cells'); + } + } + + if (commonRowSize === null) { + commonRowSize = rowSize; + } else if (commonRowSize !== rowSize) { + throw Error('Cannot copy rows with inconsistent sizes'); + } + } +} + +export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: CustomTableNode): void { + const tableColCount = (new TableMap(targetTable)).columnCount; + for (const row of rows) { + const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + let rowSize = 0; + for (const cell of cells) { + rowSize += cell.getColSpan() || 1; + } + + if (rowSize > tableColCount) { + throw Error('Cannot paste rows that are wider than target table'); + } + + while (rowSize < tableColCount) { + row.append($createCustomTableCellNode()); + rowSize++; + } + } +} + +export function $cutSelectedRowsToClipboard(): void { + const rows = $getTableRowsFromSelection($getSelection()); + validateRowsToCopy(rows); + rowClipboard.set(...rows); + for (const row of rows) { + row.remove(); + } +} + +export function $copySelectedRowsToClipboard(): void { + const rows = $getTableRowsFromSelection($getSelection()); + validateRowsToCopy(rows); + rowClipboard.set(...rows); +} + +export function $pasteClipboardRowsBefore(editor: LexicalEditor): void { + const selection = $getSelection(); + const rows = $getTableRowsFromSelection(selection); + const table = $getTableFromSelection(selection); + const lastRow = rows[rows.length - 1]; + if (lastRow && table) { + const clipboardRows = rowClipboard.get(editor); + validateRowsToPaste(clipboardRows, table); + for (const row of clipboardRows) { + lastRow.insertBefore(row); + } + } +} + +export function $pasteRowsAfter(editor: LexicalEditor): void { + const selection = $getSelection(); + const rows = $getTableRowsFromSelection(selection); + const table = $getTableFromSelection(selection); + const lastRow = rows[rows.length - 1]; + if (lastRow && table) { + const clipboardRows = rowClipboard.get(editor).reverse(); + validateRowsToPaste(clipboardRows, table); + for (const row of clipboardRows) { + lastRow.insertAfter(row); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts index 2b7eba62c..bc9721d96 100644 --- a/resources/js/wysiwyg/utils/table-map.ts +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -93,4 +93,4 @@ export class TableMap { return [...cells.values()]; } -} +} \ No newline at end of file From 1ebb0f8c93a0e74c0d6537480e899b3ca766b45f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 22 Aug 2024 13:28:30 +0100 Subject: [PATCH 082/107] Lexical: Added table column cut/copy/paste support --- resources/js/wysiwyg/todo.md | 2 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 52 +++-- resources/js/wysiwyg/ui/framework/core.ts | 2 +- resources/js/wysiwyg/ui/index.ts | 5 +- resources/js/wysiwyg/utils/node-clipboard.ts | 5 - .../js/wysiwyg/utils/table-copy-paste.ts | 185 +++++++++++++++++- resources/js/wysiwyg/utils/table-map.ts | 50 ++++- resources/js/wysiwyg/utils/tables.ts | 12 +- 8 files changed, 273 insertions(+), 40 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index dcc866888..5df26bd8c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Table Cut/Copy/Paste column +// ## Main Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 1a9ffb0d3..49e36bdac 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -29,9 +29,15 @@ import { } from "../../../utils/tables"; import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; import { + $copySelectedColumnsToClipboard, $copySelectedRowsToClipboard, + $cutSelectedColumnsToClipboard, $cutSelectedRowsToClipboard, - $pasteClipboardRowsBefore, $pasteRowsAfter, isRowClipboardEmpty + $pasteClipboardRowsBefore, + $pasteClipboardRowsAfter, + isColumnClipboardEmpty, + isRowClipboardEmpty, + $pasteClipboardColumnsBefore, $pasteClipboardColumnsAfter } from "../../../utils/table-copy-paste"; const neverActive = (): boolean => false; @@ -180,7 +186,7 @@ export const cutRow: EditorButtonDefinition = { try { $cutSelectedRowsToClipboard(); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -196,7 +202,7 @@ export const copyRow: EditorButtonDefinition = { try { $copySelectedRowsToClipboard(); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -212,7 +218,7 @@ export const pasteRowBefore: EditorButtonDefinition = { try { $pasteClipboardRowsBefore(context.editor); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -226,9 +232,9 @@ export const pasteRowAfter: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.update(() => { try { - $pasteRowsAfter(context.editor); + $pasteClipboardRowsAfter(context.editor); } catch (e: any) { - context.error(e.toString()); + context.error(e); } }); }, @@ -240,8 +246,12 @@ export const cutColumn: EditorButtonDefinition = { label: 'Cut column', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + try { + $cutSelectedColumnsToClipboard(); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, @@ -253,7 +263,11 @@ export const copyColumn: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - // TODO + try { + $copySelectedColumnsToClipboard(); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, @@ -264,24 +278,32 @@ export const pasteColumnBefore: EditorButtonDefinition = { label: 'Paste column before', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + try { + $pasteClipboardColumnsBefore(context.editor); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(), }; export const pasteColumnAfter: EditorButtonDefinition = { label: 'Paste column after', format: 'long', action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - // TODO + context.editor.update(() => { + try { + $pasteClipboardColumnsAfter(context.editor); + } catch (e: any) { + context.error(e); + } }); }, isActive: neverActive, - isDisabled: cellNotSelected, + isDisabled: (selection) => cellNotSelected(selection) || isColumnClipboardEmpty(), }; export const insertColumnBefore: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index a04f3c74a..3433b96e8 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -14,7 +14,7 @@ export type EditorUiContext = { containerDOM: HTMLElement; // DOM element which contains all editor elements scrollDOM: HTMLElement; // DOM element which is the main content scroll container translate: (text: string) => string; // Translate function - error: (text: string) => void; // Error reporting function + error: (text: string|Error) => void; // Error reporting function manager: EditorUIManager; // UI Manager instance for this editor options: Record; // General user options which may be used by sub elements }; diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index bfa76bb82..3b6d195b7 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -21,8 +21,9 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro scrollDOM: scrollContainer, manager, translate: (text: string): string => text, // TODO - Implement - error(error: string): void { - window.$events.error(error); // TODO - Translate + error(error: string|Error): void { + const message = error instanceof Error ? error.message : error; + window.$events.error(message); // TODO - Translate }, options, }; diff --git a/resources/js/wysiwyg/utils/node-clipboard.ts b/resources/js/wysiwyg/utils/node-clipboard.ts index 385c4c46c..dd3b4dfbe 100644 --- a/resources/js/wysiwyg/utils/node-clipboard.ts +++ b/resources/js/wysiwyg/utils/node-clipboard.ts @@ -30,13 +30,8 @@ function unserializeNodeRecursive(editor: LexicalEditor, {node, children}: Seria } export class NodeClipboard { - nodeClass: {importJSON: (s: SerializedLexicalNode) => T}; protected store: SerializedLexicalNodeWithChildren[] = []; - constructor(nodeClass: {importJSON: (s: any) => T}) { - this.nodeClass = nodeClass; - } - set(...nodes: LexicalNode[]): void { this.store.splice(0, this.store.length); for (const node of nodes) { diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts index ae8ef3d35..12c19b0fb 100644 --- a/resources/js/wysiwyg/utils/table-copy-paste.ts +++ b/resources/js/wysiwyg/utils/table-copy-paste.ts @@ -1,12 +1,14 @@ import {NodeClipboard} from "./node-clipboard"; import {CustomTableRowNode} from "../nodes/custom-table-row"; -import {$getTableFromSelection, $getTableRowsFromSelection} from "./tables"; -import {$getSelection, LexicalEditor} from "lexical"; -import {$createCustomTableCellNode, $isCustomTableCellNode} from "../nodes/custom-table-cell"; +import {$getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection} from "./tables"; +import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {$createCustomTableCellNode, $isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; import {CustomTableNode} from "../nodes/custom-table"; import {TableMap} from "./table-map"; +import {$isTableSelection} from "@lexical/table"; +import {$getNodeFromSelection} from "./selection"; -const rowClipboard: NodeClipboard = new NodeClipboard(CustomTableRowNode); +const rowClipboard: NodeClipboard = new NodeClipboard(); export function isRowClipboardEmpty(): boolean { return rowClipboard.size() === 0; @@ -82,7 +84,7 @@ export function $pasteClipboardRowsBefore(editor: LexicalEditor): void { } } -export function $pasteRowsAfter(editor: LexicalEditor): void { +export function $pasteClipboardRowsAfter(editor: LexicalEditor): void { const selection = $getSelection(); const rows = $getTableRowsFromSelection(selection); const table = $getTableFromSelection(selection); @@ -94,4 +96,177 @@ export function $pasteRowsAfter(editor: LexicalEditor): void { lastRow.insertAfter(row); } } +} + +const columnClipboard: NodeClipboard[] = []; + +function setColumnClipboard(columns: CustomTableCellNode[][]): void { + const newClipboards = columns.map(cells => { + const clipboard = new NodeClipboard(); + clipboard.set(...cells); + return clipboard; + }); + + columnClipboard.splice(0, columnClipboard.length, ...newClipboards); +} + +type TableRange = {from: number, to: number}; + +export function isColumnClipboardEmpty(): boolean { + return columnClipboard.length === 0; +} + +function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|null { + if ($isTableSelection(selection)) { + const shape = selection.getShape() + return {from: shape.fromX, to: shape.toX}; + } + + const cell = $getNodeFromSelection(selection, $isCustomTableCellNode); + const table = $getTableFromSelection(selection); + if (!$isCustomTableCellNode(cell) || !table) { + return null; + } + + const map = new TableMap(table); + const range = map.getRangeForCell(cell); + if (!range) { + return null; + } + + return {from: range.fromX, to: range.toX}; +} + +function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTableNode): CustomTableCellNode[][] { + const map = new TableMap(table); + const columns = []; + for (let x = range.from; x <= range.to; x++) { + const cells = map.getCellsInColumn(x); + columns.push(cells); + } + + return columns; +} + +function validateColumnsToCopy(columns: CustomTableCellNode[][]): void { + let commonColSize: number|null = null; + + for (const cells of columns) { + let colSize = 0; + for (const cell of cells) { + colSize += cell.getRowSpan() || 1; + if (cell.getColSpan() > 1) { + throw Error('Cannot copy columns with merged cells'); + } + } + + if (commonColSize === null) { + commonColSize = colSize; + } else if (commonColSize !== colSize) { + throw Error('Cannot copy columns with inconsistent sizes'); + } + } +} + +export function $cutSelectedColumnsToClipboard(): void { + const selection = $getSelection(); + const range = $getSelectionColumnRange(selection); + const table = $getTableFromSelection(selection); + if (!range || !table) { + return; + } + + const colWidths = table.getColWidths(); + const columns = $getTableColumnCellsFromSelection(range, table); + validateColumnsToCopy(columns); + setColumnClipboard(columns); + for (const cells of columns) { + for (const cell of cells) { + cell.remove(); + } + } + + const newWidths = [...colWidths].splice(range.from, (range.to - range.from) + 1); + table.setColWidths(newWidths); +} + +export function $copySelectedColumnsToClipboard(): void { + const selection = $getSelection(); + const range = $getSelectionColumnRange(selection); + const table = $getTableFromSelection(selection); + if (!range || !table) { + return; + } + + const columns = $getTableColumnCellsFromSelection(range, table); + validateColumnsToCopy(columns); + setColumnClipboard(columns); +} + +function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: CustomTableNode) { + const tableRowCount = (new TableMap(targetTable)).rowCount; + for (const cells of columns) { + let colSize = 0; + for (const cell of cells) { + colSize += cell.getRowSpan() || 1; + } + + if (colSize > tableRowCount) { + throw Error('Cannot paste columns that are taller than target table'); + } + + while (colSize < tableRowCount) { + cells.push($createCustomTableCellNode()); + colSize++; + } + } +} + +function $pasteClipboardColumns(editor: LexicalEditor, isBefore: boolean): void { + const selection = $getSelection(); + const table = $getTableFromSelection(selection); + const cells = $getTableCellsFromSelection(selection); + const referenceCell = cells[isBefore ? 0 : cells.length - 1]; + if (!table || !referenceCell) { + return; + } + + const clipboardCols = columnClipboard.map(cb => cb.get(editor)); + if (!isBefore) { + clipboardCols.reverse(); + } + + validateColumnsToPaste(clipboardCols, table); + const map = new TableMap(table); + const cellRange = map.getRangeForCell(referenceCell); + if (!cellRange) { + return; + } + + const colIndex = isBefore ? cellRange.fromX : cellRange.toX; + const colWidths = table.getColWidths(); + + for (let y = 0; y < map.rowCount; y++) { + const relCell = map.getCellAtPosition(colIndex, y); + for (const cells of clipboardCols) { + const newCell = cells[y]; + if (isBefore) { + relCell.insertBefore(newCell); + } else { + relCell.insertAfter(newCell); + } + } + } + + const refWidth = colWidths[colIndex]; + const addedWidths = clipboardCols.map(_ => refWidth); + colWidths.splice(isBefore ? colIndex : colIndex + 1, 0, ...addedWidths); +} + +export function $pasteClipboardColumnsBefore(editor: LexicalEditor): void { + $pasteClipboardColumns(editor, true); +} + +export function $pasteClipboardColumnsAfter(editor: LexicalEditor): void { + $pasteClipboardColumns(editor, false); } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts index bc9721d96..607deffe1 100644 --- a/resources/js/wysiwyg/utils/table-map.ts +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -2,6 +2,13 @@ import {CustomTableNode} from "../nodes/custom-table"; import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; import {$isTableRowNode} from "@lexical/table"; +export type CellRange = { + fromX: number; + fromY: number; + toX: number; + toY: number; +} + export class TableMap { rowCount: number = 0; @@ -77,11 +84,11 @@ export class TableMap { return this.cells[position]; } - public getCellsInRange(fromX: number, fromY: number, toX: number, toY: number): CustomTableCellNode[] { - const minX = Math.max(Math.min(fromX, toX), 0); - const maxX = Math.min(Math.max(fromX, toX), this.columnCount - 1); - const minY = Math.max(Math.min(fromY, toY), 0); - const maxY = Math.min(Math.max(fromY, toY), this.rowCount - 1); + public getCellsInRange(range: CellRange): CustomTableCellNode[] { + const minX = Math.max(Math.min(range.fromX, range.toX), 0); + const maxX = Math.min(Math.max(range.fromX, range.toX), this.columnCount - 1); + const minY = Math.max(Math.min(range.fromY, range.toY), 0); + const maxY = Math.min(Math.max(range.fromY, range.toY), this.rowCount - 1); const cells = new Set(); @@ -93,4 +100,37 @@ export class TableMap { return [...cells.values()]; } + + public getCellsInColumn(columnIndex: number): CustomTableCellNode[] { + return this.getCellsInRange({ + fromX: columnIndex, + toX: columnIndex, + fromY: 0, + toY: this.rowCount - 1, + }); + } + + public getRangeForCell(cell: CustomTableCellNode): CellRange|null { + let range: CellRange|null = null; + const cellKey = cell.getKey(); + + for (let y = 0; y < this.rowCount; y++) { + for (let x = 0; x < this.columnCount; x++) { + const index = (y * this.columnCount) + x; + const lCell = this.cells[index]; + if (lCell.getKey() === cellKey) { + if (range === null) { + range = {fromX: x, toX: x, fromY: y, toY: y}; + } else { + range.fromX = Math.min(range.fromX, x); + range.toX = Math.max(range.toX, x); + range.fromY = Math.min(range.fromY, y); + range.toY = Math.max(range.toY, y); + } + } + } + } + + return range; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index d0fd17e2c..aa8ec89ba 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -168,12 +168,12 @@ export function $mergeTableCellsInSelection(selection: TableSelection): void { const fixedToX = selectionShape.toX + ((headCell.getColSpan() || 1) - 1); const fixedToY = selectionShape.toY + ((headCell.getRowSpan() || 1) - 1); - const mergeCells = tableMap.getCellsInRange( - selectionShape.fromX, - selectionShape.fromY, - fixedToX, - fixedToY, - ); + const mergeCells = tableMap.getCellsInRange({ + fromX: selectionShape.fromX, + fromY: selectionShape.fromY, + toX: fixedToX, + toY: fixedToY, + }); if (mergeCells.length === 0) { return; From 1c9afcb84ef702412d6a004df1a3d861a8f57f1b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Sep 2024 14:07:10 +0100 Subject: [PATCH 083/107] Lexical: Added some level of img/media alignment --- resources/js/wysiwyg/nodes/image.ts | 42 ++++++++++++--- resources/js/wysiwyg/nodes/media.ts | 51 ++++++++++++++++--- resources/js/wysiwyg/todo.md | 5 +- .../wysiwyg/ui/defaults/buttons/alignments.ts | 39 +++++++++----- resources/js/wysiwyg/utils/selection.ts | 19 +++++-- 5 files changed, 124 insertions(+), 32 deletions(-) diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index ef6bf3572..77c854b41 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -10,6 +10,7 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; import {el} from "../utils/dom"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; export interface ImageNodeOptions { alt?: string; @@ -22,6 +23,7 @@ export type SerializedImageNode = Spread<{ alt: string; width: number; height: number; + alignment: CommonBlockAlignment; }, SerializedLexicalNode> export class ImageNode extends DecoratorNode { @@ -29,7 +31,7 @@ export class ImageNode extends DecoratorNode { __alt: string = ''; __width: number = 0; __height: number = 0; - // TODO - Alignment + __alignment: CommonBlockAlignment = ''; static getType(): string { return 'image'; @@ -97,6 +99,16 @@ export class ImageNode extends DecoratorNode { return self.__width; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + isInline(): boolean { return true; } @@ -121,6 +133,11 @@ export class ImageNode extends DecoratorNode { if (this.__alt) { element.setAttribute('alt', this.__alt); } + + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + return el('span', {class: 'editor-image-wrap'}, [ element, ]); @@ -158,6 +175,15 @@ export class ImageNode extends DecoratorNode { } } + if (prevNode.__alignment !== this.__alignment) { + if (prevNode.__alignment) { + image.classList.remove('align-' + prevNode.__alignment); + } + if (this.__alignment) { + image.classList.add('align-' + this.__alignment); + } + } + return false; } @@ -174,9 +200,10 @@ export class ImageNode extends DecoratorNode { width: Number.parseInt(element.getAttribute('width') || '0'), } - return { - node: new ImageNode(src, options), - }; + const node = new ImageNode(src, options); + node.setAlignment(extractAlignmentFromElement(element)); + + return { node }; }, priority: 3, }; @@ -191,16 +218,19 @@ export class ImageNode extends DecoratorNode { src: this.__src, alt: this.__alt, height: this.__height, - width: this.__width + width: this.__width, + alignment: this.__alignment, }; } static importJSON(serializedNode: SerializedImageNode): ImageNode { - return $createImageNode(serializedNode.src, { + const node = $createImageNode(serializedNode.src, { alt: serializedNode.alt, width: serializedNode.width, height: serializedNode.height, }); + node.setAlignment(serializedNode.alignment); + return node; } } diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index 73208cb2e..4159cd457 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -9,6 +9,13 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../utils/dom"; +import { + CommonBlockAlignment, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; +import {elem} from "../../services/dom"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -20,10 +27,10 @@ export type SerializedMediaNode = Spread<{ tag: MediaNodeTag; attributes: Record; sources: MediaNodeSource[]; -}, SerializedElementNode> +}, SerializedCommonBlockNode> const attributeAllowList = [ - 'id', 'width', 'height', 'style', 'title', 'name', + 'width', 'height', 'style', 'title', 'name', 'src', 'allow', 'allowfullscreen', 'loading', 'sandbox', 'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop', 'muted', 'playsinline', 'poster', 'preload' @@ -39,7 +46,7 @@ function filterAttributes(attributes: Record): Record = {}; @@ -62,10 +69,14 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode { node.setSources(sources); } + setCommonBlockPropsFromElement(element, node); + return node; } export class MediaNode extends ElementNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; __tag: MediaNodeTag; __attributes: Record = {}; __sources: MediaNodeSource[] = []; @@ -135,11 +146,32 @@ export class MediaNode extends ElementNode { this.setAttributes(attrs); } + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; const sourceEls = sources.map(source => el('source', source)); - - return el(this.__tag, this.__attributes, sourceEls); + const element = el(this.__tag, this.__attributes, sourceEls); + updateElementWithCommonBlockProps(element, this); + return element; } updateDOM(prevNode: unknown, dom: HTMLElement) { @@ -175,6 +207,8 @@ export class MediaNode extends ElementNode { ...super.exportJSON(), type: 'media', version: 1, + id: this.__id, + alignment: this.__alignment, tag: this.__tag, attributes: this.__attributes, sources: this.__sources, @@ -182,7 +216,10 @@ export class MediaNode extends ElementNode { } static importJSON(serializedNode: SerializedMediaNode): MediaNode { - return $createMediaNode(serializedNode.tag); + const node = $createMediaNode(serializedNode.tag); + node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); + return node; } } @@ -196,7 +233,7 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { const doc = parser.parseFromString(`${html}`, 'text/html'); const el = doc.body.children[0]; - if (!el) { + if (!(el instanceof HTMLElement)) { return null; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 5df26bd8c..795f7ab9c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,18 +6,19 @@ ## Main Todo -- Alignments: Handle inline block content (image, video) - 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) -- Table caption text support - Mac: Shortcut support via command. ## Secondary Todo - Color picker support in table form color fields +- Table caption text support ## Bugs +- Image alignment in editor dodgy due to wrapper. +- Can't select iframe embeds by themselves. (click enters iframe) - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 78de3c9a2..75440aed8 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,17 +1,32 @@ -import {$getSelection, BaseSelection} from "lexical"; +import {BaseSelection, LexicalEditor} from "lexical"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; import alignCenterIcon from "@icons/editor/align-center.svg"; import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; -import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../utils/selection"; +import { + $getBlockElementNodesInSelection, + $getDecoratorNodesInSelection, + $selectionContainsAlignment, getLastSelection +} from "../../../utils/selection"; import {CommonBlockAlignment} from "../../../nodes/_common"; import {nodeHasAlignment} from "../../../utils/nodes"; -function setAlignmentForSection(alignment: CommonBlockAlignment): void { - const selection = $getSelection(); +function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { + const selection = getLastSelection(editor); + const selectionNodes = selection?.getNodes() || []; + const decorators = $getDecoratorNodesInSelection(selection); + + // Handle decorator node selection alignment + if (selectionNodes.length === 1 && decorators.length === 1 && nodeHasAlignment(decorators[0])) { + decorators[0].setAlignment(alignment); + console.log('setting for decorator!'); + return; + } + + // Handle normal block/range alignment const elements = $getBlockElementNodesInSelection(selection); for (const node of elements) { if (nodeHasAlignment(node)) { @@ -24,10 +39,10 @@ export const alignLeft: EditorButtonDefinition = { label: 'Align left', icon: alignLeftIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('left')); + context.editor.update(() => setAlignmentForSection(context.editor, 'left')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'left'); + return $selectionContainsAlignment(selection, 'left'); } }; @@ -35,10 +50,10 @@ export const alignCenter: EditorButtonDefinition = { label: 'Align center', icon: alignCenterIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('center')); + context.editor.update(() => setAlignmentForSection(context.editor, 'center')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'center'); + return $selectionContainsAlignment(selection, 'center'); } }; @@ -46,10 +61,10 @@ export const alignRight: EditorButtonDefinition = { label: 'Align right', icon: alignRightIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('right')); + context.editor.update(() => setAlignmentForSection(context.editor, 'right')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'right'); + return $selectionContainsAlignment(selection, 'right'); } }; @@ -57,9 +72,9 @@ export const alignJustify: EditorButtonDefinition = { label: 'Align justify', icon: alignJustifyIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('justify')); + context.editor.update(() => setAlignmentForSection(context.editor, 'justify')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'justify'); + return $selectionContainsAlignment(selection, 'justify'); } }; diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 74dd94527..791eb7499 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -2,11 +2,11 @@ import { $createNodeSelection, $createParagraphNode, $getRoot, - $getSelection, + $getSelection, $isDecoratorNode, $isElementNode, $isTextNode, $setSelection, - BaseSelection, + BaseSelection, DecoratorNode, ElementFormatType, ElementNode, LexicalEditor, LexicalNode, @@ -16,8 +16,9 @@ import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexi import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; -import {$getParentOfType} from "./nodes"; +import {$getParentOfType, nodeHasAlignment} from "./nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {CommonBlockAlignment} from "../nodes/_common"; const lastSelectionByEditor = new WeakMap; @@ -120,10 +121,10 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le return false; } -export function $selectionContainsElementFormat(selection: BaseSelection | null, format: ElementFormatType): boolean { +export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean { const nodes = $getBlockElementNodesInSelection(selection); for (const node of nodes) { - if (node.getFormatType() === format) { + if (nodeHasAlignment(node) && node.getAlignment() === alignment) { return true; } } @@ -148,4 +149,12 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection | null } return Array.from(blockNodes.values()); +} + +export function $getDecoratorNodesInSelection(selection: BaseSelection | null): DecoratorNode[] { + if (!selection) { + return []; + } + + return selection.getNodes().filter(node => $isDecoratorNode(node)); } \ No newline at end of file From e5b6d28bcaf78a08fab97e0ee0496650b2466569 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 7 Sep 2024 18:39:58 +0100 Subject: [PATCH 084/107] Lexical: Revamped image node resize method Changed from using a decorator to using a helper that watches for image selections to then display a resize helper. Also changes resizer to use a ghost and apply changes on end instead of continuosly during resize. --- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/nodes/image.ts | 55 +++--- resources/js/wysiwyg/todo.md | 4 +- resources/js/wysiwyg/ui/decorators/image.ts | 132 -------------- .../wysiwyg/ui/defaults/buttons/alignments.ts | 16 +- .../ui/framework/helpers/image-resizer.ts | 167 ++++++++++++++++++ resources/js/wysiwyg/ui/framework/manager.ts | 19 +- resources/js/wysiwyg/ui/index.ts | 2 - resources/js/wysiwyg/utils/selection.ts | 20 ++- resources/sass/_editor.scss | 26 ++- 10 files changed, 251 insertions(+), 192 deletions(-) delete mode 100644 resources/js/wysiwyg/ui/decorators/image.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index fdcfa5b7e..c312919db 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -13,6 +13,7 @@ import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler" import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; +import {registerImageResizer} from "./ui/framework/helpers/image-resizer"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -55,6 +56,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), + registerImageResizer(context), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index 77c854b41..c9d11d871 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -1,16 +1,14 @@ import { - DecoratorNode, DOMConversion, DOMConversionMap, - DOMConversionOutput, + DOMConversionOutput, ElementNode, LexicalEditor, LexicalNode, - SerializedLexicalNode, Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {el} from "../utils/dom"; import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; +import {$selectSingleNode} from "../utils/selection"; +import {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; export interface ImageNodeOptions { alt?: string; @@ -24,9 +22,9 @@ export type SerializedImageNode = Spread<{ width: number; height: number; alignment: CommonBlockAlignment; -}, SerializedLexicalNode> +}, SerializedElementNode> -export class ImageNode extends DecoratorNode { +export class ImageNode extends ElementNode { __src: string = ''; __alt: string = ''; __width: number = 0; @@ -38,11 +36,13 @@ export class ImageNode extends DecoratorNode { } static clone(node: ImageNode): ImageNode { - return new ImageNode(node.__src, { + const newNode = new ImageNode(node.__src, { alt: node.__alt, width: node.__width, height: node.__height, }); + newNode.__alignment = node.__alignment; + return newNode; } constructor(src: string, options: ImageNodeOptions, key?: string) { @@ -113,13 +113,6 @@ export class ImageNode extends DecoratorNode { return true; } - decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { - return { - type: 'image', - getNode: () => this, - }; - } - createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('img'); element.setAttribute('src', this.__src); @@ -138,49 +131,50 @@ export class ImageNode extends DecoratorNode { element.classList.add('align-' + this.__alignment); } - return el('span', {class: 'editor-image-wrap'}, [ - element, - ]); + element.addEventListener('click', e => { + _editor.update(() => { + $selectSingleNode(this); + }); + }); + + return element; } updateDOM(prevNode: ImageNode, dom: HTMLElement) { - const image = dom.querySelector('img'); - if (!image) return false; - if (prevNode.__src !== this.__src) { - image.setAttribute('src', this.__src); + dom.setAttribute('src', this.__src); } if (prevNode.__width !== this.__width) { if (this.__width) { - image.setAttribute('width', String(this.__width)); + dom.setAttribute('width', String(this.__width)); } else { - image.removeAttribute('width'); + dom.removeAttribute('width'); } } if (prevNode.__height !== this.__height) { if (this.__height) { - image.setAttribute('height', String(this.__height)); + dom.setAttribute('height', String(this.__height)); } else { - image.removeAttribute('height'); + dom.removeAttribute('height'); } } if (prevNode.__alt !== this.__alt) { if (this.__alt) { - image.setAttribute('alt', String(this.__alt)); + dom.setAttribute('alt', String(this.__alt)); } else { - image.removeAttribute('alt'); + dom.removeAttribute('alt'); } } if (prevNode.__alignment !== this.__alignment) { if (prevNode.__alignment) { - image.classList.remove('align-' + prevNode.__alignment); + dom.classList.remove('align-' + prevNode.__alignment); } if (this.__alignment) { - image.classList.add('align-' + this.__alignment); + dom.classList.add('align-' + this.__alignment); } } @@ -213,6 +207,7 @@ export class ImageNode extends DecoratorNode { exportJSON(): SerializedImageNode { return { + ...super.exportJSON(), type: 'image', version: 1, src: this.__src, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 795f7ab9c..064d65e4c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,7 +6,6 @@ ## 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) - Mac: Shortcut support via command. @@ -14,12 +13,11 @@ - Color picker support in table form color fields - Table caption text support +- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) ## Bugs -- Image alignment in editor dodgy due to wrapper. - Can't select iframe embeds by themselves. (click enters iframe) -- Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. - Template drag/drop not handled when outside core editor area (ignored in margin area). diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts deleted file mode 100644 index d110bc499..000000000 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ /dev/null @@ -1,132 +0,0 @@ -import {EditorDecorator} from "../framework/decorator"; -import {$createNodeSelection, $setSelection} from "lexical"; -import {EditorUiContext} from "../framework/core"; -import {ImageNode} from "../../nodes/image"; -import {MouseDragTracker, MouseDragTrackerDistance} from "../framework/helpers/mouse-drag-tracker"; -import {$selectSingleNode} from "../../utils/selection"; -import {el} from "../../utils/dom"; - - -export class ImageDecorator extends EditorDecorator { - protected dom: HTMLElement|null = null; - protected dragLastMouseUp: number = 0; - - buildDOM(context: EditorUiContext) { - let handleElems: HTMLElement[] = []; - const decorateEl = el('div', { - class: 'editor-image-decorator', - }, []); - let selected = false; - let tracker: MouseDragTracker|null = null; - - const windowClick = (event: MouseEvent) => { - if (!decorateEl.contains(event.target as Node) && (Date.now() - this.dragLastMouseUp > 100)) { - unselect(); - } - }; - - const select = () => { - if (selected) { - return; - } - - selected = true; - decorateEl.classList.add('selected'); - window.addEventListener('click', windowClick); - - const handleClasses = ['nw', 'ne', 'se', 'sw']; - handleElems = handleClasses.map(c => { - return el('div', {class: `editor-image-decorator-handle ${c}`}); - }); - decorateEl.append(...handleElems); - tracker = this.setupTracker(decorateEl, context); - - context.editor.update(() => { - $selectSingleNode(this.getNode()); - }); - }; - - const unselect = () => { - selected = false; - decorateEl.classList.remove('selected'); - window.removeEventListener('click', windowClick); - tracker?.teardown(); - for (const el of handleElems) { - el.remove(); - } - }; - - decorateEl.addEventListener('click', (event) => { - select(); - }); - - return decorateEl; - } - - render(context: EditorUiContext): HTMLElement { - if (this.dom) { - return this.dom; - } - - this.dom = this.buildDOM(context); - return this.dom; - } - - setupTracker(container: HTMLElement, context: EditorUiContext): MouseDragTracker { - let startingWidth: number = 0; - let startingHeight: number = 0; - let startingRatio: number = 0; - let hasHeight = false; - let firstChange = true; - let node: ImageNode = this.getNode() as ImageNode; - let _this = this; - let flipXChange: boolean = false; - let flipYChange: boolean = false; - - return new MouseDragTracker(container, '.editor-image-decorator-handle', { - down(event: MouseEvent, handle: HTMLElement) { - context.editor.getEditorState().read(() => { - startingWidth = node.getWidth() || startingWidth; - startingHeight = node.getHeight() || startingHeight; - if (node.getHeight()) { - hasHeight = true; - } - startingRatio = startingWidth / startingHeight; - }); - - flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); - flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne'); - }, - move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { - let xChange = distance.x; - if (flipXChange) { - xChange = 0 - xChange; - } - let yChange = distance.y; - if (flipYChange) { - yChange = 0 - yChange; - } - const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); - const increase = xChange + yChange > 0; - const directedChange = increase ? balancedChange : 0-balancedChange; - const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); - let newHeight = 0; - if (hasHeight) { - newHeight = newWidth * startingRatio; - } - - const updateOptions = firstChange ? {} : {tag: 'history-merge'}; - context.editor.update(() => { - const node = _this.getNode() as ImageNode; - node.setWidth(newWidth); - node.setHeight(newHeight); - }, updateOptions); - firstChange = false; - }, - up() { - _this.dragLastMouseUp = Date.now(); - } - }); - } - -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 75440aed8..329b11956 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,4 +1,4 @@ -import {BaseSelection, LexicalEditor} from "lexical"; +import {$isElementNode, BaseSelection, LexicalEditor} from "lexical"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; @@ -7,8 +7,7 @@ import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; import { $getBlockElementNodesInSelection, - $getDecoratorNodesInSelection, - $selectionContainsAlignment, getLastSelection + $selectionContainsAlignment, $selectSingleNode, $toggleSelection, getLastSelection } from "../../../utils/selection"; import {CommonBlockAlignment} from "../../../nodes/_common"; import {nodeHasAlignment} from "../../../utils/nodes"; @@ -17,12 +16,12 @@ import {nodeHasAlignment} from "../../../utils/nodes"; function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { const selection = getLastSelection(editor); const selectionNodes = selection?.getNodes() || []; - const decorators = $getDecoratorNodesInSelection(selection); - // Handle decorator node selection alignment - if (selectionNodes.length === 1 && decorators.length === 1 && nodeHasAlignment(decorators[0])) { - decorators[0].setAlignment(alignment); - console.log('setting for decorator!'); + // Handle inline node selection alignment + if (selectionNodes.length === 1 && $isElementNode(selectionNodes[0]) && selectionNodes[0].isInline() && nodeHasAlignment(selectionNodes[0])) { + selectionNodes[0].setAlignment(alignment); + $selectSingleNode(selectionNodes[0]); + $toggleSelection(editor); return; } @@ -33,6 +32,7 @@ function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAli node.setAlignment(alignment) } } + $toggleSelection(editor); } export const alignLeft: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts new file mode 100644 index 000000000..cceb58b6b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts @@ -0,0 +1,167 @@ +import {BaseSelection,} from "lexical"; +import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; +import {el} from "../../../utils/dom"; +import {$isImageNode, ImageNode} from "../../../nodes/image"; +import {EditorUiContext} from "../core"; + +class ImageResizer { + protected context: EditorUiContext; + protected dom: HTMLElement|null = null; + protected scrollContainer: HTMLElement; + + protected mouseTracker: MouseDragTracker|null = null; + protected activeSelection: string = ''; + + constructor(context: EditorUiContext) { + this.context = context; + this.scrollContainer = context.scrollDOM; + + this.onSelectionChange = this.onSelectionChange.bind(this); + context.manager.onSelectionChange(this.onSelectionChange); + } + + onSelectionChange(selection: BaseSelection|null) { + const nodes = selection?.getNodes() || []; + if (this.activeSelection) { + this.hide(); + } + + if (nodes.length === 1 && $isImageNode(nodes[0])) { + const imageNode = nodes[0]; + const nodeKey = imageNode.getKey(); + const imageDOM = this.context.editor.getElementByKey(nodeKey); + + if (imageDOM) { + this.showForImage(imageNode, imageDOM); + } + } + } + + teardown() { + this.context.manager.offSelectionChange(this.onSelectionChange); + this.hide(); + } + + protected showForImage(node: ImageNode, dom: HTMLElement) { + this.dom = this.buildDOM(); + + const ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-image-resizer-ghost'}); + this.dom.append(ghost); + + this.context.scrollDOM.append(this.dom); + this.updateDOMPosition(dom); + + this.mouseTracker = this.setupTracker(this.dom, node, dom); + this.activeSelection = node.getKey(); + } + + protected updateDOMPosition(imageDOM: HTMLElement) { + if (!this.dom) { + return; + } + + const imageBounds = imageDOM.getBoundingClientRect(); + this.dom.style.left = imageDOM.offsetLeft + 'px'; + this.dom.style.top = imageDOM.offsetTop + 'px'; + this.dom.style.width = imageBounds.width + 'px'; + this.dom.style.height = imageBounds.height + 'px'; + } + + protected updateDOMSize(width: number, height: number): void { + if (!this.dom) { + return; + } + + this.dom.style.width = width + 'px'; + this.dom.style.height = height + 'px'; + } + + protected hide() { + this.mouseTracker?.teardown(); + this.dom?.remove(); + this.activeSelection = ''; + } + + protected buildDOM() { + const handleClasses = ['nw', 'ne', 'se', 'sw']; + const handleElems = handleClasses.map(c => { + return el('div', {class: `editor-image-resizer-handle ${c}`}); + }); + + return el('div', { + class: 'editor-image-resizer', + }, handleElems); + } + + setupTracker(container: HTMLElement, node: ImageNode, imageDOM: HTMLElement): MouseDragTracker { + let startingWidth: number = 0; + let startingHeight: number = 0; + let startingRatio: number = 0; + let hasHeight = false; + let _this = this; + let flipXChange: boolean = false; + let flipYChange: boolean = false; + + const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => { + let xChange = distance.x; + if (flipXChange) { + xChange = 0 - xChange; + } + let yChange = distance.y; + if (flipYChange) { + yChange = 0 - yChange; + } + + const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); + const increase = xChange + yChange > 0; + const directedChange = increase ? balancedChange : 0-balancedChange; + const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); + const newHeight = newWidth * startingRatio; + + return {width: newWidth, height: newHeight}; + }; + + return new MouseDragTracker(container, '.editor-image-resizer-handle', { + down(event: MouseEvent, handle: HTMLElement) { + _this.dom?.classList.add('active'); + _this.context.editor.getEditorState().read(() => { + const imageRect = imageDOM.getBoundingClientRect(); + startingWidth = node.getWidth() || imageRect.width; + startingHeight = node.getHeight() || imageRect.height; + if (node.getHeight()) { + hasHeight = true; + } + startingRatio = startingWidth / startingHeight; + }); + + flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); + flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne'); + }, + move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + const size = calculateSize(distance); + _this.updateDOMSize(size.width, size.height); + }, + up(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + const size = calculateSize(distance); + _this.context.editor.update(() => { + node.setWidth(size.width); + node.setHeight(hasHeight ? size.height : 0); + _this.context.manager.triggerLayoutUpdate(); + requestAnimationFrame(() => { + _this.updateDOMPosition(imageDOM); + }) + }); + _this.dom?.classList.remove('active'); + } + }); + } +} + + +export function registerImageResizer(context: EditorUiContext): (() => void) { + const resizer = new ImageResizer(context); + + return () => { + resizer.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index f10e85b47..8fda66cb2 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -144,6 +144,14 @@ export class EditorUIManager { this.selectionChangeHandlers.delete(handler); } + triggerLayoutUpdate(): void { + window.requestAnimationFrame(() => { + for (const toolbar of this.activeContextToolbars) { + toolbar.updatePosition(); + } + }); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; @@ -220,13 +228,8 @@ export class EditorUIManager { } protected setupEventListeners(context: EditorUiContext) { - const updateToolbars = (event: Event) => { - for (const toolbar of this.activeContextToolbars) { - toolbar.updatePosition(); - } - }; - - window.addEventListener('scroll', updateToolbars, {capture: true, passive: true}); - window.addEventListener('resize', updateToolbars, {passive: true}); + const layoutUpdate = this.triggerLayoutUpdate.bind(this); + window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true}); + window.addEventListener('resize', layoutUpdate, {passive: true}); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3b6d195b7..71a2623d6 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,7 +6,6 @@ import { getMainEditorFullToolbar, getTableToolbarContent } from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; @@ -64,7 +63,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro }); // Register image decorator listener - manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('code', CodeBlockDecorator); manager.registerDecoratorType('diagram', DiagramDecorator); diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 791eb7499..4f565fa10 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -1,6 +1,6 @@ import { $createNodeSelection, - $createParagraphNode, + $createParagraphNode, $createRangeSelection, $getRoot, $getSelection, $isDecoratorNode, $isElementNode, @@ -106,6 +106,18 @@ export function $selectSingleNode(node: LexicalNode) { $setSelection(nodeSelection); } +export function $toggleSelection(editor: LexicalEditor) { + const lastSelection = getLastSelection(editor); + + if (lastSelection) { + window.requestAnimationFrame(() => { + editor.update(() => { + $setSelection(lastSelection.clone()); + }) + }); + } +} + export function $selectionContainsNode(selection: BaseSelection | null, node: LexicalNode): boolean { if (!selection) { return false; @@ -122,7 +134,11 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le } export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean { - const nodes = $getBlockElementNodesInSelection(selection); + + const nodes = [ + ...(selection?.getNodes() || []), + ...$getBlockElementNodesInSelection(selection) + ]; for (const node of nodes) { if (nodeHasAlignment(node) && node.getAlignment() === alignment) { return true; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 78e518bd5..80633df94 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -31,6 +31,7 @@ body.editor-is-fullscreen { } } .editor-content-wrap { + position: relative; overflow-y: scroll; } @@ -287,23 +288,20 @@ body.editor-is-fullscreen { position: relative; display: inline-flex; } -.editor-image-decorator { +.editor-image-resizer { position: absolute; left: 0; right: 0; - width: 100%; - height: 100%; display: inline-block; - &.selected { - border: 1px dashed var(--editor-color-primary); - } + outline: 2px dashed var(--editor-color-primary); } -.editor-image-decorator-handle { +.editor-image-resizer-handle { position: absolute; display: block; width: 10px; height: 10px; border: 2px solid var(--editor-color-primary); + z-index: 3; background-color: #FFF; user-select: none; &.nw { @@ -327,6 +325,20 @@ body.editor-is-fullscreen { cursor: sw-resize; } } +.editor-image-resizer-ghost { + opacity: 0.5; + display: none; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 2; + pointer-events: none; +} +.editor-image-resizer.active .editor-image-resizer-ghost { + display: block; +} .editor-table-marker { position: fixed; From bed2c29a33f6e109ce1dd2ef76fc9fbd7a217080 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 8 Sep 2024 13:37:13 +0100 Subject: [PATCH 085/107] Lexical: Added media resize support via drag handles --- resources/js/wysiwyg/index.ts | 4 +- resources/js/wysiwyg/nodes/_common.ts | 7 ++ resources/js/wysiwyg/nodes/callout.ts | 1 + resources/js/wysiwyg/nodes/media.ts | 55 +++++++++++++- resources/js/wysiwyg/todo.md | 6 +- .../ui/framework/helpers/image-resizer.ts | 71 +++++++++++-------- resources/js/wysiwyg/utils/dom.ts | 16 +++++ resources/sass/_editor.scss | 16 +++-- 8 files changed, 133 insertions(+), 43 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index c312919db..64b59492b 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -13,7 +13,7 @@ import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler" import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; -import {registerImageResizer} from "./ui/framework/helpers/image-resizer"; +import {registerNodeResizer} from "./ui/framework/helpers/image-resizer"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -56,7 +56,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), - registerImageResizer(context), + registerNodeResizer(context), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts index cc45dc910..ff957f953 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -63,4 +63,11 @@ export function updateElementWithCommonBlockProps(element: HTMLElement, node: Co if (node.__alignment) { element.classList.add('align-' + node.__alignment); } +} + +export interface NodeHasSize { + setHeight(height: number): void; + setWidth(width: number): void; + getHeight(): number; + getWidth(): number; } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index 8018190c8..ededc0f29 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -34,6 +34,7 @@ export class CalloutNode extends ElementNode { static clone(node: CalloutNode) { const newNode = new CalloutNode(node.__category, node.__key); newNode.__id = node.__id; + newNode.__alignment = node.__alignment; return newNode; } diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index 4159cd457..5b3c1b9c2 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -1,6 +1,6 @@ import { DOMConversion, - DOMConversionMap, DOMConversionOutput, + DOMConversionMap, DOMConversionOutput, DOMExportOutput, ElementNode, LexicalEditor, LexicalNode, @@ -8,7 +8,7 @@ import { } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../utils/dom"; +import {el, sizeToPixels} from "../utils/dom"; import { CommonBlockAlignment, SerializedCommonBlockNode, @@ -16,6 +16,7 @@ import { updateElementWithCommonBlockProps } from "./_common"; import {elem} from "../../services/dom"; +import {$selectSingleNode} from "../utils/selection"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -89,6 +90,8 @@ export class MediaNode extends ElementNode { const newNode = new MediaNode(node.__tag, node.__key); newNode.__attributes = Object.assign({}, node.__attributes); newNode.__sources = node.__sources.map(s => Object.assign({}, s)); + newNode.__id = node.__id; + newNode.__alignment = node.__alignment; return newNode; } @@ -166,7 +169,35 @@ export class MediaNode extends ElementNode { return self.__alignment; } - createDOM(_config: EditorConfig, _editor: LexicalEditor) { + setHeight(height: number): void { + if (!height) { + return; + } + + const attrs = Object.assign({}, this.getAttributes(), {height}); + this.setAttributes(attrs); + } + + getHeight(): number { + const self = this.getLatest(); + return sizeToPixels(self.__attributes.height || '0'); + } + + setWidth(width: number): void { + const attrs = Object.assign({}, this.getAttributes(), {width}); + this.setAttributes(attrs); + } + + getWidth(): number { + const self = this.getLatest(); + return sizeToPixels(self.__attributes.width || '0'); + } + + isInline(): boolean { + return true; + } + + createInnerDOM() { const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; const sourceEls = sources.map(source => el('source', source)); const element = el(this.__tag, this.__attributes, sourceEls); @@ -174,6 +205,19 @@ export class MediaNode extends ElementNode { return element; } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const media = this.createInnerDOM(); + const wrap = el('span', { + class: media.className + ' editor-media-wrap', + }, [media]); + + wrap.addEventListener('click', e => { + _editor.update(() => $selectSingleNode(this)); + }); + + return wrap; + } + updateDOM(prevNode: unknown, dom: HTMLElement) { return true; } @@ -202,6 +246,11 @@ export class MediaNode extends ElementNode { }; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createInnerDOM(); + return { element }; + } + exportJSON(): SerializedMediaNode { return { ...super.exportJSON(), diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 064d65e4c..92042295c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,7 +6,6 @@ ## Main Todo -- Media resize support (like images) - Mac: Shortcut support via command. ## Secondary Todo @@ -17,9 +16,6 @@ ## Bugs -- Can't select iframe embeds by themselves. (click enters iframe) - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. -- Template drag/drop not handled when outside core editor area (ignored in margin area). -- Table row copy/paste does not handle merged cells - - TinyMCE fills gaps with the cells that would be visually in the row \ No newline at end of file +- Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts index cceb58b6b..c8105eafc 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts @@ -1,10 +1,16 @@ -import {BaseSelection,} from "lexical"; +import {BaseSelection, LexicalNode,} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {el} from "../../../utils/dom"; -import {$isImageNode, ImageNode} from "../../../nodes/image"; +import {$isImageNode} from "../../../nodes/image"; import {EditorUiContext} from "../core"; +import {NodeHasSize} from "../../../nodes/_common"; +import {$isMediaNode} from "../../../nodes/media"; -class ImageResizer { +function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode { + return $isImageNode(node) || $isMediaNode(node); +} + +class NodeResizer { protected context: EditorUiContext; protected dom: HTMLElement|null = null; protected scrollContainer: HTMLElement; @@ -26,13 +32,17 @@ class ImageResizer { this.hide(); } - if (nodes.length === 1 && $isImageNode(nodes[0])) { - const imageNode = nodes[0]; - const nodeKey = imageNode.getKey(); - const imageDOM = this.context.editor.getElementByKey(nodeKey); + if (nodes.length === 1 && isNodeWithSize(nodes[0])) { + const node = nodes[0]; + const nodeKey = node.getKey(); + let nodeDOM = this.context.editor.getElementByKey(nodeKey); - if (imageDOM) { - this.showForImage(imageNode, imageDOM); + if (nodeDOM && nodeDOM.nodeName === 'SPAN') { + nodeDOM = nodeDOM.firstElementChild as HTMLElement; + } + + if (nodeDOM) { + this.showForNode(node, nodeDOM); } } } @@ -42,10 +52,13 @@ class ImageResizer { this.hide(); } - protected showForImage(node: ImageNode, dom: HTMLElement) { + protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) { this.dom = this.buildDOM(); - const ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-image-resizer-ghost'}); + let ghost = el('span', {class: 'editor-node-resizer-ghost'}); + if ($isImageNode(node)) { + ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'}); + } this.dom.append(ghost); this.context.scrollDOM.append(this.dom); @@ -55,16 +68,16 @@ class ImageResizer { this.activeSelection = node.getKey(); } - protected updateDOMPosition(imageDOM: HTMLElement) { + protected updateDOMPosition(nodeDOM: HTMLElement) { if (!this.dom) { return; } - const imageBounds = imageDOM.getBoundingClientRect(); - this.dom.style.left = imageDOM.offsetLeft + 'px'; - this.dom.style.top = imageDOM.offsetTop + 'px'; - this.dom.style.width = imageBounds.width + 'px'; - this.dom.style.height = imageBounds.height + 'px'; + const nodeDOMBounds = nodeDOM.getBoundingClientRect(); + this.dom.style.left = nodeDOM.offsetLeft + 'px'; + this.dom.style.top = nodeDOM.offsetTop + 'px'; + this.dom.style.width = nodeDOMBounds.width + 'px'; + this.dom.style.height = nodeDOMBounds.height + 'px'; } protected updateDOMSize(width: number, height: number): void { @@ -85,15 +98,15 @@ class ImageResizer { protected buildDOM() { const handleClasses = ['nw', 'ne', 'se', 'sw']; const handleElems = handleClasses.map(c => { - return el('div', {class: `editor-image-resizer-handle ${c}`}); + return el('div', {class: `editor-node-resizer-handle ${c}`}); }); return el('div', { - class: 'editor-image-resizer', + class: 'editor-node-resizer', }, handleElems); } - setupTracker(container: HTMLElement, node: ImageNode, imageDOM: HTMLElement): MouseDragTracker { + setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker { let startingWidth: number = 0; let startingHeight: number = 0; let startingRatio: number = 0; @@ -116,22 +129,22 @@ class ImageResizer { const increase = xChange + yChange > 0; const directedChange = increase ? balancedChange : 0-balancedChange; const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); - const newHeight = newWidth * startingRatio; + const newHeight = Math.round(newWidth * startingRatio); return {width: newWidth, height: newHeight}; }; - return new MouseDragTracker(container, '.editor-image-resizer-handle', { + return new MouseDragTracker(container, '.editor-node-resizer-handle', { down(event: MouseEvent, handle: HTMLElement) { _this.dom?.classList.add('active'); _this.context.editor.getEditorState().read(() => { - const imageRect = imageDOM.getBoundingClientRect(); - startingWidth = node.getWidth() || imageRect.width; - startingHeight = node.getHeight() || imageRect.height; + const domRect = nodeDOM.getBoundingClientRect(); + startingWidth = node.getWidth() || domRect.width; + startingHeight = node.getHeight() || domRect.height; if (node.getHeight()) { hasHeight = true; } - startingRatio = startingWidth / startingHeight; + startingRatio = startingHeight / startingWidth; }); flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); @@ -148,7 +161,7 @@ class ImageResizer { node.setHeight(hasHeight ? size.height : 0); _this.context.manager.triggerLayoutUpdate(); requestAnimationFrame(() => { - _this.updateDOMPosition(imageDOM); + _this.updateDOMPosition(nodeDOM); }) }); _this.dom?.classList.remove('active'); @@ -158,8 +171,8 @@ class ImageResizer { } -export function registerImageResizer(context: EditorUiContext): (() => void) { - const resizer = new ImageResizer(context); +export function registerNodeResizer(context: EditorUiContext): (() => void) { + const resizer = new NodeResizer(context); return () => { resizer.teardown(); diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts index a307bdd75..d5c63a816 100644 --- a/resources/js/wysiwyg/utils/dom.ts +++ b/resources/js/wysiwyg/utils/dom.ts @@ -31,6 +31,22 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = ' return size; } +export function sizeToPixels(size: string): number { + if (/^-?\d+$/.test(size)) { + return Number(size); + } + + if (/^-?\d+\.\d+$/.test(size)) { + return Math.round(Number(size)); + } + + if (/^-?\d+px\s*$/.test(size)) { + return Number(size.trim().replace('px', '')); + } + + return 0; +} + export type StyleMap = Map; /** diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 80633df94..31ce564be 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -288,14 +288,14 @@ body.editor-is-fullscreen { position: relative; display: inline-flex; } -.editor-image-resizer { +.editor-node-resizer { position: absolute; left: 0; right: 0; display: inline-block; outline: 2px dashed var(--editor-color-primary); } -.editor-image-resizer-handle { +.editor-node-resizer-handle { position: absolute; display: block; width: 10px; @@ -325,7 +325,7 @@ body.editor-is-fullscreen { cursor: sw-resize; } } -.editor-image-resizer-ghost { +.editor-node-resizer-ghost { opacity: 0.5; display: none; position: absolute; @@ -335,8 +335,9 @@ body.editor-is-fullscreen { height: 100%; z-index: 2; pointer-events: none; + background-color: var(--editor-color-primary); } -.editor-image-resizer.active .editor-image-resizer-ghost { +.editor-node-resizer.active .editor-node-resizer-ghost { display: block; } @@ -372,6 +373,13 @@ body.editor-is-fullscreen { outline: 2px dashed var(--editor-color-primary); } +.editor-media-wrap { + cursor: not-allowed; + iframe { + pointer-events: none; + } +} + /** * Fake task list checkboxes */ From 16518a4f893f2c2be3a08292951650881bf4e278 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 8 Sep 2024 15:54:59 +0100 Subject: [PATCH 086/107] Lexical: Range of bug fixes, Updated lexical version - Updated selection change detection to be more accurate - Added UI refresh for extra actions - Fixed remove link deleting contents --- package-lock.json | 287 +++++++++--------- package.json | 18 +- resources/js/wysiwyg/index.ts | 16 +- resources/js/wysiwyg/nodes/image.ts | 2 +- resources/js/wysiwyg/todo.md | 2 - .../wysiwyg/ui/defaults/buttons/controls.ts | 2 + .../js/wysiwyg/ui/defaults/buttons/objects.ts | 17 +- resources/js/wysiwyg/ui/framework/manager.ts | 24 +- 8 files changed, 195 insertions(+), 173 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3867a1d1f..1d2527661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,20 +18,20 @@ "@codemirror/state": "^6.3.3", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.22.2", - "@lexical/history": "^0.16.0", - "@lexical/html": "^0.16.0", - "@lexical/link": "^0.16.0", - "@lexical/list": "^0.16.0", - "@lexical/rich-text": "^0.16.0", - "@lexical/selection": "^0.16.0", - "@lexical/table": "^0.16.0", - "@lexical/utils": "^0.16.0", + "@lexical/history": "^0.17.0", + "@lexical/html": "^0.17.0", + "@lexical/link": "^0.17.0", + "@lexical/list": "^0.17.0", + "@lexical/rich-text": "^0.17.0", + "@lexical/selection": "^0.17.0", + "@lexical/table": "^0.17.0", + "@lexical/utils": "^0.17.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.16.0", + "lexical": "^0.17.0", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", @@ -51,9 +51,9 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.3.tgz", - "integrity": "sha512-Vl/tIeRVVUCRDuOG48lttBasNQu8usGgXQawBXI7WJAiUDSFOfzflmEsZFZo48mAvAaa4FZ/4/yLLxFtdJaKYA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", + "integrity": "sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -68,9 +68,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", - "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz", + "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -79,15 +79,15 @@ } }, "node_modules/@codemirror/lang-css": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz", - "integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", + "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", - "@lezer/css": "^1.0.0" + "@lezer/css": "^1.1.7" } }, "node_modules/@codemirror/lang-html": { @@ -182,9 +182,9 @@ } }, "node_modules/@codemirror/legacy-modes": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.0.tgz", - "integrity": "sha512-5m/K+1A6gYR0e+h/dEde7LoGimMjRtWXZFg4Lo70cc8HzjSdHe3fLwjWMR0VRl5KFT1SxalSap7uMgPKF28wBA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.1.tgz", + "integrity": "sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==", "dependencies": { "@codemirror/language": "^6.0.0" } @@ -226,9 +226,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.28.2", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.2.tgz", - "integrity": "sha512-A3DmyVfjgPsGIjiJqM/zvODUAPQdQl3ci0ghehYNnbt5x+o76xq+dL5+mMBuysDXnI3kapgOkoeJ0sbtL/3qPw==", + "version": "6.33.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.33.0.tgz", + "integrity": "sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -619,9 +619,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.1.tgz", - "integrity": "sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -695,91 +695,91 @@ "dev": true }, "node_modules/@lexical/clipboard": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.16.0.tgz", - "integrity": "sha512-eYMJ6jCXpWBVC05Mu9HLMysrBbfi++xFfsm+Yo7A6kYGrqYUhpXqjJkYnw1xdZYL3bV73Oe4ByVJuq42GU+Mqw==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.17.1.tgz", + "integrity": "sha512-OVqnEfWX8XN5xxuMPo6BfgGKHREbz++D5V5ISOiml0Z8fV/TQkdgwqbBJcUdJHGRHWSUwdK7CWGs/VALvVvZyw==", "dependencies": { - "@lexical/html": "0.16.0", - "@lexical/list": "0.16.0", - "@lexical/selection": "0.16.0", - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/html": "0.17.1", + "@lexical/list": "0.17.1", + "@lexical/selection": "0.17.1", + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/history": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.16.0.tgz", - "integrity": "sha512-xwFxgDZGviyGEqHmgt6A6gPhsyU/yzlKRk9TBUVByba3khuTknlJ1a80H5jb+OYcrpiElml7iVuGYt+oC7atCA==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.17.1.tgz", + "integrity": "sha512-OU/ohajz4FXchUhghsWC7xeBPypFe50FCm5OePwo767G7P233IztgRKIng2pTT4zhCPW7S6Mfl53JoFHKehpWA==", "dependencies": { - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/html": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.16.0.tgz", - "integrity": "sha512-okxn3q/1qkUpCZNEFRI39XeJj4YRjb6prm3WqZgP4d39DI1W24feeTZJjYRCW+dc3NInwFaolU3pNA2MGkjRtg==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.17.1.tgz", + "integrity": "sha512-yGG+K2DXl7Wn2DpNuZ0Y3uCHJgfHkJN3/MmnFb4jLnH1FoJJiuy7WJb/BRRh9H+6xBJ9v70iv+kttDJ0u1xp5w==", "dependencies": { - "@lexical/selection": "0.16.0", - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/selection": "0.17.1", + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/link": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.16.0.tgz", - "integrity": "sha512-ppvJSh/XGqlzbeymOiwcXJcUcrqgQqTK2QXTBAZq7JThtb0WsJxYd2CSLSN+Ycu23prnwqOqILcU0+34+gAVFw==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.17.1.tgz", + "integrity": "sha512-qFJEKBesZAtR8kfJfIVXRFXVw6dwcpmGCW7duJbtBRjdLjralOxrlVKyFhW9PEXGhi4Mdq2Ux16YnnDncpORdQ==", "dependencies": { - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/list": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.16.0.tgz", - "integrity": "sha512-nBx/DMM7nCgnOzo1JyNnVaIrk/Xi5wIPNi8jixrEV6w9Om2K6dHutn/79Xzp2dQlNGSLHEDjky6N2RyFgmXh0g==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.17.1.tgz", + "integrity": "sha512-k9ZnmQuBvW+xVUtWJZwoGtiVG2cy+hxzkLGU4jTq1sqxRIoSeGcjvhFAK8JSEj4i21SgkB1FmkWXoYK5kbwtRA==", "dependencies": { - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/rich-text": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.16.0.tgz", - "integrity": "sha512-AGTD6yJZ+kj2TNah1r7/6vyufs6fZANeSvv9x5eG+WjV4uyUJYkd1qR8C5gFZHdkyr+bhAcsAXvS039VzAxRrQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.17.1.tgz", + "integrity": "sha512-T3kvj4P1OpedX9jvxN3WN8NP1Khol6mCW2ScFIRNRz2dsXgyN00thH1Q1J/uyu7aKyGS7rzcY0rb1Pz1qFufqQ==", "dependencies": { - "@lexical/clipboard": "0.16.0", - "@lexical/selection": "0.16.0", - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/clipboard": "0.17.1", + "@lexical/selection": "0.17.1", + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/selection": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.16.0.tgz", - "integrity": "sha512-trT9gQVJ2j6AwAe7tHJ30SRuxCpV6yR9LFtggxphHsXSvJYnoHC0CXh1TF2jHl8Gd5OsdWseexGLBE4Y0V3gwQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.17.1.tgz", + "integrity": "sha512-qBKVn+lMV2YIoyRELNr1/QssXx/4c0id9NCB/BOuYlG8du5IjviVJquEF56NEv2t0GedDv4BpUwkhXT2QbNAxA==", "dependencies": { - "lexical": "0.16.0" + "lexical": "0.17.1" } }, "node_modules/@lexical/table": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.16.0.tgz", - "integrity": "sha512-A66K779kxdr0yH2RwT2itsMnkzyFLFNPXyiWGLobCH8ON4QPuBouZvjbRHBe8Pe64yJ0c1bRDxSbTqUi9Wt3Gg==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.17.1.tgz", + "integrity": "sha512-2fUYPmxhyuMQX3MRvSsNaxbgvwGNJpHaKx1Ldc+PT2MvDZ6ALZkfsxbi0do54Q3i7dOon8/avRp4TuVaCnqvoA==", "dependencies": { - "@lexical/utils": "0.16.0", - "lexical": "0.16.0" + "@lexical/utils": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lexical/utils": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.16.0.tgz", - "integrity": "sha512-GWmFEmd7o3GHqJBaEwzuZQbfTNI3Gg8ReGuHMHABgrkhZ8j2NggoRBlxsQLG0f7BewfTMVwbye22yBPq78775w==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.17.1.tgz", + "integrity": "sha512-jCQER5EsvhLNxKH3qgcpdWj/necUb82Xjp8qWQ3c0tyL07hIRm2tDRA/s9mQmvcP855HEZSmGVmR5SKtkcEAVg==", "dependencies": { - "@lexical/list": "0.16.0", - "@lexical/selection": "0.16.0", - "@lexical/table": "0.16.0", - "lexical": "0.16.0" + "@lexical/list": "0.17.1", + "@lexical/selection": "0.17.1", + "@lexical/table": "0.17.1", + "lexical": "0.17.1" } }, "node_modules/@lezer/common": { @@ -811,9 +811,9 @@ } }, "node_modules/@lezer/highlight": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", - "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", "dependencies": { "@lezer/common": "^1.0.0" } @@ -849,17 +849,17 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.1.tgz", - "integrity": "sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "dependencies": { "@lezer/common": "^1.0.0" } }, "node_modules/@lezer/markdown": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.0.tgz", - "integrity": "sha512-ErbEQ15eowmJUyT095e9NJc3BI9yZ894fjSDtHftD0InkfUBGgnKSU6dvan9jqsZuNHg2+ag/1oyDRxNsENupQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.1.tgz", + "integrity": "sha512-DGlzU/i8DC8k0uz1F+jeePrkATl0jWakauTzftMQOcbaMkHbNSRki/4E2tOzJWsVpoKYhe7iTJ03aepdwVUXUA==", "dependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0" @@ -920,6 +920,12 @@ "node": ">= 8" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, "node_modules/@ssddanbrown/codemirror-lang-smarty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-smarty/-/codemirror-lang-smarty-1.0.0.tgz", @@ -948,9 +954,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -1437,12 +1443,12 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1818,9 +1824,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", - "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz", + "integrity": "sha512-gbBE5Hitek/oG6MUVj6sFuzEjA/ClzNflVrLovHi/JgLdC7fiN5gLAY1WIPW1a0V5I999MnsrvVrCOGmmVqDBQ==", "dev": true, "dependencies": { "debug": "^3.2.7" @@ -1844,26 +1850,27 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", + "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", "dev": true, "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", + "eslint-module-utils": "^2.9.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", "semver": "^6.3.1", "tsconfig-paths": "^3.15.0" }, @@ -1953,9 +1960,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -2372,18 +2379,18 @@ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", "dev": true }, "node_modules/import-fresh": { @@ -2517,9 +2524,9 @@ } }, "node_modules/is-core-module": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", - "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, "dependencies": { "hasown": "^2.0.2" @@ -2807,9 +2814,9 @@ } }, "node_modules/lexical": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.16.0.tgz", - "integrity": "sha512-Skn45Qhriazq4fpAtwnAB11U//GKc4vjzx54xsV3TkDLDvWpbL4Z9TNRwRoN3g7w8AkWnqjeOSODKkrjgfRSrg==" + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.17.1.tgz", + "integrity": "sha512-72/MhR7jqmyqD10bmJw8gztlCm4KDDT+TPtU4elqXrEvHoO5XENi34YAEUD9gIkPfqSwyLa9mwAX1nKzIr5xEA==" }, "node_modules/linkify-it": { "version": "5.0.0", @@ -2948,9 +2955,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, "node_modules/natural-compare": { @@ -3659,9 +3666,9 @@ } }, "node_modules/sass": { - "version": "1.77.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", - "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", + "integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -3779,9 +3786,9 @@ } }, "node_modules/sortablejs": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", - "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz", + "integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg==" }, "node_modules/source-map-js": { "version": "1.2.0", @@ -3819,9 +3826,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.18", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", - "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", "dev": true }, "node_modules/string-width": { @@ -4116,9 +4123,9 @@ } }, "node_modules/typescript": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", - "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 71debf2bd..d39bf5a2c 100644 --- a/package.json +++ b/package.json @@ -43,20 +43,20 @@ "@codemirror/state": "^6.3.3", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.22.2", - "@lexical/history": "^0.16.0", - "@lexical/html": "^0.16.0", - "@lexical/link": "^0.16.0", - "@lexical/list": "^0.16.0", - "@lexical/rich-text": "^0.16.0", - "@lexical/selection": "^0.16.0", - "@lexical/table": "^0.16.0", - "@lexical/utils": "^0.16.0", + "@lexical/history": "^0.17.0", + "@lexical/html": "^0.17.0", + "@lexical/link": "^0.17.0", + "@lexical/list": "^0.17.0", + "@lexical/rich-text": "^0.17.0", + "@lexical/selection": "^0.17.0", + "@lexical/table": "^0.17.0", + "@lexical/utils": "^0.17.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.16.0", + "lexical": "^0.17.0", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 64b59492b..1e5e4b3ce 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,4 +1,4 @@ -import {createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; +import {$getSelection, createEditor, CreateEditorArgs, isCurrentlyReadOnlyMode, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; @@ -69,7 +69,19 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st } let changeFromLoading = true; - editor.registerUpdateListener(({editorState, dirtyElements, dirtyLeaves}) => { + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Watch for selection changes to update the UI on change + // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit + // for all selection changes, so this proved more reliable. + const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false); + if (selectionChange) { + editor.update(() => { + const selection = $getSelection(); + context.manager.triggerStateUpdate({ + editor, selection, + }); + }); + } // Emit change event to component system (for draft detection) on actual user content change if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index c9d11d871..b6d362b62 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -40,7 +40,7 @@ export class ImageNode extends ElementNode { alt: node.__alt, width: node.__width, height: node.__height, - }); + }, node.__key); newNode.__alignment = node.__alignment; return newNode; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 92042295c..15a59c734 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -16,6 +16,4 @@ ## Bugs -- Removing link around image via button deletes image, not just link -- `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. - Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 2a2fecc40..8829d241f 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -19,6 +19,7 @@ export const undo: EditorButtonDefinition = { icon: undoIcon, action(context: EditorUiContext) { context.editor.dispatchCommand(UNDO_COMMAND, undefined); + context.manager.triggerFutureStateRefresh(); }, isActive(selection: BaseSelection|null): boolean { return false; @@ -38,6 +39,7 @@ export const redo: EditorButtonDefinition = { icon: redoIcon, action(context: EditorUiContext) { context.editor.dispatchCommand(REDO_COMMAND, undefined); + context.manager.triggerFutureStateRefresh(); }, isActive(selection: BaseSelection|null): boolean { return false; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 46556d3d1..fd95f9f35 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -6,7 +6,7 @@ import { $getRoot, $getSelection, $insertNodes, BaseSelection, - ElementNode + ElementNode, isCurrentlyReadOnlyMode } from "lexical"; import {$isLinkNode, LinkNode} from "@lexical/link"; import unlinkIcon from "@icons/editor/unlink.svg"; @@ -54,16 +54,17 @@ export const unlink: EditorButtonDefinition = { context.editor.update(() => { const selection = getLastSelection(context.editor); const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; - const selectionPoints = selection?.getStartEndPoints(); if (selectedLink) { - const newNode = $createTextNode(selectedLink.getTextContent()); - selectedLink.replace(newNode); - if (selectionPoints?.length === 2) { - newNode.select(selectionPoints[0].offset, selectionPoints[1].offset); - } else { - newNode.select(); + const contents = selectedLink.getChildren().reverse(); + for (const child of contents) { + selectedLink.insertAfter(child); } + selectedLink.remove(); + + contents[contents.length - 1].selectStart(); + + context.manager.triggerFutureStateRefresh(); } }); }, diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 8fda66cb2..732530375 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -108,7 +108,7 @@ export class EditorUIManager { this.contextToolbarDefinitionsByKey[key] = definition; } - protected triggerStateUpdate(update: EditorUiStateUpdate): void { + triggerStateUpdate(update: EditorUiStateUpdate): void { setLastSelection(update.editor, update.selection); this.toolbar?.updateState(update); this.updateContextToolbars(update); @@ -120,9 +120,20 @@ export class EditorUIManager { triggerStateRefresh(): void { const editor = this.getContext().editor; - this.triggerStateUpdate({ + const update = { editor, selection: getLastSelection(editor), + }; + + this.triggerStateUpdate(update); + this.updateContextToolbars(update); + } + + triggerFutureStateRefresh(): void { + requestAnimationFrame(() => { + this.getContext().editor.getEditorState().read(() => { + this.triggerStateRefresh(); + }); }); } @@ -195,15 +206,6 @@ export class EditorUIManager { } protected setupEditor(editor: LexicalEditor) { - // Update button states on editor selection change - editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { - this.triggerStateUpdate({ - editor: editor, - selection: $getSelection(), - }); - return false; - }, COMMAND_PRIORITY_LOW); - // Register our DOM decorate listener with the editor const domDecorateListener: DecoratorListener = (decorators: Record) => { editor.getEditorState().read(() => { From fd07aa0f05389055840a3d41c19cdfc1c81b4d0b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Sep 2024 12:28:01 +0100 Subject: [PATCH 087/107] Lexical: Further fixes - Improved node resizer positioning to be more accurate - Fixed drop handling not running within editor margin space - Made media dom update smarter to reduce reloads - Fixed media alignment, broken due to added wrapper --- resources/js/wysiwyg/index.ts | 2 +- resources/js/wysiwyg/nodes/media.ts | 38 ++++++++++++++++--- .../wysiwyg/services/drop-paste-handling.ts | 5 +++ resources/js/wysiwyg/todo.md | 2 +- .../{image-resizer.ts => node-resizer.ts} | 14 ++++--- resources/js/wysiwyg/utils/dom.ts | 8 ++++ resources/sass/_editor.scss | 11 ++++++ 7 files changed, 68 insertions(+), 12 deletions(-) rename resources/js/wysiwyg/ui/framework/helpers/{image-resizer.ts => node-resizer.ts} (92%) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 1e5e4b3ce..9015cea0c 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -13,7 +13,7 @@ import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler" import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; -import {registerNodeResizer} from "./ui/framework/helpers/image-resizer"; +import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index 5b3c1b9c2..fb940f893 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -4,18 +4,17 @@ import { ElementNode, LexicalEditor, LexicalNode, - SerializedElementNode, Spread + Spread } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el, sizeToPixels} from "../utils/dom"; +import {el, setOrRemoveAttribute, sizeToPixels} from "../utils/dom"; import { CommonBlockAlignment, SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps } from "./_common"; -import {elem} from "../../services/dom"; import {$selectSingleNode} from "../utils/selection"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; @@ -218,8 +217,37 @@ export class MediaNode extends ElementNode { return wrap; } - updateDOM(prevNode: unknown, dom: HTMLElement) { - return true; + updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean { + if (prevNode.__tag !== this.__tag) { + return true; + } + + if (JSON.stringify(prevNode.__sources) !== JSON.stringify(this.__sources)) { + return true; + } + + if (JSON.stringify(prevNode.__attributes) !== JSON.stringify(this.__attributes)) { + return true; + } + + const mediaEl = dom.firstElementChild as HTMLElement; + + if (prevNode.__id !== this.__id) { + setOrRemoveAttribute(mediaEl, 'id', this.__id); + } + + if (prevNode.__alignment !== this.__alignment) { + if (prevNode.__alignment) { + dom.classList.remove(`align-${prevNode.__alignment}`); + mediaEl.classList.remove(`align-${prevNode.__alignment}`); + } + if (this.__alignment) { + dom.classList.add(`align-${this.__alignment}`); + mediaEl.classList.add(`align-${this.__alignment}`); + } + } + + return false; } static importDOM(): DOMConversionMap|null { diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index 85d0235d8..07e35d443 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -103,6 +103,7 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo if (templateId) { insertTemplateToEditor(editor, templateId, event); event.preventDefault(); + event.stopPropagation(); return true; } @@ -114,6 +115,7 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo $insertNodesAtEvent(newNodes, event, editor); }); event.preventDefault(); + event.stopPropagation(); return true; } @@ -121,6 +123,7 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo const handled = handleMediaInsert(event.dataTransfer, context); if (handled) { event.preventDefault(); + event.stopPropagation(); return true; } } @@ -150,9 +153,11 @@ export function registerDropPasteHandling(context: EditorUiContext): () => void const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH); const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH); + context.scrollDOM.addEventListener('drop', dropListener); return () => { unregisterDrop(); unregisterPaste(); + context.scrollDOM.removeEventListener('drop', dropListener); }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 15a59c734..9c196d6d3 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -16,4 +16,4 @@ ## Bugs -- Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file +// \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts similarity index 92% rename from resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts rename to resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts index c8105eafc..2e4f2939c 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts @@ -73,11 +73,15 @@ class NodeResizer { return; } - const nodeDOMBounds = nodeDOM.getBoundingClientRect(); - this.dom.style.left = nodeDOM.offsetLeft + 'px'; - this.dom.style.top = nodeDOM.offsetTop + 'px'; - this.dom.style.width = nodeDOMBounds.width + 'px'; - this.dom.style.height = nodeDOMBounds.height + 'px'; + const scrollAreaRect = this.scrollContainer.getBoundingClientRect(); + const nodeRect = nodeDOM.getBoundingClientRect(); + const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop); + const left = nodeRect.left - scrollAreaRect.left; + + this.dom.style.top = `${top}px`; + this.dom.style.left = `${left}px`; + this.dom.style.width = nodeRect.width + 'px'; + this.dom.style.height = nodeRect.height + 'px'; } protected updateDOMSize(width: number, height: number): void { diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts index d5c63a816..bbb07cb41 100644 --- a/resources/js/wysiwyg/utils/dom.ts +++ b/resources/js/wysiwyg/utils/dom.ts @@ -70,4 +70,12 @@ export function extractStyleMapFromElement(element: HTMLElement): StyleMap { } return map; +} + +export function setOrRemoveAttribute(element: HTMLElement, name: string, value: string|null|undefined) { + if (value) { + element.setAttribute(name, value); + } else { + element.removeAttribute(name); + } } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 31ce564be..04f18702e 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -374,10 +374,21 @@ body.editor-is-fullscreen { } .editor-media-wrap { + display: inline-block; cursor: not-allowed; iframe { pointer-events: none; } + &.align-left { + float: left; + } + &.align-right { + float: right; + } + &.align-center { + display: block; + margin-inline: auto; + } } /** From fb49371c6bc7aa0bdb4acdeeac86185ff2cd7405 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Sep 2024 14:06:41 +0100 Subject: [PATCH 088/107] Lexical: Refined editor UI - Cleaned up dropdown lists to look integrated - Added icons for color picker clear and menu list items --- resources/icons/editor/color-clear.svg | 1 + resources/js/wysiwyg/todo.md | 1 + .../js/wysiwyg/ui/defaults/buttons/tables.ts | 3 ++ .../ui/framework/blocks/color-picker.ts | 8 +++- .../ui/framework/blocks/dropdown-button.ts | 4 +- .../ui/framework/blocks/menu-button.ts | 15 +++++++ .../wysiwyg/ui/framework/blocks/separator.ts | 10 +++++ resources/js/wysiwyg/ui/toolbars.ts | 17 +++++--- resources/sass/_editor.scss | 39 ++++++++++++++++++- 9 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 resources/icons/editor/color-clear.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/menu-button.ts create mode 100644 resources/js/wysiwyg/ui/framework/blocks/separator.ts diff --git a/resources/icons/editor/color-clear.svg b/resources/icons/editor/color-clear.svg new file mode 100644 index 000000000..5d0850282 --- /dev/null +++ b/resources/icons/editor/color-clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 9c196d6d3..498d286fd 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -11,6 +11,7 @@ ## Secondary Todo - Color picker support in table form color fields +- Color picker for color controls - Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index 49e36bdac..fc4196f0a 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -347,6 +347,7 @@ export const deleteColumn: EditorButtonDefinition = { export const cellProperties: EditorButtonDefinition = { label: 'Cell properties', + format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); @@ -361,6 +362,7 @@ export const cellProperties: EditorButtonDefinition = { export const mergeCells: EditorButtonDefinition = { label: 'Merge cells', + format: 'long', action(context: EditorUiContext) { context.editor.update(() => { const selection = $getSelection(); @@ -377,6 +379,7 @@ export const mergeCells: EditorButtonDefinition = { export const splitCell: EditorButtonDefinition = { label: 'Split cell', + format: 'long', action(context: EditorUiContext) { context.editor.update(() => { $unmergeCell(); diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index 48e313f5c..b068fb4f0 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -3,6 +3,8 @@ import {$getSelection} from "lexical"; import {$patchStyleText} from "@lexical/selection"; import {el} from "../../../utils/dom"; +import removeIcon from "@icons/editor/color-clear.svg"; + const colorChoices = [ '#000000', '#ffffff', @@ -52,11 +54,13 @@ export class EditorColorPicker extends EditorUiElement { }); }); - colorOptions.push(el('div', { + const removeButton = el('div', { class: 'editor-color-select-option', 'data-color': '', title: 'Clear color', - }, ['x'])); + }, []); + removeButton.innerHTML = removeIcon; + colorOptions.push(removeButton); const colorRows = []; for (let i = 0; i < colorOptions.length; i+=5) { diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index a7905a6dd..cba141f6c 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -2,6 +2,7 @@ import {handleDropdown} from "../helpers/dropdowns"; import {EditorContainerUiElement, EditorUiElement} from "../core"; import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; import {el} from "../../../utils/dom"; +import {EditorMenuButton} from "./menu-button"; export type EditorDropdownButtonOptions = { showOnHover?: boolean; @@ -29,7 +30,8 @@ export class EditorDropdownButton extends EditorContainerUiElement { if (options.button instanceof EditorButton) { this.button = options.button; } else { - this.button = new EditorButton({ + const type = options.button.format === 'long' ? EditorMenuButton : EditorButton; + this.button = new type({ ...options.button, action() { return false; diff --git a/resources/js/wysiwyg/ui/framework/blocks/menu-button.ts b/resources/js/wysiwyg/ui/framework/blocks/menu-button.ts new file mode 100644 index 000000000..6f6c8cf1b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/menu-button.ts @@ -0,0 +1,15 @@ +import {EditorButton} from "../buttons"; +import {el} from "../../../utils/dom"; +import arrowIcon from "@icons/chevron-right.svg" + +export class EditorMenuButton extends EditorButton { + protected buildDOM(): HTMLButtonElement { + const dom = super.buildDOM(); + + const icon = el('div', {class: 'editor-menu-button-icon'}); + icon.innerHTML = arrowIcon; + dom.append(icon); + + return dom; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/separator.ts b/resources/js/wysiwyg/ui/framework/blocks/separator.ts new file mode 100644 index 000000000..c0ef353e6 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/separator.ts @@ -0,0 +1,10 @@ +import {EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; + +export class EditorSeparator extends EditorUiElement { + buildDOM(): HTMLElement { + return el('div', { + class: 'editor-separator', + }); + } +} diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 87ecae03e..e7d486cd5 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -65,6 +65,7 @@ import { } from "./defaults/buttons/objects"; import {el} from "../utils/dom"; import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; +import {EditorSeparator} from "./framework/blocks/separator"; export function getMainEditorFullToolbar(): EditorContainerUiElement { return new EditorSimpleClassContainer('editor-toolbar-main', [ @@ -83,7 +84,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new FormatPreviewButton(el('h5'), h5), new FormatPreviewButton(el('blockquote'), blockquote), new FormatPreviewButton(el('p'), paragraph), - new EditorDropdownButton({button: {label: 'Callouts'}, showOnHover: true, direction: 'vertical'}, [ + new EditorDropdownButton({button: {label: 'Callouts', format: 'long'}, showOnHover: true, direction: 'vertical'}, [ new FormatPreviewButton(el('p', {class: 'callout info'}), infoCallout), new FormatPreviewButton(el('p', {class: 'callout success'}), successCallout), new FormatPreviewButton(el('p', {class: 'callout warning'}), warningCallout), @@ -125,37 +126,41 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Insert types - new EditorOverflowContainer(8, [ + new EditorOverflowContainer(4, [ new EditorButton(link), new EditorDropdownButton({button: table, direction: 'vertical'}, [ - new EditorDropdownButton({button: {...table, format: 'long'}, showOnHover: true}, [ + new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [ new EditorTableCreator(), ]), - new EditorDropdownButton({button: {label: 'Cell'}, direction: 'vertical', showOnHover: true}, [ + new EditorSeparator(), + new EditorDropdownButton({button: {label: 'Cell', format: 'long'}, direction: 'vertical', showOnHover: true}, [ new EditorButton(cellProperties), new EditorButton(mergeCells), new EditorButton(splitCell), ]), - new EditorDropdownButton({button: {label: 'Row'}, direction: 'vertical', showOnHover: true}, [ + new EditorDropdownButton({button: {label: 'Row', format: 'long'}, direction: 'vertical', showOnHover: true}, [ new EditorButton({...insertRowAbove, format: 'long'}), new EditorButton({...insertRowBelow, format: 'long'}), new EditorButton({...deleteRow, format: 'long'}), new EditorButton(rowProperties), + new EditorSeparator(), new EditorButton(cutRow), new EditorButton(copyRow), new EditorButton(pasteRowBefore), new EditorButton(pasteRowAfter), ]), - new EditorDropdownButton({button: {label: 'Column'}, direction: 'vertical', showOnHover: true}, [ + new EditorDropdownButton({button: {label: 'Column', format: 'long'}, direction: 'vertical', showOnHover: true}, [ new EditorButton({...insertColumnBefore, format: 'long'}), new EditorButton({...insertColumnAfter, format: 'long'}), new EditorButton({...deleteColumn, format: 'long'}), + new EditorSeparator(), new EditorButton(cutColumn), new EditorButton(copyColumn), new EditorButton(pasteColumnBefore), new EditorButton(pasteColumnAfter), ]), + new EditorSeparator(), new EditorButton({...tableProperties, format: 'long'}), new EditorButton(clearTableFormatting), new EditorButton(resizeTableToContents), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 04f18702e..61a9f2de0 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -70,12 +70,18 @@ body.editor-is-fullscreen { .editor-button-text { font-weight: 400; color: #000; - font-size: 12.2px; + font-size: 14px; + flex: 1; + padding-inline-end: 4px; } .editor-button-format-preview { padding: 4px 6px; display: block; } +.editor-button-long .editor-button-icon { + width: 24px; + height: 24px; +} .editor-button-icon svg { width: 24px; height: 24px; @@ -83,6 +89,13 @@ body.editor-is-fullscreen { fill: currentColor; display: block; } +.editor-menu-button-icon { + width: 24px; + height: 24px; + svg { + fill: #888; + } +} .editor-button-with-menu-container { display: flex; flex-direction: row; @@ -126,6 +139,7 @@ body.editor-is-fullscreen { display: flex; flex-direction: column; align-items: stretch; + min-width: 160px; } .editor-dropdown-menu-vertical .editor-button { border-bottom: 0; @@ -138,9 +152,17 @@ body.editor-is-fullscreen { top: 0; } +.editor-separator { + display: block; + height: 1px; + background-color: #DDD; + opacity: .8; +} + .editor-format-menu-toggle { width: 130px; height: 32px; + font-size: 13px; overflow: hidden; padding-inline: 12px; justify-content: start; @@ -154,6 +176,9 @@ body.editor-is-fullscreen { .editor-dropdown-menu { min-width: 220px; } + .editor-button-icon { + display: none; + } } .editor-format-menu .editor-dropdown-menu .editor-dropdown-menu-container > .editor-button { padding: 8px 10px; @@ -259,6 +284,9 @@ body.editor-is-fullscreen { width: 28px; height: 28px; cursor: pointer; + display: flex; + align-items: center; + justify-content: center; } .editor-color-select-option:hover { border-radius: 3px; @@ -266,6 +294,11 @@ body.editor-is-fullscreen { z-index: 3; box-shadow: 0 0 4px 1px rgba(0, 0, 0, 0.25); } +.editor-color-select-option[data-color=""] svg { + width: 20px; + height: 20px; + fill: #888; +} .editor-table-creator-row { display: flex; } @@ -422,7 +455,9 @@ body.editor-is-fullscreen { background-size: 100% 100%; } -// Editor form elements +/** + * Form elements + */ .editor-form-field-wrapper { margin-bottom: .5rem; } From ced66f167132ff8b556ad0df6204a6a8b09e2d77 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Sep 2024 18:33:54 +0100 Subject: [PATCH 089/107] Lexical: Added single node backspace/delete support --- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/nodes/code-block.ts | 2 +- .../js/wysiwyg/services/keyboard-handling.ts | 40 +++++++++++++++++++ .../js/wysiwyg/ui/decorators/code-block.ts | 10 +++-- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 resources/js/wysiwyg/services/keyboard-handling.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9015cea0c..c5dd151af 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -14,6 +14,7 @@ import {registerTableSelectionHandler} from "./ui/framework/helpers/table-select import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; +import {registerKeyboardHandling} from "./services/keyboard-handling"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -52,6 +53,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context), + registerKeyboardHandling(context), registerTableResizer(editor, editWrap), registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/nodes/code-block.ts index a71e21e2e..76c171971 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/nodes/code-block.ts @@ -33,7 +33,7 @@ export class CodeBlockNode extends DecoratorNode { } static clone(node: CodeBlockNode): CodeBlockNode { - const newNode = new CodeBlockNode(node.__language, node.__code); + const newNode = new CodeBlockNode(node.__language, node.__code, node.__key); newNode.__id = node.__id; return newNode; } diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts new file mode 100644 index 000000000..7e3323f86 --- /dev/null +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -0,0 +1,40 @@ +import {EditorUiContext} from "../ui/framework/core"; +import { + $isDecoratorNode, + COMMAND_PRIORITY_LOW, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + LexicalEditor +} from "lexical"; +import {$isImageNode} from "../nodes/image"; +import {$isMediaNode} from "../nodes/media"; +import {getLastSelection} from "../utils/selection"; + +function deleteSingleSelectedNode(editor: LexicalEditor) { + const selectionNodes = getLastSelection(editor)?.getNodes() || []; + if (selectionNodes.length === 1) { + const node = selectionNodes[0]; + if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) { + editor.update(() => { + node.remove(); + }); + } + } +} + +export function registerKeyboardHandling(context: EditorUiContext): () => void { + const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => { + deleteSingleSelectedNode(context.editor); + return false; + }, COMMAND_PRIORITY_LOW); + + const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => { + deleteSingleSelectedNode(context.editor); + return false; + }, COMMAND_PRIORITY_LOW); + + return () => { + unregisterBackspace(); + unregisterDelete(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index 650bd64c5..37d3df588 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,7 +1,7 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {BaseSelection} from "lexical"; +import {$isDecoratorNode, BaseSelection} from "lexical"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; @@ -34,9 +34,11 @@ export class CodeBlockDecorator extends EditorDecorator { const startTime = Date.now(); element.addEventListener('click', event => { - context.editor.update(() => { - $selectSingleNode(this.getNode()); - }) + requestAnimationFrame(() => { + context.editor.update(() => { + $selectSingleNode(this.getNode()); + }); + }); }); element.addEventListener('dblclick', event => { From 20364382034c4979dc05e207242baf2871bf6283 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Sep 2024 12:14:26 +0100 Subject: [PATCH 090/107] Lexical: Added single node enter handling Also updated media to be an inline element to align with old editor behaviour. --- resources/js/wysiwyg/nodes/media.ts | 9 +++- .../js/wysiwyg/services/keyboard-handling.ts | 52 ++++++++++++++++--- .../js/wysiwyg/ui/defaults/forms/objects.ts | 4 +- resources/js/wysiwyg/utils/nodes.ts | 23 +++++++- resources/js/wysiwyg/utils/selection.ts | 9 ++-- 5 files changed, 79 insertions(+), 18 deletions(-) diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index fb940f893..8658a1216 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -196,6 +196,10 @@ export class MediaNode extends ElementNode { return true; } + isParentRequired(): boolean { + return true; + } + createInnerDOM() { const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; const sourceEls = sources.map(source => el('source', source)); @@ -325,12 +329,13 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov']; const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm']; -const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx']; +const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', '']; export function $createMediaNodeFromSrc(src: string): MediaNode { let nodeTag: MediaNodeTag = 'iframe'; const srcEnd = src.split('?')[0].split('/').pop() || ''; - const extension = (srcEnd.split('.').pop() || '').toLowerCase(); + const srcEndSplit = srcEnd.split('.'); + const extension = (srcEndSplit.length > 1 ? srcEndSplit[srcEndSplit.length - 1] : '').toLowerCase(); if (videoExtensions.includes(extension)) { nodeTag = 'video'; } else if (audioExtensions.includes(extension)) { diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 7e3323f86..65a8e4254 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -4,22 +4,55 @@ import { COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, - LexicalEditor + KEY_ENTER_COMMAND, + LexicalEditor, + LexicalNode } from "lexical"; import {$isImageNode} from "../nodes/image"; import {$isMediaNode} from "../nodes/media"; import {getLastSelection} from "../utils/selection"; +import {$getNearestNodeBlockParent} from "../utils/nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; + +function isSingleSelectedNode(nodes: LexicalNode[]): boolean { + if (nodes.length === 1) { + const node = nodes[0]; + if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) { + return true; + } + } + + return false; +} function deleteSingleSelectedNode(editor: LexicalEditor) { const selectionNodes = getLastSelection(editor)?.getNodes() || []; - if (selectionNodes.length === 1) { + if (isSingleSelectedNode(selectionNodes)) { + editor.update(() => { + selectionNodes[0].remove(); + }); + } +} + +function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const selectionNodes = getLastSelection(editor)?.getNodes() || []; + if (isSingleSelectedNode(selectionNodes)) { const node = selectionNodes[0]; - if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) { - editor.update(() => { - node.remove(); + const nearestBlock = $getNearestNodeBlockParent(node) || node; + if (nearestBlock) { + requestAnimationFrame(() => { + editor.update(() => { + const newParagraph = $createCustomParagraphNode(); + nearestBlock.insertAfter(newParagraph); + newParagraph.select(); + }); }); + event?.preventDefault(); + return true; } } + + return false; } export function registerKeyboardHandling(context: EditorUiContext): () => void { @@ -33,8 +66,13 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { return false; }, COMMAND_PRIORITY_LOW); + const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { + return insertAfterSingleSelectedNode(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { - unregisterBackspace(); - unregisterDelete(); + unregisterBackspace(); + unregisterDelete(); + unregisterEnter(); }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 714d5f64b..f1575953b 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -197,7 +197,7 @@ export const media: EditorFormDefinition = { if (selectedNode && node) { selectedNode.replace(node) } else if (node) { - $insertNodeToNearestRoot(node); + $insertNodes([node]); } }); @@ -213,7 +213,7 @@ export const media: EditorFormDefinition = { updateNode.setSrc(src); updateNode.setWidthAndHeight(width, height); if (!selectedNode) { - $insertNodeToNearestRoot(updateNode); + $insertNodes([updateNode]); } }); diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index e33cfda7c..b8bb8de9a 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -1,9 +1,18 @@ -import {$getRoot, $isElementNode, $isTextNode, ElementNode, LexicalEditor, LexicalNode} from "lexical"; +import { + $getRoot, + $isDecoratorNode, + $isElementNode, + $isTextNode, + ElementNode, + LexicalEditor, + LexicalNode +} from "lexical"; import {LexicalNodeMatcher} from "../nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; import {NodeHasAlignment} from "../nodes/_common"; +import {$findMatchingParent} from "@lexical/utils"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { return nodes.map(node => { @@ -73,6 +82,18 @@ export function $getNearestBlockNodeForCoords(editor: LexicalEditor, x: number, return null; } +export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null { + const isBlockNode = (node: LexicalNode): boolean => { + return ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); + }; + + if (isBlockNode(node)) { + return node; + } + + return $findMatchingParent(node, isBlockNode); +} + export function nodeHasAlignment(node: object): node is NodeHasAlignment { return '__alignment' in node; } \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 4f565fa10..4aa21045f 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -16,7 +16,7 @@ import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexi import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; -import {$getParentOfType, nodeHasAlignment} from "./nodes"; +import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {CommonBlockAlignment} from "../nodes/_common"; @@ -155,11 +155,8 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection | null const blockNodes: Map = new Map(); for (const node of selection.getNodes()) { - const blockElement = $findMatchingParent(node, (node) => { - return $isElementNode(node) && !node.isInline(); - }) as ElementNode | null; - - if (blockElement) { + const blockElement = $getNearestNodeBlockParent(node); + if ($isElementNode(blockElement)) { blockNodes.set(blockElement.getKey(), blockElement); } } From 5083188ed82b9aa2e5df976f592d1baa67865c39 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Sep 2024 15:55:46 +0100 Subject: [PATCH 091/107] Lexical: Added block indenting capability Needed a custom implementation due to hardcoded defaults for Lexical default indenting. --- resources/js/wysiwyg/nodes/_common.ts | 29 +++++++++- resources/js/wysiwyg/nodes/callout.ts | 18 +++++- resources/js/wysiwyg/nodes/custom-heading.ts | 18 +++++- .../js/wysiwyg/nodes/custom-paragraph.ts | 18 +++++- resources/js/wysiwyg/nodes/custom-quote.ts | 18 +++++- resources/js/wysiwyg/nodes/custom-table.ts | 18 +++++- resources/js/wysiwyg/nodes/media.ts | 22 ++++++- resources/js/wysiwyg/todo.md | 1 + .../js/wysiwyg/ui/defaults/buttons/lists.ts | 58 ++++++++++++++++++- resources/js/wysiwyg/ui/toolbars.ts | 12 +++- resources/js/wysiwyg/utils/nodes.ts | 6 +- 11 files changed, 193 insertions(+), 25 deletions(-) diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts index ff957f953..8a0475c7b 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -1,5 +1,6 @@ import {LexicalNode, Spread} from "lexical"; import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; +import {sizeToPixels} from "../utils/dom"; export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; @@ -7,6 +8,7 @@ const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'jus export type SerializedCommonBlockNode = Spread<{ id: string; alignment: CommonBlockAlignment; + inset: number; }, SerializedElementNode> export interface NodeHasAlignment { @@ -21,7 +23,13 @@ export interface NodeHasId { getId(): string; } -interface CommonBlockInterface extends NodeHasId, NodeHasAlignment {} +export interface NodeHasInset { + readonly __inset: number; + setInset(inset: number): void; + getInset(): number; +} + +interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset {} export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment { const textAlignStyle: string = element.style.textAlign || ''; @@ -42,17 +50,24 @@ export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAl return ''; } +export function extractInsetFromElement(element: HTMLElement): number { + const elemPadding: string = element.style.paddingLeft || '0'; + return sizeToPixels(elemPadding); +} + export function setCommonBlockPropsFromElement(element: HTMLElement, node: CommonBlockInterface): void { if (element.id) { node.setId(element.id); } node.setAlignment(extractAlignmentFromElement(element)); + node.setInset(extractInsetFromElement(element)); } export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: CommonBlockInterface): boolean { return nodeA.__id !== nodeB.__id || - nodeA.__alignment !== nodeB.__alignment; + nodeA.__alignment !== nodeB.__alignment || + nodeA.__inset !== nodeB.__inset; } export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void { @@ -63,6 +78,16 @@ export function updateElementWithCommonBlockProps(element: HTMLElement, node: Co if (node.__alignment) { element.classList.add('align-' + node.__alignment); } + + if (node.__inset) { + element.style.paddingLeft = `${node.__inset}px`; + } +} + +export function deserializeCommonBlockNode(serializedNode: SerializedCommonBlockNode, node: CommonBlockInterface): void { + node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); + node.setInset(serializedNode.inset); } export interface NodeHasSize { diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index ededc0f29..cfe32ec85 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -10,7 +10,7 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import type {RangeSelection} from "lexical/LexicalSelection"; import { - CommonBlockAlignment, commonPropertiesDifferent, + CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps @@ -26,6 +26,7 @@ export class CalloutNode extends ElementNode { __id: string = ''; __category: CalloutCategory = 'info'; __alignment: CommonBlockAlignment = ''; + __inset: number = 0; static getType() { return 'callout'; @@ -35,6 +36,7 @@ export class CalloutNode extends ElementNode { const newNode = new CalloutNode(node.__category, node.__key); newNode.__id = node.__id; newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; return newNode; } @@ -73,6 +75,16 @@ export class CalloutNode extends ElementNode { return self.__alignment; } + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('p'); element.classList.add('callout', this.__category || ''); @@ -141,13 +153,13 @@ export class CalloutNode extends ElementNode { category: this.__category, id: this.__id, alignment: this.__alignment, + inset: this.__inset, }; } static importJSON(serializedNode: SerializedCalloutNode): CalloutNode { const node = $createCalloutNode(serializedNode.category); - node.setId(serializedNode.id); - node.setAlignment(serializedNode.alignment); + deserializeCommonBlockNode(serializedNode, node); return node; } diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts index 885622ad3..5df6245f5 100644 --- a/resources/js/wysiwyg/nodes/custom-heading.ts +++ b/resources/js/wysiwyg/nodes/custom-heading.ts @@ -7,7 +7,7 @@ import { import {EditorConfig} from "lexical/LexicalEditor"; import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text"; import { - CommonBlockAlignment, commonPropertiesDifferent, + CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps @@ -19,6 +19,7 @@ export type SerializedCustomHeadingNode = Spread = {}; __sources: MediaNodeSource[] = []; + __inset: number = 0; static getType() { return 'media'; @@ -91,6 +92,7 @@ export class MediaNode extends ElementNode { newNode.__sources = node.__sources.map(s => Object.assign({}, s)); newNode.__id = node.__id; newNode.__alignment = node.__alignment; + newNode.__inset = node.__inset; return newNode; } @@ -168,6 +170,16 @@ export class MediaNode extends ElementNode { return self.__alignment; } + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + setHeight(height: number): void { if (!height) { return; @@ -251,6 +263,10 @@ export class MediaNode extends ElementNode { } } + if (prevNode.__inset !== this.__inset) { + dom.style.paddingLeft = `${this.__inset}px`; + } + return false; } @@ -290,6 +306,7 @@ export class MediaNode extends ElementNode { version: 1, id: this.__id, alignment: this.__alignment, + inset: this.__inset, tag: this.__tag, attributes: this.__attributes, sources: this.__sources, @@ -298,8 +315,7 @@ export class MediaNode extends ElementNode { static importJSON(serializedNode: SerializedMediaNode): MediaNode { const node = $createMediaNode(serializedNode.tag); - node.setId(serializedNode.id); - node.setAlignment(serializedNode.alignment); + deserializeCommonBlockNode(serializedNode, node); return node; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 498d286fd..34367a36b 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,6 +6,7 @@ ## Main Todo +- Align list nesting with old editor - Mac: Shortcut support via command. ## Secondary Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index edec3ea00..0857fb70a 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -1,12 +1,24 @@ import {$isListNode, ListNode, ListType} from "@lexical/list"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {BaseSelection, LexicalNode} from "lexical"; +import { + BaseSelection, + LexicalEditor, + LexicalNode, +} from "lexical"; import listBulletIcon from "@icons/editor/list-bullet.svg"; import listNumberedIcon from "@icons/editor/list-numbered.svg"; import listCheckIcon from "@icons/editor/list-check.svg"; -import {$selectionContainsNodeType} from "../../../utils/selection"; +import indentIncreaseIcon from "@icons/editor/indent-increase.svg"; +import indentDecreaseIcon from "@icons/editor/indent-decrease.svg"; +import { + $getBlockElementNodesInSelection, + $selectionContainsNodeType, + $toggleSelection, + getLastSelection +} from "../../../utils/selection"; import {toggleSelectionAsList} from "../../../utils/formats"; +import {nodeHasInset} from "../../../utils/nodes"; function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { @@ -27,3 +39,45 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon); export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); + + +function setInsetForSelection(editor: LexicalEditor, change: number): void { + const selection = getLastSelection(editor); + + const elements = $getBlockElementNodesInSelection(selection); + for (const node of elements) { + if (nodeHasInset(node)) { + const currentInset = node.getInset(); + const newInset = Math.min(Math.max(currentInset + change, 0), 500); + node.setInset(newInset) + } + } + + $toggleSelection(editor); +} + +export const indentIncrease: EditorButtonDefinition = { + label: 'Increase indent', + icon: indentIncreaseIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + setInsetForSelection(context.editor, 40); + }); + }, + isActive() { + return false; + } +}; + +export const indentDecrease: EditorButtonDefinition = { + label: 'Decrease indent', + icon: indentDecreaseIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + setInsetForSelection(context.editor, -40); + }); + }, + isActive() { + return false; + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index e7d486cd5..0ad638410 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -52,7 +52,13 @@ import { underline } from "./defaults/buttons/inline-formats"; import {alignCenter, alignJustify, alignLeft, alignRight} from "./defaults/buttons/alignments"; -import {bulletList, numberList, taskList} from "./defaults/buttons/lists"; +import { + bulletList, + indentDecrease, + indentIncrease, + numberList, + taskList +} from "./defaults/buttons/lists"; import { codeBlock, details, @@ -119,10 +125,12 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Lists - new EditorOverflowContainer(3, [ + new EditorOverflowContainer(5, [ new EditorButton(bulletList), new EditorButton(numberList), new EditorButton(taskList), + new EditorButton(indentDecrease), + new EditorButton(indentIncrease), ]), // Insert types diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index b8bb8de9a..48fbe043f 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -11,7 +11,7 @@ import {LexicalNodeMatcher} from "../nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; -import {NodeHasAlignment} from "../nodes/_common"; +import {NodeHasAlignment, NodeHasInset} from "../nodes/_common"; import {$findMatchingParent} from "@lexical/utils"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { @@ -96,4 +96,8 @@ export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null export function nodeHasAlignment(node: object): node is NodeHasAlignment { return '__alignment' in node; +} + +export function nodeHasInset(node: object): node is NodeHasInset { + return '__inset' in node; } \ No newline at end of file From 662110c269218807379546cc19c2292f5e3765de Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Sep 2024 15:50:42 +0100 Subject: [PATCH 092/107] Lexical: Custom list nesting support Added list nesting support to allow li > ul style nesting which lexical didn't do by default. Adds tab handling for inset/outset controls. Will be a range of edge-case bugs to squash during testing. --- .../js/wysiwyg/nodes/custom-list-item.ts | 25 ++++ resources/js/wysiwyg/nodes/custom-list.ts | 38 +++++- .../js/wysiwyg/services/keyboard-handling.ts | 21 ++- resources/js/wysiwyg/todo.md | 5 +- .../js/wysiwyg/ui/defaults/buttons/lists.ts | 24 +--- resources/js/wysiwyg/utils/lists.ts | 123 ++++++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 53 +++++++- 7 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 resources/js/wysiwyg/utils/lists.ts diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts index 2b4d74146..659a55a15 100644 --- a/resources/js/wysiwyg/nodes/custom-list-item.ts +++ b/resources/js/wysiwyg/nodes/custom-list-item.ts @@ -3,6 +3,7 @@ import {EditorConfig} from "lexical/LexicalEditor"; import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; import {el} from "../utils/dom"; +import {$isCustomListNode} from "./custom-list"; function updateListItemChecked( dom: HTMLElement, @@ -38,6 +39,10 @@ export class CustomListItemNode extends ListItemNode { element.value = this.__value; + if ($hasNestedListWithoutLabel(this)) { + element.style.listStyle = 'none'; + } + return element; } @@ -86,8 +91,28 @@ export class CustomListItemNode extends ListItemNode { } } +function $hasNestedListWithoutLabel(node: CustomListItemNode): boolean { + const children = node.getChildren(); + let hasLabel = false; + let hasNestedList = false; + + for (const child of children) { + if ($isCustomListNode(child)) { + hasNestedList = true; + } else if (child.getTextContent().trim().length > 0) { + hasLabel = true; + } + } + + return hasNestedList && !hasLabel; +} + export function $isCustomListItemNode( node: LexicalNode | null | undefined, ): node is CustomListItemNode { return node instanceof CustomListItemNode; +} + +export function $createCustomListItemNode(): CustomListItemNode { + return new CustomListItemNode(); } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-list.ts b/resources/js/wysiwyg/nodes/custom-list.ts index 953bcb8cd..a6c473999 100644 --- a/resources/js/wysiwyg/nodes/custom-list.ts +++ b/resources/js/wysiwyg/nodes/custom-list.ts @@ -5,7 +5,8 @@ import { Spread } from "lexical"; import {EditorConfig} from "lexical/LexicalEditor"; -import {ListNode, ListType, SerializedListNode} from "@lexical/list"; +import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list"; +import {$createCustomListItemNode} from "./custom-list-item"; export type SerializedCustomListNode = Spread<{ @@ -30,7 +31,7 @@ export class CustomListNode extends ListNode { } static clone(node: CustomListNode) { - const newNode = new CustomListNode(node.__listType, 0, node.__key); + const newNode = new CustomListNode(node.__listType, node.__start, node.__key); newNode.__id = node.__id; return newNode; } @@ -67,6 +68,11 @@ export class CustomListNode extends ListNode { if (element.id && baseResult?.node) { (baseResult.node as CustomListNode).setId(element.id); } + + if (baseResult) { + baseResult.after = $normalizeChildren; + } + return baseResult; }; @@ -83,8 +89,34 @@ export class CustomListNode extends ListNode { } } +/* + * This function is a custom normalization function to allow nested lists within list item elements. + * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303 + * With modifications made. + * Copyright (c) Meta Platforms, Inc. and affiliates. + * MIT license + */ +function $normalizeChildren(nodes: Array): Array { + const normalizedListItems: Array = []; + + for (const node of nodes) { + if ($isListItemNode(node)) { + normalizedListItems.push(node); + } else { + normalizedListItems.push($wrapInListItem(node)); + } + } + + return normalizedListItems; +} + +function $wrapInListItem(node: LexicalNode): ListItemNode { + const listItemWrapper = $createCustomListItemNode(); + return listItemWrapper.append(node); +} + export function $createCustomListNode(type: ListType): CustomListNode { - return new CustomListNode(type, 0); + return new CustomListNode(type, 1); } export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode { diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 65a8e4254..791fb0bed 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -1,10 +1,11 @@ import {EditorUiContext} from "../ui/framework/core"; import { + $getSelection, $isDecoratorNode, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, - KEY_ENTER_COMMAND, + KEY_ENTER_COMMAND, KEY_TAB_COMMAND, LexicalEditor, LexicalNode } from "lexical"; @@ -13,6 +14,8 @@ import {$isMediaNode} from "../nodes/media"; import {getLastSelection} from "../utils/selection"; import {$getNearestNodeBlockParent} from "../utils/nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$isCustomListItemNode} from "../nodes/custom-list-item"; +import {$setInsetForSelection} from "../utils/lists"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -55,6 +58,17 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null) { + const change = event?.shiftKey ? -40 : 40; + editor.update(() => { + const selection = $getSelection(); + const nodes = selection?.getNodes() || []; + if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) { + $setInsetForSelection(editor, change); + } + }); +} + export function registerKeyboardHandling(context: EditorUiContext): () => void { const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => { deleteSingleSelectedNode(context.editor); @@ -70,9 +84,14 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { return insertAfterSingleSelectedNode(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { + return handleInsetOnTab(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { unregisterBackspace(); unregisterDelete(); unregisterEnter(); + unregisterTab(); }; } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 34367a36b..2662350af 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,8 +6,8 @@ ## Main Todo -- Align list nesting with old editor - Mac: Shortcut support via command. +- RTL/LTR support ## Secondary Todo @@ -18,4 +18,5 @@ ## Bugs -// \ No newline at end of file +- Focus/click area reduced to content area, single line on initial access +- List selection can get lost on nesting/unnesting \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index 0857fb70a..87630eb27 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -13,12 +13,14 @@ import indentIncreaseIcon from "@icons/editor/indent-increase.svg"; import indentDecreaseIcon from "@icons/editor/indent-decrease.svg"; import { $getBlockElementNodesInSelection, - $selectionContainsNodeType, + $selectionContainsNodeType, $selectNodes, $selectSingleNode, $toggleSelection, getLastSelection } from "../../../utils/selection"; import {toggleSelectionAsList} from "../../../utils/formats"; import {nodeHasInset} from "../../../utils/nodes"; +import {$isCustomListItemNode, CustomListItemNode} from "../../../nodes/custom-list-item"; +import {$nestListItem, $setInsetForSelection, $unnestListItem} from "../../../utils/lists"; function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { @@ -40,28 +42,12 @@ export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon); export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon); - -function setInsetForSelection(editor: LexicalEditor, change: number): void { - const selection = getLastSelection(editor); - - const elements = $getBlockElementNodesInSelection(selection); - for (const node of elements) { - if (nodeHasInset(node)) { - const currentInset = node.getInset(); - const newInset = Math.min(Math.max(currentInset + change, 0), 500); - node.setInset(newInset) - } - } - - $toggleSelection(editor); -} - export const indentIncrease: EditorButtonDefinition = { label: 'Increase indent', icon: indentIncreaseIcon, action(context: EditorUiContext) { context.editor.update(() => { - setInsetForSelection(context.editor, 40); + $setInsetForSelection(context.editor, 40); }); }, isActive() { @@ -74,7 +60,7 @@ export const indentDecrease: EditorButtonDefinition = { icon: indentDecreaseIcon, action(context: EditorUiContext) { context.editor.update(() => { - setInsetForSelection(context.editor, -40); + $setInsetForSelection(context.editor, -40); }); }, isActive() { diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts new file mode 100644 index 000000000..edde994e5 --- /dev/null +++ b/resources/js/wysiwyg/utils/lists.ts @@ -0,0 +1,123 @@ +import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item"; +import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list"; +import {BaseSelection, LexicalEditor} from "lexical"; +import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection, getLastSelection} from "./selection"; +import {nodeHasInset} from "./nodes"; + + +export function $nestListItem(node: CustomListItemNode) { + const list = node.getParent(); + if (!$isCustomListNode(list)) { + return; + } + + const listItems = list.getChildren() as CustomListItemNode[]; + const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); + const isFirst = nodeIndex === 0; + + const newListItem = $createCustomListItemNode(); + const newList = $createCustomListNode(list.getListType()); + newList.append(newListItem); + newListItem.append(...node.getChildren()); + + if (isFirst) { + node.append(newList); + } else { + const prevListItem = listItems[nodeIndex - 1]; + prevListItem.append(newList); + node.remove(); + } +} + +export function $unnestListItem(node: CustomListItemNode) { + const list = node.getParent(); + const parentListItem = list?.getParent(); + const outerList = parentListItem?.getParent(); + if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) { + return; + } + + parentListItem.insertAfter(node); + if (list.getChildren().length === 0) { + list.remove(); + } + + if (parentListItem.getChildren().length === 0) { + parentListItem.remove(); + } +} + +function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] { + const nodes = selection?.getNodes() || []; + const listItemNodes = []; + + outer: for (const node of nodes) { + if ($isCustomListItemNode(node)) { + listItemNodes.push(node); + continue; + } + + const parents = node.getParents(); + for (const parent of parents) { + if ($isCustomListItemNode(parent)) { + listItemNodes.push(parent); + continue outer; + } + } + + listItemNodes.push(null); + } + + return listItemNodes; +} + +function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] { + const listItemMap: Record = {}; + + for (const item of listItems) { + if (item === null) { + continue; + } + + const key = item.getKey(); + if (typeof listItemMap[key] === 'undefined') { + listItemMap[key] = item; + } + } + + return Object.values(listItemMap); +} + +export function $setInsetForSelection(editor: LexicalEditor, change: number): void { + const selection = getLastSelection(editor); + + const listItemsInSelection = getListItemsForSelection(selection); + const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); + + if (isListSelection) { + const listItems = $reduceDedupeListItems(listItemsInSelection); + if (change > 0) { + for (const listItem of listItems) { + $nestListItem(listItem); + } + } else if (change < 0) { + for (const listItem of [...listItems].reverse()) { + $unnestListItem(listItem); + } + } + + $selectNodes(listItems); + return; + } + + const elements = $getBlockElementNodesInSelection(selection); + for (const node of elements) { + if (nodeHasInset(node)) { + const currentInset = node.getInset(); + const newInset = Math.min(Math.max(currentInset + change, 0), 500); + node.setInset(newInset) + } + } + + $toggleSelection(editor); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 4aa21045f..2110ea4be 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -10,7 +10,7 @@ import { ElementFormatType, ElementNode, LexicalEditor, LexicalNode, - TextFormatType + TextFormatType, TextNode } from "lexical"; import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; @@ -106,6 +106,57 @@ export function $selectSingleNode(node: LexicalNode) { $setSelection(nodeSelection); } +function getFirstTextNodeInNodes(nodes: LexicalNode[]): TextNode|null { + for (const node of nodes) { + if ($isTextNode(node)) { + return node; + } + + if ($isElementNode(node)) { + const children = node.getChildren(); + const textNode = getFirstTextNodeInNodes(children); + if (textNode !== null) { + return textNode; + } + } + } + + return null; +} + +function getLastTextNodeInNodes(nodes: LexicalNode[]): TextNode|null { + const revNodes = [...nodes].reverse(); + for (const node of revNodes) { + if ($isTextNode(node)) { + return node; + } + + if ($isElementNode(node)) { + const children = [...node.getChildren()].reverse(); + const textNode = getLastTextNodeInNodes(children); + if (textNode !== null) { + return textNode; + } + } + } + + return null; +} + +export function $selectNodes(nodes: LexicalNode[]) { + if (nodes.length === 0) { + return; + } + + const selection = $createRangeSelection(); + const firstText = getFirstTextNodeInNodes(nodes); + const lastText = getLastTextNodeInNodes(nodes); + if (firstText && lastText) { + selection.setTextNodeRange(firstText, 0, lastText, lastText.getTextContentSize() || 0) + $setSelection(selection); + } +} + export function $toggleSelection(editor: LexicalEditor) { const lastSelection = getLastSelection(editor); From 6872eb802c3c7bf9fed1e21eb7dc691a5e09af98 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Sep 2024 16:05:55 +0100 Subject: [PATCH 093/107] Lexical: Altered keyboard handling to indicant handled state --- .../js/wysiwyg/services/keyboard-handling.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 791fb0bed..2c7bfdbba 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -58,15 +58,19 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } -function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null) { +function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const change = event?.shiftKey ? -40 : 40; - editor.update(() => { - const selection = $getSelection(); - const nodes = selection?.getNodes() || []; - if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) { + const selection = $getSelection(); + const nodes = selection?.getNodes() || []; + if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) { + editor.update(() => { $setInsetForSelection(editor, change); - } - }); + }); + event?.preventDefault(); + return true; + } + + return false; } export function registerKeyboardHandling(context: EditorUiContext): () => void { From 5f46d71af0af216e413af8771f227f2a73deb2ec Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Sep 2024 16:10:46 +0100 Subject: [PATCH 094/107] Lexical: Fixed a range of issues in RTL mode --- resources/js/components/wysiwyg-editor.js | 2 ++ resources/js/wysiwyg/index.ts | 5 +++++ resources/js/wysiwyg/todo.md | 4 ++-- resources/sass/_editor.scss | 9 ++++++++- resources/sass/_pages.scss | 2 -- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index ebc142e2a..eed1c6155 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -15,6 +15,8 @@ export class WysiwygEditor extends Component { this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent, { drawioUrl: this.getDrawIoUrl(), pageId: Number(this.$opts.pageId), + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.$opts.textDirection, translations: { imageUploadErrorText: this.$opts.imageUploadErrorText, serverUploadLimitText: this.$opts.serverUploadLimitText, diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index c5dd151af..c4403773b 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -42,8 +42,13 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st const editWrap = el('div', { class: 'editor-content-wrap', }, [editArea]); + container.append(editWrap); container.classList.add('editor-container'); + container.setAttribute('dir', options.textDirection); + if (options.darkMode) { + container.classList.add('editor-dark'); + } const editor = createEditor(config); editor.setRootElement(editArea); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 2662350af..874ac537f 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,12 +2,12 @@ ## In progress -// +- RTL/LTR support ## Main Todo - Mac: Shortcut support via command. -- RTL/LTR support +- Translations ## Secondary Todo diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 61a9f2de0..dd1e1a2c3 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -96,6 +96,9 @@ body.editor-is-fullscreen { fill: #888; } } +.editor-container[dir="rtl"] .editor-menu-button-icon { + rotate: 180deg; +} .editor-button-with-menu-container { display: flex; flex-direction: row; @@ -171,6 +174,9 @@ body.editor-is-fullscreen { background-position: 98% 50%; background-size: 28px; } +.editor-container[dir="rtl"] .editor-format-menu-toggle { + background-position: 2% 50%; +} .editor-format-menu .editor-dropdown-menu { min-width: 300px; .editor-dropdown-menu { @@ -324,9 +330,10 @@ body.editor-is-fullscreen { .editor-node-resizer { position: absolute; left: 0; - right: 0; + right: auto; display: inline-block; outline: 2px dashed var(--editor-color-primary); + direction: ltr; } .editor-node-resizer-handle { position: absolute; diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index ca59c85ca..6e6f7bb7e 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -2,13 +2,11 @@ display: flex; flex-direction: column; align-items: stretch; - overflow: hidden; .edit-area { flex: 1; flex-direction: column; z-index: 10; - overflow: hidden; border-radius: 0 0 8px 8px; } From 03490d6597dae4b50019b3087c517a783afaff81 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Sep 2024 12:29:46 +0100 Subject: [PATCH 095/107] Lexical: Added RTL/LTR actions Kinda useless though due to Lexical reconciler :( --- resources/icons/editor/direction-ltr.svg | 1 + resources/icons/editor/direction-rtl.svg | 1 + .../wysiwyg/ui/defaults/buttons/alignments.ts | 46 ++++++++++++++++--- resources/js/wysiwyg/ui/toolbars.ts | 13 +++++- resources/js/wysiwyg/utils/selection.ts | 18 +++++++- 5 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 resources/icons/editor/direction-ltr.svg create mode 100644 resources/icons/editor/direction-rtl.svg diff --git a/resources/icons/editor/direction-ltr.svg b/resources/icons/editor/direction-ltr.svg new file mode 100644 index 000000000..16befc75c --- /dev/null +++ b/resources/icons/editor/direction-ltr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/editor/direction-rtl.svg b/resources/icons/editor/direction-rtl.svg new file mode 100644 index 000000000..5125472a0 --- /dev/null +++ b/resources/icons/editor/direction-rtl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 329b11956..130fd6b72 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -5,15 +5,17 @@ import {EditorUiContext} from "../../framework/core"; import alignCenterIcon from "@icons/editor/align-center.svg"; import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; +import ltrIcon from "@icons/editor/direction-ltr.svg"; +import rtlIcon from "@icons/editor/direction-rtl.svg"; import { $getBlockElementNodesInSelection, - $selectionContainsAlignment, $selectSingleNode, $toggleSelection, getLastSelection + $selectionContainsAlignment, $selectionContainsDirection, $selectSingleNode, $toggleSelection, getLastSelection } from "../../../utils/selection"; import {CommonBlockAlignment} from "../../../nodes/_common"; import {nodeHasAlignment} from "../../../utils/nodes"; -function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { +function setAlignmentForSelection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { const selection = getLastSelection(editor); const selectionNodes = selection?.getNodes() || []; @@ -35,11 +37,21 @@ function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAli $toggleSelection(editor); } +function setDirectionForSelection(editor: LexicalEditor, direction: 'ltr' | 'rtl'): void { + const selection = getLastSelection(editor); + + const elements = $getBlockElementNodesInSelection(selection); + for (const node of elements) { + console.log('setting direction', node); + node.setDirection(direction); + } +} + export const alignLeft: EditorButtonDefinition = { label: 'Align left', icon: alignLeftIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection(context.editor, 'left')); + context.editor.update(() => setAlignmentForSelection(context.editor, 'left')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'left'); @@ -50,7 +62,7 @@ export const alignCenter: EditorButtonDefinition = { label: 'Align center', icon: alignCenterIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection(context.editor, 'center')); + context.editor.update(() => setAlignmentForSelection(context.editor, 'center')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'center'); @@ -61,7 +73,7 @@ export const alignRight: EditorButtonDefinition = { label: 'Align right', icon: alignRightIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection(context.editor, 'right')); + context.editor.update(() => setAlignmentForSelection(context.editor, 'right')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'right'); @@ -72,9 +84,31 @@ export const alignJustify: EditorButtonDefinition = { label: 'Align justify', icon: alignJustifyIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection(context.editor, 'justify')); + context.editor.update(() => setAlignmentForSelection(context.editor, 'justify')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'justify'); } }; + +export const directionLTR: EditorButtonDefinition = { + label: 'Left to right', + icon: ltrIcon, + action(context: EditorUiContext) { + context.editor.update(() => setDirectionForSelection(context.editor, 'ltr')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsDirection(selection, 'ltr'); + } +}; + +export const directionRTL: EditorButtonDefinition = { + label: 'Right to left', + icon: rtlIcon, + action(context: EditorUiContext) { + context.editor.update(() => setDirectionForSelection(context.editor, 'rtl')); + }, + isActive(selection: BaseSelection|null) { + return $selectionContainsDirection(selection, 'rtl'); + } +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 0ad638410..b064a2a9f 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -51,7 +51,14 @@ import { textColor, underline } from "./defaults/buttons/inline-formats"; -import {alignCenter, alignJustify, alignLeft, alignRight} from "./defaults/buttons/alignments"; +import { + alignCenter, + alignJustify, + alignLeft, + alignRight, + directionLTR, + directionRTL +} from "./defaults/buttons/alignments"; import { bulletList, indentDecrease, @@ -117,11 +124,13 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Alignment - new EditorOverflowContainer(4, [ + new EditorOverflowContainer(6, [ // TODO - Dynamic new EditorButton(alignLeft), new EditorButton(alignCenter), new EditorButton(alignRight), new EditorButton(alignJustify), + new EditorButton(directionLTR), // TODO - Dynamic + new EditorButton(directionRTL), // TODO - Dynamic ]), // Lists diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 2110ea4be..f1055d98a 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -2,7 +2,7 @@ import { $createNodeSelection, $createParagraphNode, $createRangeSelection, $getRoot, - $getSelection, $isDecoratorNode, + $getSelection, $isBlockElementNode, $isDecoratorNode, $isElementNode, $isTextNode, $setSelection, @@ -199,6 +199,22 @@ export function $selectionContainsAlignment(selection: BaseSelection | null, ali return false; } +export function $selectionContainsDirection(selection: BaseSelection | null, direction: 'rtl'|'ltr'): boolean { + + const nodes = [ + ...(selection?.getNodes() || []), + ...$getBlockElementNodesInSelection(selection) + ]; + + for (const node of nodes) { + if ($isBlockElementNode(node) && node.getDirection() === direction) { + return true; + } + } + + return false; +} + export function $getBlockElementNodesInSelection(selection: BaseSelection | null): ElementNode[] { if (!selection) { return []; From 22d078b47f5bd024da98432c7db745d953291712 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Sep 2024 13:43:39 +0100 Subject: [PATCH 096/107] Lexical: Imported core lexical libs Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies. --- .gitignore | 1 + dev/build/esbuild.js | 2 + jest.config.ts | 207 + package-lock.json | 3822 ++++++++++++++++- package.json | 18 +- resources/js/app.js | 3 + resources/js/global.d.ts | 8 +- .../wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE | 21 + .../js/wysiwyg/lexical/clipboard/clipboard.ts | 542 +++ .../js/wysiwyg/lexical/clipboard/index.ts | 21 + .../wysiwyg/lexical/core/LexicalCommands.ts | 125 + .../wysiwyg/lexical/core/LexicalConstants.ts | 145 + .../js/wysiwyg/lexical/core/LexicalEditor.ts | 1289 ++++++ .../lexical/core/LexicalEditorState.ts | 137 + .../js/wysiwyg/lexical/core/LexicalEvents.ts | 1385 ++++++ .../js/wysiwyg/lexical/core/LexicalGC.ts | 125 + .../wysiwyg/lexical/core/LexicalMutations.ts | 322 ++ .../js/wysiwyg/lexical/core/LexicalNode.ts | 1221 ++++++ .../lexical/core/LexicalNormalization.ts | 124 + .../wysiwyg/lexical/core/LexicalReconciler.ts | 943 ++++ .../wysiwyg/lexical/core/LexicalSelection.ts | 2835 ++++++++++++ .../js/wysiwyg/lexical/core/LexicalUpdates.ts | 1035 +++++ .../js/wysiwyg/lexical/core/LexicalUtils.ts | 1788 ++++++++ .../core/__tests__/unit/CodeBlock.test.ts | 144 + .../__tests__/unit/HTMLCopyAndPaste.test.ts | 125 + .../core/__tests__/unit/LexicalEditor.test.ts | 2856 ++++++++++++ .../__tests__/unit/LexicalEditorState.test.ts | 159 + .../__tests__/unit/LexicalListPlugin.test.tsx | 212 + .../core/__tests__/unit/LexicalNode.test.ts | 1517 +++++++ .../unit/LexicalNormalization.test.tsx | 176 + .../__tests__/unit/LexicalSelection.test.ts | 342 ++ .../unit/LexicalSerialization.test.ts | 126 + .../core/__tests__/unit/LexicalUtils.test.ts | 293 ++ .../lexical/core/__tests__/utils/index.ts | 751 ++++ resources/js/wysiwyg/lexical/core/index.ts | 208 + .../lexical/core/nodes/ArtificialNode.ts | 23 + .../core/nodes/LexicalDecoratorNode.ts | 56 + .../lexical/core/nodes/LexicalElementNode.ts | 635 +++ .../core/nodes/LexicalLineBreakNode.ts | 142 + .../core/nodes/LexicalParagraphNode.ts | 236 + .../lexical/core/nodes/LexicalRootNode.ts | 132 + .../lexical/core/nodes/LexicalTabNode.ts | 94 + .../lexical/core/nodes/LexicalTextNode.ts | 1364 ++++++ .../unit/LexicalElementNode.test.tsx | 635 +++ .../nodes/__tests__/unit/LexicalGC.test.tsx | 119 + .../unit/LexicalLineBreakNode.test.ts | 74 + .../unit/LexicalParagraphNode.test.ts | 153 + .../__tests__/unit/LexicalRootNode.test.ts | 271 ++ .../__tests__/unit/LexicalTabNode.test.tsx | 257 ++ .../__tests__/unit/LexicalTextNode.test.tsx | 843 ++++ .../core/shared/__mocks__/invariant.ts | 24 + .../wysiwyg/lexical/core/shared/canUseDOM.ts | 12 + .../lexical/core/shared/caretFromPoint.ts | 40 + .../lexical/core/shared/environment.ts | 56 + .../wysiwyg/lexical/core/shared/invariant.ts | 26 + .../core/shared/normalizeClassNames.ts | 21 + .../lexical/core/shared/react-test-utils.ts | 18 + .../lexical/core/shared/reactPatches.ts | 22 + .../core/shared/simpleDiffWithCursor.ts | 49 + .../lexical/core/shared/useLayoutEffect.ts | 19 + .../lexical/core/shared/warnOnlyOnce.ts | 20 + .../unit/LexicalHeadlessEditor.test.ts | 212 + .../js/wysiwyg/lexical/headless/index.ts | 43 + resources/js/wysiwyg/lexical/history/index.ts | 501 +++ .../html/__tests__/unit/LexicalHtml.test.ts | 212 + resources/js/wysiwyg/lexical/html/index.ts | 376 ++ .../unit/LexicalAutoLinkNode.test.ts | 506 +++ .../__tests__/unit/LexicalLinkNode.test.ts | 413 ++ resources/js/wysiwyg/lexical/link/index.ts | 610 +++ .../lexical/list/LexicalListItemNode.ts | 552 +++ .../wysiwyg/lexical/list/LexicalListNode.ts | 367 ++ .../unit/LexicalListItemNode.test.ts | 1365 ++++++ .../__tests__/unit/LexicalListNode.test.ts | 317 ++ .../lexical/list/__tests__/unit/utils.test.ts | 335 ++ .../wysiwyg/lexical/list/__tests__/utils.ts | 33 + .../js/wysiwyg/lexical/list/formatList.ts | 530 +++ resources/js/wysiwyg/lexical/list/index.ts | 50 + resources/js/wysiwyg/lexical/list/utils.ts | 205 + resources/js/wysiwyg/lexical/readme.md | 12 + .../__tests__/unit/LexicalHeadingNode.test.ts | 202 + .../__tests__/unit/LexicalQuoteNode.test.ts | 97 + .../js/wysiwyg/lexical/rich-text/index.ts | 1067 +++++ .../__tests__/unit/LexicalSelection.test.tsx | 3082 +++++++++++++ .../unit/LexicalSelectionHelpers.test.ts | 3173 ++++++++++++++ .../selection/__tests__/utils/index.ts | 918 ++++ .../js/wysiwyg/lexical/selection/constants.ts | 8 + .../js/wysiwyg/lexical/selection/index.ts | 56 + .../wysiwyg/lexical/selection/lexical-node.ts | 427 ++ .../lexical/selection/range-selection.ts | 608 +++ .../js/wysiwyg/lexical/selection/utils.ts | 228 + .../lexical/table/LexicalTableCellNode.ts | 374 ++ .../lexical/table/LexicalTableCommands.ts | 27 + .../wysiwyg/lexical/table/LexicalTableNode.ts | 258 ++ .../lexical/table/LexicalTableObserver.ts | 414 ++ .../lexical/table/LexicalTableRowNode.ts | 130 + .../lexical/table/LexicalTableSelection.ts | 373 ++ .../table/LexicalTableSelectionHelpers.ts | 1819 ++++++++ .../lexical/table/LexicalTableUtils.ts | 894 ++++ .../unit/LexicalTableCellNode.test.ts | 70 + .../__tests__/unit/LexicalTableNode.test.tsx | 351 ++ .../unit/LexicalTableRowNode.test.ts | 50 + .../unit/LexicalTableSelection.test.tsx | 176 + .../js/wysiwyg/lexical/table/constants.ts | 13 + resources/js/wysiwyg/lexical/table/index.ts | 74 + .../unit/LexicalElementHelpers.test.ts | 77 + .../unit/LexicalEventHelpers.test.tsx | 747 ++++ .../__tests__/unit/LexicalNodeHelpers.test.ts | 236 + .../__tests__/unit/LexicalRootHelpers.test.ts | 63 + .../unit/LexicalUtilsKlassEqual.test.ts | 36 + .../unit/LexicalUtilsSplitNode.test.tsx | 142 + ...xlcaiUtilsInsertNodeToNearestRoot.test.tsx | 184 + .../__tests__/unit/mergeRegister.test.ts | 21 + resources/js/wysiwyg/lexical/utils/index.ts | 607 +++ .../js/wysiwyg/lexical/utils/markSelection.ts | 170 + .../js/wysiwyg/lexical/utils/mergeRegister.ts | 44 + .../lexical/utils/positionNodeOnRange.ts | 141 + resources/js/wysiwyg/lexical/utils/px.ts | 11 + resources/js/wysiwyg/lexical/yjs/Bindings.ts | 78 + .../lexical/yjs/CollabDecoratorNode.ts | 110 + .../wysiwyg/lexical/yjs/CollabElementNode.ts | 666 +++ .../lexical/yjs/CollabLineBreakNode.ts | 68 + .../js/wysiwyg/lexical/yjs/CollabTextNode.ts | 178 + .../js/wysiwyg/lexical/yjs/SyncCursors.ts | 536 +++ .../wysiwyg/lexical/yjs/SyncEditorStates.ts | 247 ++ resources/js/wysiwyg/lexical/yjs/Utils.ts | 560 +++ resources/js/wysiwyg/lexical/yjs/index.ts | 116 + resources/js/wysiwyg/lexical/yjs/types.ts | 27 + tsconfig.json | 121 +- 128 files changed, 54875 insertions(+), 208 deletions(-) create mode 100644 jest.config.ts create mode 100644 resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE create mode 100644 resources/js/wysiwyg/lexical/clipboard/clipboard.ts create mode 100644 resources/js/wysiwyg/lexical/clipboard/index.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalCommands.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalConstants.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalEditor.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalEditorState.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalEvents.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalGC.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalMutations.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalNormalization.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalReconciler.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalSelection.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalUpdates.ts create mode 100644 resources/js/wysiwyg/lexical/core/LexicalUtils.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts create mode 100644 resources/js/wysiwyg/lexical/core/index.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx create mode 100644 resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx create mode 100644 resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/environment.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/invariant.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/reactPatches.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts create mode 100644 resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts create mode 100644 resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts create mode 100644 resources/js/wysiwyg/lexical/headless/index.ts create mode 100644 resources/js/wysiwyg/lexical/history/index.ts create mode 100644 resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts create mode 100644 resources/js/wysiwyg/lexical/html/index.ts create mode 100644 resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/link/index.ts create mode 100644 resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts create mode 100644 resources/js/wysiwyg/lexical/list/LexicalListNode.ts create mode 100644 resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts create mode 100644 resources/js/wysiwyg/lexical/list/__tests__/utils.ts create mode 100644 resources/js/wysiwyg/lexical/list/formatList.ts create mode 100644 resources/js/wysiwyg/lexical/list/index.ts create mode 100644 resources/js/wysiwyg/lexical/list/utils.ts create mode 100644 resources/js/wysiwyg/lexical/readme.md create mode 100644 resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/rich-text/index.ts create mode 100644 resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx create mode 100644 resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts create mode 100644 resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts create mode 100644 resources/js/wysiwyg/lexical/selection/constants.ts create mode 100644 resources/js/wysiwyg/lexical/selection/index.ts create mode 100644 resources/js/wysiwyg/lexical/selection/lexical-node.ts create mode 100644 resources/js/wysiwyg/lexical/selection/range-selection.ts create mode 100644 resources/js/wysiwyg/lexical/selection/utils.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableNode.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts create mode 100644 resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts create mode 100644 resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx create mode 100644 resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts create mode 100644 resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx create mode 100644 resources/js/wysiwyg/lexical/table/constants.ts create mode 100644 resources/js/wysiwyg/lexical/table/index.ts create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx create mode 100644 resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts create mode 100644 resources/js/wysiwyg/lexical/utils/index.ts create mode 100644 resources/js/wysiwyg/lexical/utils/markSelection.ts create mode 100644 resources/js/wysiwyg/lexical/utils/mergeRegister.ts create mode 100644 resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts create mode 100644 resources/js/wysiwyg/lexical/utils/px.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/Bindings.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/SyncCursors.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/Utils.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/index.ts create mode 100644 resources/js/wysiwyg/lexical/yjs/types.ts diff --git a/.gitignore b/.gitignore index 55cc0557b..3582c4102 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /node_modules /.vscode /composer +/coverage Homestead.yaml .env .idea diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index 0680f4ac3..fea8c01e3 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -38,6 +38,8 @@ esbuild.build({ absWorkingDir: path.join(__dirname, '../..'), alias: { '@icons': './resources/icons', + lexical: './resources/js/wysiwyg/lexical/core', + '@lexical': './resources/js/wysiwyg/lexical', }, banner: { js: '// See the "/licenses" URI for full package license details', diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 000000000..0243b39cd --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,207 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type {Config} from 'jest'; +import {pathsToModuleNameMapper} from "ts-jest"; +import { compilerOptions } from './tsconfig.json'; + +const config: Config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/tmp/jest_rs", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + globals: { + __DEV__: true, + }, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + modulePaths: ['/home/dan/web/bookstack/'], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [ + "./resources/js" + ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + transform: { + "^.+.tsx?$": ["ts-jest",{}], + }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +export default config; diff --git a/package-lock.json b/package-lock.json index 1d2527661..0b6c97080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,20 +18,12 @@ "@codemirror/state": "^6.3.3", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.22.2", - "@lexical/history": "^0.17.0", - "@lexical/html": "^0.17.0", - "@lexical/link": "^0.17.0", - "@lexical/list": "^0.17.0", - "@lexical/rich-text": "^0.17.0", - "@lexical/selection": "^0.17.0", - "@lexical/table": "^0.17.0", - "@lexical/utils": "^0.17.0", "@lezer/highlight": "^1.2.0", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", + "@types/jest": "^29.5.13", "codemirror": "^6.0.1", "idb-keyval": "^6.2.1", - "lexical": "^0.17.0", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", @@ -39,17 +31,597 @@ }, "devDependencies": { "@lezer/generator": "^1.5.1", + "babel-jest": "^29.7.0", "chokidar-cli": "^3.0", "esbuild": "^0.20", "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", "sass": "^1.69.5", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "typescript": "^5.4.5" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "dependencies": { + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.6", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.24.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.6.tgz", + "integrity": "sha512-sXaDXaJN9SNLymBdlWFA+bjzBhFD617ZaFiY13dGt7TVslVvVgA6fkZOP7Ki3IGElC45lwHdOTrCtKZGVAWeLQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", + "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", + "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@codemirror/autocomplete": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", @@ -235,6 +807,28 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -694,92 +1288,435 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "node_modules/@lexical/clipboard": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.17.1.tgz", - "integrity": "sha512-OVqnEfWX8XN5xxuMPo6BfgGKHREbz++D5V5ISOiml0Z8fV/TQkdgwqbBJcUdJHGRHWSUwdK7CWGs/VALvVvZyw==", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, "dependencies": { - "@lexical/html": "0.17.1", - "@lexical/list": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@lexical/history": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.17.1.tgz", - "integrity": "sha512-OU/ohajz4FXchUhghsWC7xeBPypFe50FCm5OePwo767G7P233IztgRKIng2pTT4zhCPW7S6Mfl53JoFHKehpWA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "sprintf-js": "~1.0.2" } }, - "node_modules/@lexical/html": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.17.1.tgz", - "integrity": "sha512-yGG+K2DXl7Wn2DpNuZ0Y3uCHJgfHkJN3/MmnFb4jLnH1FoJJiuy7WJb/BRRh9H+6xBJ9v70iv+kttDJ0u1xp5w==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "dependencies": { - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@lexical/link": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.17.1.tgz", - "integrity": "sha512-qFJEKBesZAtR8kfJfIVXRFXVw6dwcpmGCW7duJbtBRjdLjralOxrlVKyFhW9PEXGhi4Mdq2Ux16YnnDncpORdQ==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@lexical/list": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.17.1.tgz", - "integrity": "sha512-k9ZnmQuBvW+xVUtWJZwoGtiVG2cy+hxzkLGU4jTq1sqxRIoSeGcjvhFAK8JSEj4i21SgkB1FmkWXoYK5kbwtRA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@lexical/rich-text": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.17.1.tgz", - "integrity": "sha512-T3kvj4P1OpedX9jvxN3WN8NP1Khol6mCW2ScFIRNRz2dsXgyN00thH1Q1J/uyu7aKyGS7rzcY0rb1Pz1qFufqQ==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "dependencies": { - "@lexical/clipboard": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@lexical/selection": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.17.1.tgz", - "integrity": "sha512-qBKVn+lMV2YIoyRELNr1/QssXx/4c0id9NCB/BOuYlG8du5IjviVJquEF56NEv2t0GedDv4BpUwkhXT2QbNAxA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "dependencies": { - "lexical": "0.17.1" + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@lexical/table": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.17.1.tgz", - "integrity": "sha512-2fUYPmxhyuMQX3MRvSsNaxbgvwGNJpHaKx1Ldc+PT2MvDZ6ALZkfsxbi0do54Q3i7dOon8/avRp4TuVaCnqvoA==", - "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" } }, - "node_modules/@lexical/utils": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.17.1.tgz", - "integrity": "sha512-jCQER5EsvhLNxKH3qgcpdWj/necUb82Xjp8qWQ3c0tyL07hIRm2tDRA/s9mQmvcP855HEZSmGVmR5SKtkcEAVg==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, "dependencies": { - "@lexical/list": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/table": "0.17.1", - "lexical": "0.17.1" + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@lezer/common": { @@ -926,6 +1863,29 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, "node_modules/@ssddanbrown/codemirror-lang-smarty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-smarty/-/codemirror-lang-smarty-1.0.0.tgz", @@ -941,18 +1901,181 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.13", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", + "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/node": { + "version": "22.5.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", + "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -965,6 +2088,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -974,6 +2107,30 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -990,6 +2147,33 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1003,7 +2187,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1027,6 +2210,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1146,6 +2335,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1161,6 +2362,116 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1193,7 +2504,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -1201,6 +2511,65 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -1238,11 +2607,30 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001660", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", + "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1254,6 +2642,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1296,6 +2693,26 @@ "node": ">= 8.10.0" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "dev": true + }, "node_modules/cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -1328,6 +2745,16 @@ "node": ">=6" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, "node_modules/codemirror": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", @@ -1342,11 +2769,16 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1357,8 +2789,19 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -1372,6 +2815,39 @@ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", "dev": true }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -1391,6 +2867,44 @@ "node": ">= 8" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -1468,12 +2982,41 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1508,6 +3051,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1520,6 +3098,52 @@ "node": ">=6.0.0" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.25", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.25.tgz", + "integrity": "sha512-kMb204zvK3PsSlgvvwzI3wBIcAw15tRkYk+NQdsjdDtcQWTp2RABbMQ9rUBy8KNEOM+/E6ep+XC3AykiWZld4g==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -1717,6 +3341,15 @@ "@esbuild/win32-x64": "0.20.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1729,6 +3362,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", @@ -1959,6 +3613,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -2001,6 +3668,53 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2028,6 +3742,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2040,11 +3763,40 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2097,6 +3849,20 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2153,6 +3919,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2181,6 +3956,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -2277,8 +4073,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -2299,7 +4094,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2373,6 +4167,72 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -2409,6 +4269,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2586,6 +4465,15 @@ "node": ">=4" } }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2614,7 +4502,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -2643,6 +4530,12 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -2674,6 +4567,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -2743,6 +4648,819 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2755,6 +5473,84 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2767,6 +5563,12 @@ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2800,6 +5602,24 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2813,10 +5633,11 @@ "node": ">= 0.8.0" } }, - "node_modules/lexical": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.17.1.tgz", - "integrity": "sha512-72/MhR7jqmyqD10bmJw8gztlCm4KDDT+TPtU4elqXrEvHoO5XENi34YAEUD9gIkPfqSwyLa9mwAX1nKzIr5xEA==" + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true }, "node_modules/linkify-it": { "version": "5.0.0", @@ -2886,6 +5707,12 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2898,6 +5725,57 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "dev": true }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -2933,6 +5811,54 @@ "node": ">= 0.10.0" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2972,6 +5898,18 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -3165,6 +6103,24 @@ "which": "bin/which" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", + "dev": true + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -3276,6 +6232,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3363,6 +6334,18 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3408,11 +6391,15 @@ "node": ">=4" } }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -3441,6 +6428,79 @@ "node": ">=4" } }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -3459,6 +6519,49 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3476,6 +6579,28 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3496,6 +6621,11 @@ } ] }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -3555,6 +6685,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3572,6 +6708,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3581,6 +6738,15 @@ "node": ">=4" } }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -3665,6 +6831,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, "node_modules/sass": { "version": "1.78.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", @@ -3682,6 +6854,18 @@ "node": ">=14.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3777,6 +6961,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, "node_modules/snabbdom": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.2.tgz", @@ -3790,6 +6994,15 @@ "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz", "integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg==" }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", @@ -3799,6 +7012,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -3831,6 +7054,44 @@ "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", "dev": true }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -3954,6 +7215,15 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3975,7 +7245,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -3995,17 +7264,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -4013,6 +7316,157 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ts-jest": { + "version": "29.2.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", + "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "dev": true, + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.6.3", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -4037,6 +7491,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -4155,6 +7618,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4164,6 +7671,36 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -4179,6 +7716,70 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4312,6 +7913,19 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -4333,12 +7947,33 @@ } } }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "dev": true }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, "node_modules/yargs": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", @@ -4428,6 +8063,15 @@ "node": ">=4" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d39bf5a2c..163df34ed 100644 --- a/package.json +++ b/package.json @@ -15,18 +15,24 @@ "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads", "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"", - "ts:lint": "tsc --noEmit" + "ts:lint": "tsc --noEmit", + "test": "jest" }, "devDependencies": { "@lezer/generator": "^1.5.1", + "babel-jest": "^29.7.0", "chokidar-cli": "^3.0", "esbuild": "^0.20", "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", "sass": "^1.69.5", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "typescript": "^5.4.5" }, "dependencies": { @@ -43,20 +49,12 @@ "@codemirror/state": "^6.3.3", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.22.2", - "@lexical/history": "^0.17.0", - "@lexical/html": "^0.17.0", - "@lexical/link": "^0.17.0", - "@lexical/list": "^0.17.0", - "@lexical/rich-text": "^0.17.0", - "@lexical/selection": "^0.17.0", - "@lexical/table": "^0.17.0", - "@lexical/utils": "^0.17.0", "@lezer/highlight": "^1.2.0", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", + "@types/jest": "^29.5.13", "codemirror": "^6.0.1", "idb-keyval": "^6.2.1", - "lexical": "^0.17.0", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", diff --git a/resources/js/app.js b/resources/js/app.js index e08b90ba1..7f4bbe54d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -4,6 +4,9 @@ import Translations from './services/translations'; import * as componentMap from './components'; import {ComponentStore} from './services/components.ts'; +// eslint-disable-next-line no-underscore-dangle +window.__DEV__ = false; + // Url retrieval function window.baseUrl = function baseUrl(path) { let targetPath = path; diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index 1f216b7a5..0d7efc4d4 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -3,10 +3,12 @@ import {EventManager} from "./services/events"; import {HttpManager} from "./services/http"; declare global { + const __DEV__: boolean; + interface Window { - $components: ComponentStore, - $events: EventManager, - $http: HttpManager, + $components: ComponentStore; + $events: EventManager; + $http: HttpManager; baseUrl: (path: string) => string; } } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE b/resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE new file mode 100644 index 000000000..b93be9051 --- /dev/null +++ b/resources/js/wysiwyg/lexical/ORIGINAL-LEXICAL-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/js/wysiwyg/lexical/clipboard/clipboard.ts b/resources/js/wysiwyg/lexical/clipboard/clipboard.ts new file mode 100644 index 000000000..1d79c2d7b --- /dev/null +++ b/resources/js/wysiwyg/lexical/clipboard/clipboard.ts @@ -0,0 +1,542 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection'; +import {objectKlassEquals} from '@lexical/utils'; +import { + $cloneWithProperties, + $createTabNode, + $getEditor, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + $parseSerializedNode, + BaseSelection, + COMMAND_PRIORITY_CRITICAL, + COPY_COMMAND, + isSelectionWithinEditor, + LexicalEditor, + LexicalNode, + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, + SerializedElementNode, + SerializedTextNode, +} from 'lexical'; +import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; +import invariant from 'lexical/shared/invariant'; + +const getDOMSelection = (targetWindow: Window | null): Selection | null => + CAN_USE_DOM ? (targetWindow || window).getSelection() : null; + +export interface LexicalClipboardData { + 'text/html'?: string | undefined; + 'application/x-lexical-editor'?: string | undefined; + 'text/plain': string; +} + +/** + * Returns the *currently selected* Lexical content as an HTML string, relying on the + * logic defined in the exportDOM methods on the LexicalNode classes. Note that + * this will not return the HTML content of the entire editor (unless all the content is included + * in the current selection). + * + * @param editor - LexicalEditor instance to get HTML content from + * @param selection - The selection to use (default is $getSelection()) + * @returns a string of HTML content + */ +export function $getHtmlContent( + editor: LexicalEditor, + selection = $getSelection(), +): string { + if (selection == null) { + invariant(false, 'Expected valid LexicalSelection'); + } + + // If we haven't selected anything + if ( + ($isRangeSelection(selection) && selection.isCollapsed()) || + selection.getNodes().length === 0 + ) { + return ''; + } + + return $generateHtmlFromNodes(editor, selection); +} + +/** + * Returns the *currently selected* Lexical content as a JSON string, relying on the + * logic defined in the exportJSON methods on the LexicalNode classes. Note that + * this will not return the JSON content of the entire editor (unless all the content is included + * in the current selection). + * + * @param editor - LexicalEditor instance to get the JSON content from + * @param selection - The selection to use (default is $getSelection()) + * @returns + */ +export function $getLexicalContent( + editor: LexicalEditor, + selection = $getSelection(), +): null | string { + if (selection == null) { + invariant(false, 'Expected valid LexicalSelection'); + } + + // If we haven't selected anything + if ( + ($isRangeSelection(selection) && selection.isCollapsed()) || + selection.getNodes().length === 0 + ) { + return null; + } + + return JSON.stringify($generateJSONFromSelectedNodes(editor, selection)); +} + +/** + * Attempts to insert content of the mime-types text/plain or text/uri-list from + * the provided DataTransfer object into the editor at the provided selection. + * text/uri-list is only used if text/plain is not also provided. + * + * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface) + * @param selection the selection to use as the insertion point for the content in the DataTransfer object + */ +export function $insertDataTransferForPlainText( + dataTransfer: DataTransfer, + selection: BaseSelection, +): void { + const text = + dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list'); + + if (text != null) { + selection.insertRawText(text); + } +} + +/** + * Attempts to insert content of the mime-types application/x-lexical-editor, text/html, + * text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer + * object into the editor at the provided selection. + * + * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface) + * @param selection the selection to use as the insertion point for the content in the DataTransfer object + * @param editor the LexicalEditor the content is being inserted into. + */ +export function $insertDataTransferForRichText( + dataTransfer: DataTransfer, + selection: BaseSelection, + editor: LexicalEditor, +): void { + const lexicalString = dataTransfer.getData('application/x-lexical-editor'); + + if (lexicalString) { + try { + const payload = JSON.parse(lexicalString); + if ( + payload.namespace === editor._config.namespace && + Array.isArray(payload.nodes) + ) { + const nodes = $generateNodesFromSerializedNodes(payload.nodes); + return $insertGeneratedNodes(editor, nodes, selection); + } + } catch { + // Fail silently. + } + } + + const htmlString = dataTransfer.getData('text/html'); + if (htmlString) { + try { + const parser = new DOMParser(); + const dom = parser.parseFromString(htmlString, 'text/html'); + const nodes = $generateNodesFromDOM(editor, dom); + return $insertGeneratedNodes(editor, nodes, selection); + } catch { + // Fail silently. + } + } + + // Multi-line plain text in rich text mode pasted as separate paragraphs + // instead of single paragraph with linebreaks. + // Webkit-specific: Supports read 'text/uri-list' in clipboard. + const text = + dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list'); + if (text != null) { + if ($isRangeSelection(selection)) { + const parts = text.split(/(\r?\n|\t)/); + if (parts[parts.length - 1] === '') { + parts.pop(); + } + for (let i = 0; i < parts.length; i++) { + const currentSelection = $getSelection(); + if ($isRangeSelection(currentSelection)) { + const part = parts[i]; + if (part === '\n' || part === '\r\n') { + currentSelection.insertParagraph(); + } else if (part === '\t') { + currentSelection.insertNodes([$createTabNode()]); + } else { + currentSelection.insertText(part); + } + } + } + } else { + selection.insertRawText(text); + } + } +} + +/** + * Inserts Lexical nodes into the editor using different strategies depending on + * some simple selection-based heuristics. If you're looking for a generic way to + * to insert nodes into the editor at a specific selection point, you probably want + * {@link lexical.$insertNodes} + * + * @param editor LexicalEditor instance to insert the nodes into. + * @param nodes The nodes to insert. + * @param selection The selection to insert the nodes into. + */ +export function $insertGeneratedNodes( + editor: LexicalEditor, + nodes: Array, + selection: BaseSelection, +): void { + if ( + !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, { + nodes, + selection, + }) + ) { + selection.insertNodes(nodes); + } + return; +} + +export interface BaseSerializedNode { + children?: Array; + type: string; + version: number; +} + +function exportNodeToJSON(node: T): BaseSerializedNode { + const serializedNode = node.exportJSON(); + const nodeClass = node.constructor; + + if (serializedNode.type !== nodeClass.getType()) { + invariant( + false, + 'LexicalNode: Node %s does not implement .exportJSON().', + nodeClass.name, + ); + } + + if ($isElementNode(node)) { + const serializedChildren = (serializedNode as SerializedElementNode) + .children; + if (!Array.isArray(serializedChildren)) { + invariant( + false, + 'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.', + nodeClass.name, + ); + } + } + + return serializedNode; +} + +function $appendNodesToJSON( + editor: LexicalEditor, + selection: BaseSelection | null, + currentNode: LexicalNode, + targetArray: Array = [], +): boolean { + let shouldInclude = + selection !== null ? currentNode.isSelected(selection) : true; + const shouldExclude = + $isElementNode(currentNode) && currentNode.excludeFromCopy('html'); + let target = currentNode; + + if (selection !== null) { + let clone = $cloneWithProperties(currentNode); + clone = + $isTextNode(clone) && selection !== null + ? $sliceSelectedTextNodeContent(selection, clone) + : clone; + target = clone; + } + const children = $isElementNode(target) ? target.getChildren() : []; + + const serializedNode = exportNodeToJSON(target); + + // TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method + // which uses getLatest() to get the text from the original node with the same key. + // This is a deeper issue with the word "clone" here, it's still a reference to the + // same node as far as the LexicalEditor is concerned since it shares a key. + // We need a way to create a clone of a Node in memory with its own key, but + // until then this hack will work for the selected text extract use case. + if ($isTextNode(target)) { + const text = target.__text; + // If an uncollapsed selection ends or starts at the end of a line of specialized, + // TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one + // with text of length 0. We don't want this, it makes a confusing mess. Reset! + if (text.length > 0) { + (serializedNode as SerializedTextNode).text = text; + } else { + shouldInclude = false; + } + } + + for (let i = 0; i < children.length; i++) { + const childNode = children[i]; + const shouldIncludeChild = $appendNodesToJSON( + editor, + selection, + childNode, + serializedNode.children, + ); + + if ( + !shouldInclude && + $isElementNode(currentNode) && + shouldIncludeChild && + currentNode.extractWithChild(childNode, selection, 'clone') + ) { + shouldInclude = true; + } + } + + if (shouldInclude && !shouldExclude) { + targetArray.push(serializedNode); + } else if (Array.isArray(serializedNode.children)) { + for (let i = 0; i < serializedNode.children.length; i++) { + const serializedChildNode = serializedNode.children[i]; + targetArray.push(serializedChildNode); + } + } + + return shouldInclude; +} + +// TODO why $ function with Editor instance? +/** + * Gets the Lexical JSON of the nodes inside the provided Selection. + * + * @param editor LexicalEditor to get the JSON content from. + * @param selection Selection to get the JSON content from. + * @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects. + */ +export function $generateJSONFromSelectedNodes< + SerializedNode extends BaseSerializedNode, +>( + editor: LexicalEditor, + selection: BaseSelection | null, +): { + namespace: string; + nodes: Array; +} { + const nodes: Array = []; + const root = $getRoot(); + const topLevelChildren = root.getChildren(); + for (let i = 0; i < topLevelChildren.length; i++) { + const topLevelNode = topLevelChildren[i]; + $appendNodesToJSON(editor, selection, topLevelNode, nodes); + } + return { + namespace: editor._config.namespace, + nodes, + }; +} + +/** + * This method takes an array of objects conforming to the BaseSeralizedNode interface and returns + * an Array containing instances of the corresponding LexicalNode classes registered on the editor. + * Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes} + * + * @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface. + * @returns an Array of Lexical Node objects. + */ +export function $generateNodesFromSerializedNodes( + serializedNodes: Array, +): Array { + const nodes = []; + for (let i = 0; i < serializedNodes.length; i++) { + const serializedNode = serializedNodes[i]; + const node = $parseSerializedNode(serializedNode); + if ($isTextNode(node)) { + $addNodeStyle(node); + } + nodes.push(node); + } + return nodes; +} + +const EVENT_LATENCY = 50; +let clipboardEventTimeout: null | number = null; + +// TODO custom selection +// TODO potentially have a node customizable version for plain text +/** + * Copies the content of the current selection to the clipboard in + * text/plain, text/html, and application/x-lexical-editor (Lexical JSON) + * formats. + * + * @param editor the LexicalEditor instance to copy content from + * @param event the native browser ClipboardEvent to add the content to. + * @returns + */ +export async function copyToClipboard( + editor: LexicalEditor, + event: null | ClipboardEvent, + data?: LexicalClipboardData, +): Promise { + if (clipboardEventTimeout !== null) { + // Prevent weird race conditions that can happen when this function is run multiple times + // synchronously. In the future, we can do better, we can cancel/override the previously running job. + return false; + } + if (event !== null) { + return new Promise((resolve, reject) => { + editor.update(() => { + resolve($copyToClipboardEvent(editor, event, data)); + }); + }); + } + + const rootElement = editor.getRootElement(); + const windowDocument = + editor._window == null ? window.document : editor._window.document; + const domSelection = getDOMSelection(editor._window); + if (rootElement === null || domSelection === null) { + return false; + } + const element = windowDocument.createElement('span'); + element.style.cssText = 'position: fixed; top: -1000px;'; + element.append(windowDocument.createTextNode('#')); + rootElement.append(element); + const range = new Range(); + range.setStart(element, 0); + range.setEnd(element, 1); + domSelection.removeAllRanges(); + domSelection.addRange(range); + return new Promise((resolve, reject) => { + const removeListener = editor.registerCommand( + COPY_COMMAND, + (secondEvent) => { + if (objectKlassEquals(secondEvent, ClipboardEvent)) { + removeListener(); + if (clipboardEventTimeout !== null) { + window.clearTimeout(clipboardEventTimeout); + clipboardEventTimeout = null; + } + resolve( + $copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data), + ); + } + // Block the entire copy flow while we wait for the next ClipboardEvent + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ); + // If the above hack execCommand hack works, this timeout code should never fire. Otherwise, + // the listener will be quickly freed so that the user can reuse it again + clipboardEventTimeout = window.setTimeout(() => { + removeListener(); + clipboardEventTimeout = null; + resolve(false); + }, EVENT_LATENCY); + windowDocument.execCommand('copy'); + element.remove(); + }); +} + +// TODO shouldn't pass editor (pass namespace directly) +function $copyToClipboardEvent( + editor: LexicalEditor, + event: ClipboardEvent, + data?: LexicalClipboardData, +): boolean { + if (data === undefined) { + const domSelection = getDOMSelection(editor._window); + if (!domSelection) { + return false; + } + const anchorDOM = domSelection.anchorNode; + const focusDOM = domSelection.focusNode; + if ( + anchorDOM !== null && + focusDOM !== null && + !isSelectionWithinEditor(editor, anchorDOM, focusDOM) + ) { + return false; + } + const selection = $getSelection(); + if (selection === null) { + return false; + } + data = $getClipboardDataFromSelection(selection); + } + event.preventDefault(); + const clipboardData = event.clipboardData; + if (clipboardData === null) { + return false; + } + setLexicalClipboardDataTransfer(clipboardData, data); + return true; +} + +const clipboardDataFunctions = [ + ['text/html', $getHtmlContent], + ['application/x-lexical-editor', $getLexicalContent], +] as const; + +/** + * Serialize the content of the current selection to strings in + * text/plain, text/html, and application/x-lexical-editor (Lexical JSON) + * formats (as available). + * + * @param selection the selection to serialize (defaults to $getSelection()) + * @returns LexicalClipboardData + */ +export function $getClipboardDataFromSelection( + selection: BaseSelection | null = $getSelection(), +): LexicalClipboardData { + const clipboardData: LexicalClipboardData = { + 'text/plain': selection ? selection.getTextContent() : '', + }; + if (selection) { + const editor = $getEditor(); + for (const [mimeType, $editorFn] of clipboardDataFunctions) { + const v = $editorFn(editor, selection); + if (v !== null) { + clipboardData[mimeType] = v; + } + } + } + return clipboardData; +} + +/** + * Call setData on the given clipboardData for each MIME type present + * in the given data (from {@link $getClipboardDataFromSelection}) + * + * @param clipboardData the event.clipboardData to populate from data + * @param data The lexical data + */ +export function setLexicalClipboardDataTransfer( + clipboardData: DataTransfer, + data: LexicalClipboardData, +) { + for (const k in data) { + const v = data[k as keyof LexicalClipboardData]; + if (v !== undefined) { + clipboardData.setData(k, v); + } + } +} diff --git a/resources/js/wysiwyg/lexical/clipboard/index.ts b/resources/js/wysiwyg/lexical/clipboard/index.ts new file mode 100644 index 000000000..ffa1f19f6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/clipboard/index.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { + $generateJSONFromSelectedNodes, + $generateNodesFromSerializedNodes, + $getClipboardDataFromSelection, + $getHtmlContent, + $getLexicalContent, + $insertDataTransferForPlainText, + $insertDataTransferForRichText, + $insertGeneratedNodes, + copyToClipboard, + type LexicalClipboardData, + setLexicalClipboardDataTransfer, +} from './clipboard'; diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts new file mode 100644 index 000000000..0f1c0a5d3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + BaseSelection, + ElementFormatType, + LexicalCommand, + LexicalNode, + TextFormatType, +} from 'lexical'; + +export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent; + +export function createCommand(type?: string): LexicalCommand { + return __DEV__ ? {type} : {}; +} + +export const SELECTION_CHANGE_COMMAND: LexicalCommand = createCommand( + 'SELECTION_CHANGE_COMMAND', +); +export const SELECTION_INSERT_CLIPBOARD_NODES_COMMAND: LexicalCommand<{ + nodes: Array; + selection: BaseSelection; +}> = createCommand('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND'); +export const CLICK_COMMAND: LexicalCommand = + createCommand('CLICK_COMMAND'); +export const DELETE_CHARACTER_COMMAND: LexicalCommand = createCommand( + 'DELETE_CHARACTER_COMMAND', +); +export const INSERT_LINE_BREAK_COMMAND: LexicalCommand = createCommand( + 'INSERT_LINE_BREAK_COMMAND', +); +export const INSERT_PARAGRAPH_COMMAND: LexicalCommand = createCommand( + 'INSERT_PARAGRAPH_COMMAND', +); +export const CONTROLLED_TEXT_INSERTION_COMMAND: LexicalCommand< + InputEvent | string +> = createCommand('CONTROLLED_TEXT_INSERTION_COMMAND'); +export const PASTE_COMMAND: LexicalCommand = + createCommand('PASTE_COMMAND'); +export const REMOVE_TEXT_COMMAND: LexicalCommand = + createCommand('REMOVE_TEXT_COMMAND'); +export const DELETE_WORD_COMMAND: LexicalCommand = createCommand( + 'DELETE_WORD_COMMAND', +); +export const DELETE_LINE_COMMAND: LexicalCommand = createCommand( + 'DELETE_LINE_COMMAND', +); +export const FORMAT_TEXT_COMMAND: LexicalCommand = + createCommand('FORMAT_TEXT_COMMAND'); +export const UNDO_COMMAND: LexicalCommand = createCommand('UNDO_COMMAND'); +export const REDO_COMMAND: LexicalCommand = createCommand('REDO_COMMAND'); +export const KEY_DOWN_COMMAND: LexicalCommand = + createCommand('KEYDOWN_COMMAND'); +export const KEY_ARROW_RIGHT_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_RIGHT_COMMAND'); +export const MOVE_TO_END: LexicalCommand = + createCommand('MOVE_TO_END'); +export const KEY_ARROW_LEFT_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_LEFT_COMMAND'); +export const MOVE_TO_START: LexicalCommand = + createCommand('MOVE_TO_START'); +export const KEY_ARROW_UP_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_UP_COMMAND'); +export const KEY_ARROW_DOWN_COMMAND: LexicalCommand = + createCommand('KEY_ARROW_DOWN_COMMAND'); +export const KEY_ENTER_COMMAND: LexicalCommand = + createCommand('KEY_ENTER_COMMAND'); +export const KEY_SPACE_COMMAND: LexicalCommand = + createCommand('KEY_SPACE_COMMAND'); +export const KEY_BACKSPACE_COMMAND: LexicalCommand = + createCommand('KEY_BACKSPACE_COMMAND'); +export const KEY_ESCAPE_COMMAND: LexicalCommand = + createCommand('KEY_ESCAPE_COMMAND'); +export const KEY_DELETE_COMMAND: LexicalCommand = + createCommand('KEY_DELETE_COMMAND'); +export const KEY_TAB_COMMAND: LexicalCommand = + createCommand('KEY_TAB_COMMAND'); +export const INSERT_TAB_COMMAND: LexicalCommand = + createCommand('INSERT_TAB_COMMAND'); +export const INDENT_CONTENT_COMMAND: LexicalCommand = createCommand( + 'INDENT_CONTENT_COMMAND', +); +export const OUTDENT_CONTENT_COMMAND: LexicalCommand = createCommand( + 'OUTDENT_CONTENT_COMMAND', +); +export const DROP_COMMAND: LexicalCommand = + createCommand('DROP_COMMAND'); +export const FORMAT_ELEMENT_COMMAND: LexicalCommand = + createCommand('FORMAT_ELEMENT_COMMAND'); +export const DRAGSTART_COMMAND: LexicalCommand = + createCommand('DRAGSTART_COMMAND'); +export const DRAGOVER_COMMAND: LexicalCommand = + createCommand('DRAGOVER_COMMAND'); +export const DRAGEND_COMMAND: LexicalCommand = + createCommand('DRAGEND_COMMAND'); +export const COPY_COMMAND: LexicalCommand< + ClipboardEvent | KeyboardEvent | null +> = createCommand('COPY_COMMAND'); +export const CUT_COMMAND: LexicalCommand< + ClipboardEvent | KeyboardEvent | null +> = createCommand('CUT_COMMAND'); +export const SELECT_ALL_COMMAND: LexicalCommand = + createCommand('SELECT_ALL_COMMAND'); +export const CLEAR_EDITOR_COMMAND: LexicalCommand = createCommand( + 'CLEAR_EDITOR_COMMAND', +); +export const CLEAR_HISTORY_COMMAND: LexicalCommand = createCommand( + 'CLEAR_HISTORY_COMMAND', +); +export const CAN_REDO_COMMAND: LexicalCommand = + createCommand('CAN_REDO_COMMAND'); +export const CAN_UNDO_COMMAND: LexicalCommand = + createCommand('CAN_UNDO_COMMAND'); +export const FOCUS_COMMAND: LexicalCommand = + createCommand('FOCUS_COMMAND'); +export const BLUR_COMMAND: LexicalCommand = + createCommand('BLUR_COMMAND'); +export const KEY_MODIFIER_COMMAND: LexicalCommand = + createCommand('KEY_MODIFIER_COMMAND'); diff --git a/resources/js/wysiwyg/lexical/core/LexicalConstants.ts b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts new file mode 100644 index 000000000..82461e74d --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {ElementFormatType} from './nodes/LexicalElementNode'; +import type { + TextDetailType, + TextFormatType, + TextModeType, +} from './nodes/LexicalTextNode'; + +import { + IS_APPLE_WEBKIT, + IS_FIREFOX, + IS_IOS, + IS_SAFARI, +} from 'lexical/shared/environment'; + +// DOM +export const DOM_ELEMENT_TYPE = 1; +export const DOM_TEXT_TYPE = 3; + +// Reconciling +export const NO_DIRTY_NODES = 0; +export const HAS_DIRTY_NODES = 1; +export const FULL_RECONCILE = 2; + +// Text node modes +export const IS_NORMAL = 0; +export const IS_TOKEN = 1; +export const IS_SEGMENTED = 2; +// IS_INERT = 3 + +// Text node formatting +export const IS_BOLD = 1; +export const IS_ITALIC = 1 << 1; +export const IS_STRIKETHROUGH = 1 << 2; +export const IS_UNDERLINE = 1 << 3; +export const IS_CODE = 1 << 4; +export const IS_SUBSCRIPT = 1 << 5; +export const IS_SUPERSCRIPT = 1 << 6; +export const IS_HIGHLIGHT = 1 << 7; + +export const IS_ALL_FORMATTING = + IS_BOLD | + IS_ITALIC | + IS_STRIKETHROUGH | + IS_UNDERLINE | + IS_CODE | + IS_SUBSCRIPT | + IS_SUPERSCRIPT | + IS_HIGHLIGHT; + +// Text node details +export const IS_DIRECTIONLESS = 1; +export const IS_UNMERGEABLE = 1 << 1; + +// Element node formatting +export const IS_ALIGN_LEFT = 1; +export const IS_ALIGN_CENTER = 2; +export const IS_ALIGN_RIGHT = 3; +export const IS_ALIGN_JUSTIFY = 4; +export const IS_ALIGN_START = 5; +export const IS_ALIGN_END = 6; + +// Reconciliation +export const NON_BREAKING_SPACE = '\u00A0'; +const ZERO_WIDTH_SPACE = '\u200b'; + +// For iOS/Safari we use a non breaking space, otherwise the cursor appears +// overlapping the composed text. +export const COMPOSITION_SUFFIX: string = + IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT + ? NON_BREAKING_SPACE + : ZERO_WIDTH_SPACE; +export const DOUBLE_LINE_BREAK = '\n\n'; + +// For FF, we need to use a non-breaking space, or it gets composition +// in a stuck state. +export const COMPOSITION_START_CHAR: string = IS_FIREFOX + ? NON_BREAKING_SPACE + : COMPOSITION_SUFFIX; +const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'; +const LTR = + 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + + '\uFE00-\uFE6F\uFEFD-\uFFFF'; + +// eslint-disable-next-line no-misleading-character-class +export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']'); +// eslint-disable-next-line no-misleading-character-class +export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']'); + +export const TEXT_TYPE_TO_FORMAT: Record = { + bold: IS_BOLD, + code: IS_CODE, + highlight: IS_HIGHLIGHT, + italic: IS_ITALIC, + strikethrough: IS_STRIKETHROUGH, + subscript: IS_SUBSCRIPT, + superscript: IS_SUPERSCRIPT, + underline: IS_UNDERLINE, +}; + +export const DETAIL_TYPE_TO_DETAIL: Record = { + directionless: IS_DIRECTIONLESS, + unmergeable: IS_UNMERGEABLE, +}; + +export const ELEMENT_TYPE_TO_FORMAT: Record< + Exclude, + number +> = { + center: IS_ALIGN_CENTER, + end: IS_ALIGN_END, + justify: IS_ALIGN_JUSTIFY, + left: IS_ALIGN_LEFT, + right: IS_ALIGN_RIGHT, + start: IS_ALIGN_START, +}; + +export const ELEMENT_FORMAT_TO_TYPE: Record = { + [IS_ALIGN_CENTER]: 'center', + [IS_ALIGN_END]: 'end', + [IS_ALIGN_JUSTIFY]: 'justify', + [IS_ALIGN_LEFT]: 'left', + [IS_ALIGN_RIGHT]: 'right', + [IS_ALIGN_START]: 'start', +}; + +export const TEXT_MODE_TO_TYPE: Record = { + normal: IS_NORMAL, + segmented: IS_SEGMENTED, + token: IS_TOKEN, +}; + +export const TEXT_TYPE_TO_MODE: Record = { + [IS_NORMAL]: 'normal', + [IS_SEGMENTED]: 'segmented', + [IS_TOKEN]: 'token', +}; diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts new file mode 100644 index 000000000..b0b90002e --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -0,0 +1,1289 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {EditorState, SerializedEditorState} from './LexicalEditorState'; +import type { + DOMConversion, + DOMConversionMap, + DOMExportOutput, + DOMExportOutputMap, + NodeKey, +} from './LexicalNode'; + +import invariant from 'lexical/shared/invariant'; + +import {$getRoot, $getSelection, TextNode} from '.'; +import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; +import {createEmptyEditorState} from './LexicalEditorState'; +import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents'; +import {$flushRootMutations, initMutationObserver} from './LexicalMutations'; +import {LexicalNode} from './LexicalNode'; +import { + $commitPendingUpdates, + internalGetActiveEditor, + parseEditorState, + triggerListeners, + updateEditor, +} from './LexicalUpdates'; +import { + createUID, + dispatchCommand, + getCachedClassNameArray, + getCachedTypeToNodeMap, + getDefaultView, + getDOMSelection, + markAllNodesAsDirty, +} from './LexicalUtils'; +import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; +import {DecoratorNode} from './nodes/LexicalDecoratorNode'; +import {LineBreakNode} from './nodes/LexicalLineBreakNode'; +import {ParagraphNode} from './nodes/LexicalParagraphNode'; +import {RootNode} from './nodes/LexicalRootNode'; +import {TabNode} from './nodes/LexicalTabNode'; + +export type Spread = Omit & T1; + +// https://github.com/microsoft/TypeScript/issues/3841 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type KlassConstructor> = + GenericConstructor> & {[k in keyof Cls]: Cls[k]}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GenericConstructor = new (...args: any[]) => T; + +export type Klass = InstanceType< + T['constructor'] +> extends T + ? T['constructor'] + : GenericConstructor & T['constructor']; + +export type EditorThemeClassName = string; + +export type TextNodeThemeClasses = { + base?: EditorThemeClassName; + bold?: EditorThemeClassName; + code?: EditorThemeClassName; + highlight?: EditorThemeClassName; + italic?: EditorThemeClassName; + strikethrough?: EditorThemeClassName; + subscript?: EditorThemeClassName; + superscript?: EditorThemeClassName; + underline?: EditorThemeClassName; + underlineStrikethrough?: EditorThemeClassName; + [key: string]: EditorThemeClassName | undefined; +}; + +export type EditorUpdateOptions = { + onUpdate?: () => void; + skipTransforms?: true; + tag?: string; + discrete?: true; +}; + +export type EditorSetOptions = { + tag?: string; +}; + +export type EditorFocusOptions = { + defaultSelection?: 'rootStart' | 'rootEnd'; +}; + +export type EditorThemeClasses = { + blockCursor?: EditorThemeClassName; + characterLimit?: EditorThemeClassName; + code?: EditorThemeClassName; + codeHighlight?: Record; + hashtag?: EditorThemeClassName; + heading?: { + h1?: EditorThemeClassName; + h2?: EditorThemeClassName; + h3?: EditorThemeClassName; + h4?: EditorThemeClassName; + h5?: EditorThemeClassName; + h6?: EditorThemeClassName; + }; + hr?: EditorThemeClassName; + image?: EditorThemeClassName; + link?: EditorThemeClassName; + list?: { + ul?: EditorThemeClassName; + ulDepth?: Array; + ol?: EditorThemeClassName; + olDepth?: Array; + checklist?: EditorThemeClassName; + listitem?: EditorThemeClassName; + listitemChecked?: EditorThemeClassName; + listitemUnchecked?: EditorThemeClassName; + nested?: { + list?: EditorThemeClassName; + listitem?: EditorThemeClassName; + }; + }; + ltr?: EditorThemeClassName; + mark?: EditorThemeClassName; + markOverlap?: EditorThemeClassName; + paragraph?: EditorThemeClassName; + quote?: EditorThemeClassName; + root?: EditorThemeClassName; + rtl?: EditorThemeClassName; + table?: EditorThemeClassName; + tableAddColumns?: EditorThemeClassName; + tableAddRows?: EditorThemeClassName; + tableCellActionButton?: EditorThemeClassName; + tableCellActionButtonContainer?: EditorThemeClassName; + tableCellPrimarySelected?: EditorThemeClassName; + tableCellSelected?: EditorThemeClassName; + tableCell?: EditorThemeClassName; + tableCellEditing?: EditorThemeClassName; + tableCellHeader?: EditorThemeClassName; + tableCellResizer?: EditorThemeClassName; + tableCellSortedIndicator?: EditorThemeClassName; + tableResizeRuler?: EditorThemeClassName; + tableRow?: EditorThemeClassName; + tableSelected?: EditorThemeClassName; + text?: TextNodeThemeClasses; + embedBlock?: { + base?: EditorThemeClassName; + focus?: EditorThemeClassName; + }; + indent?: EditorThemeClassName; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +export type EditorConfig = { + disableEvents?: boolean; + namespace: string; + theme: EditorThemeClasses; +}; + +export type LexicalNodeReplacement = { + replace: Klass; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + with: ( + node: InstanceType, + ) => LexicalNode; + withKlass?: Klass; +}; + +export type HTMLConfig = { + export?: DOMExportOutputMap; + import?: DOMConversionMap; +}; + +export type CreateEditorArgs = { + disableEvents?: boolean; + editorState?: EditorState; + namespace?: string; + nodes?: ReadonlyArray | LexicalNodeReplacement>; + onError?: ErrorHandler; + parentEditor?: LexicalEditor; + editable?: boolean; + theme?: EditorThemeClasses; + html?: HTMLConfig; +}; + +export type RegisteredNodes = Map; + +export type RegisteredNode = { + klass: Klass; + transforms: Set>; + replace: null | ((node: LexicalNode) => LexicalNode); + replaceWithKlass: null | Klass; + exportDOM?: ( + editor: LexicalEditor, + targetNode: LexicalNode, + ) => DOMExportOutput; +}; + +export type Transform = (node: T) => void; + +export type ErrorHandler = (error: Error) => void; + +export type MutationListeners = Map>; + +export type MutatedNodes = Map, Map>; + +export type NodeMutation = 'created' | 'updated' | 'destroyed'; + +export interface MutationListenerOptions { + /** + * Skip the initial call of the listener with pre-existing DOM nodes. + * + * The default is currently true for backwards compatibility with <= 0.16.1 + * but this default is expected to change to false in 0.17.0. + */ + skipInitialization?: boolean; +} + +const DEFAULT_SKIP_INITIALIZATION = true; + +export type UpdateListener = (arg0: { + dirtyElements: Map; + dirtyLeaves: Set; + editorState: EditorState; + normalizedNodes: Set; + prevEditorState: EditorState; + tags: Set; +}) => void; + +export type DecoratorListener = ( + decorator: Record, +) => void; + +export type RootListener = ( + rootElement: null | HTMLElement, + prevRootElement: null | HTMLElement, +) => void; + +export type TextContentListener = (text: string) => void; + +export type MutationListener = ( + nodes: Map, + payload: { + updateTags: Set; + dirtyLeaves: Set; + prevEditorState: EditorState; + }, +) => void; + +export type CommandListener

    = (payload: P, editor: LexicalEditor) => boolean; + +export type EditableListener = (editable: boolean) => void; + +export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; + +export const COMMAND_PRIORITY_EDITOR = 0; +export const COMMAND_PRIORITY_LOW = 1; +export const COMMAND_PRIORITY_NORMAL = 2; +export const COMMAND_PRIORITY_HIGH = 3; +export const COMMAND_PRIORITY_CRITICAL = 4; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type LexicalCommand = { + type?: string; +}; + +/** + * Type helper for extracting the payload type from a command. + * + * @example + * ```ts + * const MY_COMMAND = createCommand(); + * + * // ... + * + * editor.registerCommand(MY_COMMAND, payload => { + * // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to + * handleMyCommand(editor, payload); + * return true; + * }); + * + * function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) { + * // `payload` is of type `SomeType`, extracted from the command. + * } + * ``` + */ +export type CommandPayloadType> = + TCommand extends LexicalCommand ? TPayload : never; + +type Commands = Map< + LexicalCommand, + Array>> +>; +type Listeners = { + decorator: Set; + mutation: MutationListeners; + editable: Set; + root: Set; + textcontent: Set; + update: Set; +}; + +export type Listener = + | DecoratorListener + | EditableListener + | MutationListener + | RootListener + | TextContentListener + | UpdateListener; + +export type ListenerType = + | 'update' + | 'root' + | 'decorator' + | 'textcontent' + | 'mutation' + | 'editable'; + +export type TransformerType = 'text' | 'decorator' | 'element' | 'root'; + +type IntentionallyMarkedAsDirtyElement = boolean; + +type DOMConversionCache = Map< + string, + Array<(node: Node) => DOMConversion | null> +>; + +export type SerializedEditor = { + editorState: SerializedEditorState; +}; + +export function resetEditor( + editor: LexicalEditor, + prevRootElement: null | HTMLElement, + nextRootElement: null | HTMLElement, + pendingEditorState: EditorState, +): void { + const keyNodeMap = editor._keyToDOMMap; + keyNodeMap.clear(); + editor._editorState = createEmptyEditorState(); + editor._pendingEditorState = pendingEditorState; + editor._compositionKey = null; + editor._dirtyType = NO_DIRTY_NODES; + editor._cloneNotNeeded.clear(); + editor._dirtyLeaves = new Set(); + editor._dirtyElements.clear(); + editor._normalizedNodes = new Set(); + editor._updateTags = new Set(); + editor._updates = []; + editor._blockCursorElement = null; + + const observer = editor._observer; + + if (observer !== null) { + observer.disconnect(); + editor._observer = null; + } + + // Remove all the DOM nodes from the root element + if (prevRootElement !== null) { + prevRootElement.textContent = ''; + } + + if (nextRootElement !== null) { + nextRootElement.textContent = ''; + keyNodeMap.set('root', nextRootElement); + } +} + +function initializeConversionCache( + nodes: RegisteredNodes, + additionalConversions?: DOMConversionMap, +): DOMConversionCache { + const conversionCache = new Map(); + const handledConversions = new Set(); + const addConversionsToCache = (map: DOMConversionMap) => { + Object.keys(map).forEach((key) => { + let currentCache = conversionCache.get(key); + + if (currentCache === undefined) { + currentCache = []; + conversionCache.set(key, currentCache); + } + + currentCache.push(map[key]); + }); + }; + nodes.forEach((node) => { + const importDOM = node.klass.importDOM; + + if (importDOM == null || handledConversions.has(importDOM)) { + return; + } + + handledConversions.add(importDOM); + const map = importDOM.call(node.klass); + + if (map !== null) { + addConversionsToCache(map); + } + }); + if (additionalConversions) { + addConversionsToCache(additionalConversions); + } + return conversionCache; +} + +/** + * Creates a new LexicalEditor attached to a single contentEditable (provided in the config). This is + * the lowest-level initialization API for a LexicalEditor. If you're using React or another framework, + * consider using the appropriate abstractions, such as LexicalComposer + * @param editorConfig - the editor configuration. + * @returns a LexicalEditor instance + */ +export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { + const config = editorConfig || {}; + const activeEditor = internalGetActiveEditor(); + const theme = config.theme || {}; + const parentEditor = + editorConfig === undefined ? activeEditor : config.parentEditor || null; + const disableEvents = config.disableEvents || false; + const editorState = createEmptyEditorState(); + const namespace = + config.namespace || + (parentEditor !== null ? parentEditor._config.namespace : createUID()); + const initialEditorState = config.editorState; + const nodes = [ + RootNode, + TextNode, + LineBreakNode, + TabNode, + ParagraphNode, + ArtificialNode__DO_NOT_USE, + ...(config.nodes || []), + ]; + const {onError, html} = config; + const isEditable = config.editable !== undefined ? config.editable : true; + let registeredNodes: Map; + + if (editorConfig === undefined && activeEditor !== null) { + registeredNodes = activeEditor._nodes; + } else { + registeredNodes = new Map(); + for (let i = 0; i < nodes.length; i++) { + let klass = nodes[i]; + let replace: RegisteredNode['replace'] = null; + let replaceWithKlass: RegisteredNode['replaceWithKlass'] = null; + + if (typeof klass !== 'function') { + const options = klass; + klass = options.replace; + replace = options.with; + replaceWithKlass = options.withKlass || null; + } + // Ensure custom nodes implement required methods and replaceWithKlass is instance of base klass. + if (__DEV__) { + // ArtificialNode__DO_NOT_USE can get renamed, so we use the type + const nodeType = + Object.prototype.hasOwnProperty.call(klass, 'getType') && + klass.getType(); + const name = klass.name; + + if (replaceWithKlass) { + invariant( + replaceWithKlass.prototype instanceof klass, + "%s doesn't extend the %s", + replaceWithKlass.name, + name, + ); + } + + if ( + name !== 'RootNode' && + nodeType !== 'root' && + nodeType !== 'artificial' + ) { + const proto = klass.prototype; + ['getType', 'clone'].forEach((method) => { + // eslint-disable-next-line no-prototype-builtins + if (!klass.hasOwnProperty(method)) { + console.warn(`${name} must implement static "${method}" method`); + } + }); + if ( + // eslint-disable-next-line no-prototype-builtins + !klass.hasOwnProperty('importDOM') && + // eslint-disable-next-line no-prototype-builtins + klass.hasOwnProperty('exportDOM') + ) { + console.warn( + `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`, + ); + } + if (proto instanceof DecoratorNode) { + // eslint-disable-next-line no-prototype-builtins + if (!proto.hasOwnProperty('decorate')) { + console.warn( + `${proto.constructor.name} must implement "decorate" method`, + ); + } + } + if ( + // eslint-disable-next-line no-prototype-builtins + !klass.hasOwnProperty('importJSON') + ) { + console.warn( + `${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`, + ); + } + if ( + // eslint-disable-next-line no-prototype-builtins + !proto.hasOwnProperty('exportJSON') + ) { + console.warn( + `${name} should implement "exportJSON" method to ensure JSON and default HTML serialization works as expected`, + ); + } + } + } + const type = klass.getType(); + const transform = klass.transform(); + const transforms = new Set>(); + if (transform !== null) { + transforms.add(transform); + } + registeredNodes.set(type, { + exportDOM: html && html.export ? html.export.get(klass) : undefined, + klass, + replace, + replaceWithKlass, + transforms, + }); + } + } + const editor = new LexicalEditor( + editorState, + parentEditor, + registeredNodes, + { + disableEvents, + namespace, + theme, + }, + onError ? onError : console.error, + initializeConversionCache(registeredNodes, html ? html.import : undefined), + isEditable, + ); + + if (initialEditorState !== undefined) { + editor._pendingEditorState = initialEditorState; + editor._dirtyType = FULL_RECONCILE; + } + + return editor; +} +export class LexicalEditor { + ['constructor']!: KlassConstructor; + + /** The version with build identifiers for this editor (since 0.17.1) */ + static version: string | undefined; + + /** @internal */ + _headless: boolean; + /** @internal */ + _parentEditor: null | LexicalEditor; + /** @internal */ + _rootElement: null | HTMLElement; + /** @internal */ + _editorState: EditorState; + /** @internal */ + _pendingEditorState: null | EditorState; + /** @internal */ + _compositionKey: null | NodeKey; + /** @internal */ + _deferred: Array<() => void>; + /** @internal */ + _keyToDOMMap: Map; + /** @internal */ + _updates: Array<[() => void, EditorUpdateOptions | undefined]>; + /** @internal */ + _updating: boolean; + /** @internal */ + _listeners: Listeners; + /** @internal */ + _commands: Commands; + /** @internal */ + _nodes: RegisteredNodes; + /** @internal */ + _decorators: Record; + /** @internal */ + _pendingDecorators: null | Record; + /** @internal */ + _config: EditorConfig; + /** @internal */ + _dirtyType: 0 | 1 | 2; + /** @internal */ + _cloneNotNeeded: Set; + /** @internal */ + _dirtyLeaves: Set; + /** @internal */ + _dirtyElements: Map; + /** @internal */ + _normalizedNodes: Set; + /** @internal */ + _updateTags: Set; + /** @internal */ + _observer: null | MutationObserver; + /** @internal */ + _key: string; + /** @internal */ + _onError: ErrorHandler; + /** @internal */ + _htmlConversions: DOMConversionCache; + /** @internal */ + _window: null | Window; + /** @internal */ + _editable: boolean; + /** @internal */ + _blockCursorElement: null | HTMLDivElement; + + /** @internal */ + constructor( + editorState: EditorState, + parentEditor: null | LexicalEditor, + nodes: RegisteredNodes, + config: EditorConfig, + onError: ErrorHandler, + htmlConversions: DOMConversionCache, + editable: boolean, + ) { + this._parentEditor = parentEditor; + // The root element associated with this editor + this._rootElement = null; + // The current editor state + this._editorState = editorState; + // Handling of drafts and updates + this._pendingEditorState = null; + // Used to help co-ordinate selection and events + this._compositionKey = null; + this._deferred = []; + // Used during reconciliation + this._keyToDOMMap = new Map(); + this._updates = []; + this._updating = false; + // Listeners + this._listeners = { + decorator: new Set(), + editable: new Set(), + mutation: new Map(), + root: new Set(), + textcontent: new Set(), + update: new Set(), + }; + // Commands + this._commands = new Map(); + // Editor configuration for theme/context. + this._config = config; + // Mapping of types to their nodes + this._nodes = nodes; + // React node decorators for portals + this._decorators = {}; + this._pendingDecorators = null; + // Used to optimize reconciliation + this._dirtyType = NO_DIRTY_NODES; + this._cloneNotNeeded = new Set(); + this._dirtyLeaves = new Set(); + this._dirtyElements = new Map(); + this._normalizedNodes = new Set(); + this._updateTags = new Set(); + // Handling of DOM mutations + this._observer = null; + // Used for identifying owning editors + this._key = createUID(); + + this._onError = onError; + this._htmlConversions = htmlConversions; + this._editable = editable; + this._headless = parentEditor !== null && parentEditor._headless; + this._window = null; + this._blockCursorElement = null; + } + + /** + * + * @returns true if the editor is currently in "composition" mode due to receiving input + * through an IME, or 3P extension, for example. Returns false otherwise. + */ + isComposing(): boolean { + return this._compositionKey != null; + } + /** + * Registers a listener for Editor update event. Will trigger the provided callback + * each time the editor goes through an update (via {@link LexicalEditor.update}) until the + * teardown function is called. + * + * @returns a teardown function that can be used to cleanup the listener. + */ + registerUpdateListener(listener: UpdateListener): () => void { + const listenerSetOrMap = this._listeners.update; + listenerSetOrMap.add(listener); + return () => { + listenerSetOrMap.delete(listener); + }; + } + /** + * Registers a listener for for when the editor changes between editable and non-editable states. + * Will trigger the provided callback each time the editor transitions between these states until the + * teardown function is called. + * + * @returns a teardown function that can be used to cleanup the listener. + */ + registerEditableListener(listener: EditableListener): () => void { + const listenerSetOrMap = this._listeners.editable; + listenerSetOrMap.add(listener); + return () => { + listenerSetOrMap.delete(listener); + }; + } + /** + * Registers a listener for when the editor's decorator object changes. The decorator object contains + * all DecoratorNode keys -> their decorated value. This is primarily used with external UI frameworks. + * + * Will trigger the provided callback each time the editor transitions between these states until the + * teardown function is called. + * + * @returns a teardown function that can be used to cleanup the listener. + */ + registerDecoratorListener(listener: DecoratorListener): () => void { + const listenerSetOrMap = this._listeners.decorator; + listenerSetOrMap.add(listener); + return () => { + listenerSetOrMap.delete(listener); + }; + } + /** + * Registers a listener for when Lexical commits an update to the DOM and the text content of + * the editor changes from the previous state of the editor. If the text content is the + * same between updates, no notifications to the listeners will happen. + * + * Will trigger the provided callback each time the editor transitions between these states until the + * teardown function is called. + * + * @returns a teardown function that can be used to cleanup the listener. + */ + registerTextContentListener(listener: TextContentListener): () => void { + const listenerSetOrMap = this._listeners.textcontent; + listenerSetOrMap.add(listener); + return () => { + listenerSetOrMap.delete(listener); + }; + } + /** + * Registers a listener for when the editor's root DOM element (the content editable + * Lexical attaches to) changes. This is primarily used to attach event listeners to the root + * element. The root listener function is executed directly upon registration and then on + * any subsequent update. + * + * Will trigger the provided callback each time the editor transitions between these states until the + * teardown function is called. + * + * @returns a teardown function that can be used to cleanup the listener. + */ + registerRootListener(listener: RootListener): () => void { + const listenerSetOrMap = this._listeners.root; + listener(this._rootElement, null); + listenerSetOrMap.add(listener); + return () => { + listener(null, this._rootElement); + listenerSetOrMap.delete(listener); + }; + } + /** + * Registers a listener that will trigger anytime the provided command + * is dispatched, subject to priority. Listeners that run at a higher priority can "intercept" + * commands and prevent them from propagating to other handlers by returning true. + * + * Listeners registered at the same priority level will run deterministically in the order of registration. + * + * @param command - the command that will trigger the callback. + * @param listener - the function that will execute when the command is dispatched. + * @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4 + * @returns a teardown function that can be used to cleanup the listener. + */ + registerCommand

    ( + command: LexicalCommand

    , + listener: CommandListener

    , + priority: CommandListenerPriority, + ): () => void { + if (priority === undefined) { + invariant(false, 'Listener for type "command" requires a "priority".'); + } + + const commandsMap = this._commands; + + if (!commandsMap.has(command)) { + commandsMap.set(command, [ + new Set(), + new Set(), + new Set(), + new Set(), + new Set(), + ]); + } + + const listenersInPriorityOrder = commandsMap.get(command); + + if (listenersInPriorityOrder === undefined) { + invariant( + false, + 'registerCommand: Command %s not found in command map', + String(command), + ); + } + + const listeners = listenersInPriorityOrder[priority]; + listeners.add(listener as CommandListener); + return () => { + listeners.delete(listener as CommandListener); + + if ( + listenersInPriorityOrder.every( + (listenersSet) => listenersSet.size === 0, + ) + ) { + commandsMap.delete(command); + } + }; + } + + /** + * Registers a listener that will run when a Lexical node of the provided class is + * mutated. The listener will receive a list of nodes along with the type of mutation + * that was performed on each: created, destroyed, or updated. + * + * One common use case for this is to attach DOM event listeners to the underlying DOM nodes as Lexical nodes are created. + * {@link LexicalEditor.getElementByKey} can be used for this. + * + * If any existing nodes are in the DOM, and skipInitialization is not true, the listener + * will be called immediately with an updateTag of 'registerMutationListener' where all + * nodes have the 'created' NodeMutation. This can be controlled with the skipInitialization option + * (default is currently true for backwards compatibility in 0.16.x but will change to false in 0.17.0). + * + * @param klass - The class of the node that you want to listen to mutations on. + * @param listener - The logic you want to run when the node is mutated. + * @param options - see {@link MutationListenerOptions} + * @returns a teardown function that can be used to cleanup the listener. + */ + registerMutationListener( + klass: Klass, + listener: MutationListener, + options?: MutationListenerOptions, + ): () => void { + const klassToMutate = this.resolveRegisteredNodeAfterReplacements( + this.getRegisteredNode(klass), + ).klass; + const mutations = this._listeners.mutation; + mutations.set(listener, klassToMutate); + const skipInitialization = options && options.skipInitialization; + if ( + !(skipInitialization === undefined + ? DEFAULT_SKIP_INITIALIZATION + : skipInitialization) + ) { + this.initializeMutationListener(listener, klassToMutate); + } + + return () => { + mutations.delete(listener); + }; + } + + /** @internal */ + private getRegisteredNode(klass: Klass): RegisteredNode { + const registeredNode = this._nodes.get(klass.getType()); + + if (registeredNode === undefined) { + invariant( + false, + 'Node %s has not been registered. Ensure node has been passed to createEditor.', + klass.name, + ); + } + + return registeredNode; + } + + /** @internal */ + private resolveRegisteredNodeAfterReplacements( + registeredNode: RegisteredNode, + ): RegisteredNode { + while (registeredNode.replaceWithKlass) { + registeredNode = this.getRegisteredNode(registeredNode.replaceWithKlass); + } + return registeredNode; + } + + /** @internal */ + private initializeMutationListener( + listener: MutationListener, + klass: Klass, + ): void { + const prevEditorState = this._editorState; + const nodeMap = getCachedTypeToNodeMap(prevEditorState).get( + klass.getType(), + ); + if (!nodeMap) { + return; + } + const nodeMutationMap = new Map(); + for (const k of nodeMap.keys()) { + nodeMutationMap.set(k, 'created'); + } + if (nodeMutationMap.size > 0) { + listener(nodeMutationMap, { + dirtyLeaves: new Set(), + prevEditorState, + updateTags: new Set(['registerMutationListener']), + }); + } + } + + /** @internal */ + private registerNodeTransformToKlass( + klass: Klass, + listener: Transform, + ): RegisteredNode { + const registeredNode = this.getRegisteredNode(klass); + registeredNode.transforms.add(listener as Transform); + + return registeredNode; + } + + /** + * Registers a listener that will run when a Lexical node of the provided class is + * marked dirty during an update. The listener will continue to run as long as the node + * is marked dirty. There are no guarantees around the order of transform execution! + * + * Watch out for infinite loops. See [Node Transforms](https://lexical.dev/docs/concepts/transforms) + * @param klass - The class of the node that you want to run transforms on. + * @param listener - The logic you want to run when the node is updated. + * @returns a teardown function that can be used to cleanup the listener. + */ + registerNodeTransform( + klass: Klass, + listener: Transform, + ): () => void { + const registeredNode = this.registerNodeTransformToKlass(klass, listener); + const registeredNodes = [registeredNode]; + + const replaceWithKlass = registeredNode.replaceWithKlass; + if (replaceWithKlass != null) { + const registeredReplaceWithNode = this.registerNodeTransformToKlass( + replaceWithKlass, + listener as Transform, + ); + registeredNodes.push(registeredReplaceWithNode); + } + + markAllNodesAsDirty(this, klass.getType()); + return () => { + registeredNodes.forEach((node) => + node.transforms.delete(listener as Transform), + ); + }; + } + + /** + * Used to assert that a certain node is registered, usually by plugins to ensure nodes that they + * depend on have been registered. + * @returns True if the editor has registered the provided node type, false otherwise. + */ + hasNode>(node: T): boolean { + return this._nodes.has(node.getType()); + } + + /** + * Used to assert that certain nodes are registered, usually by plugins to ensure nodes that they + * depend on have been registered. + * @returns True if the editor has registered all of the provided node types, false otherwise. + */ + hasNodes>(nodes: Array): boolean { + return nodes.every(this.hasNode.bind(this)); + } + + /** + * Dispatches a command of the specified type with the specified payload. + * This triggers all command listeners (set by {@link LexicalEditor.registerCommand}) + * for this type, passing them the provided payload. + * @param type - the type of command listeners to trigger. + * @param payload - the data to pass as an argument to the command listeners. + */ + dispatchCommand>( + type: TCommand, + payload: CommandPayloadType, + ): boolean { + return dispatchCommand(this, type, payload); + } + + /** + * Gets a map of all decorators in the editor. + * @returns A mapping of call decorator keys to their decorated content + */ + getDecorators(): Record { + return this._decorators as Record; + } + + /** + * + * @returns the current root element of the editor. If you want to register + * an event listener, do it via {@link LexicalEditor.registerRootListener}, since + * this reference may not be stable. + */ + getRootElement(): null | HTMLElement { + return this._rootElement; + } + + /** + * Gets the key of the editor + * @returns The editor key + */ + getKey(): string { + return this._key; + } + + /** + * Imperatively set the root contenteditable element that Lexical listens + * for events on. + */ + setRootElement(nextRootElement: null | HTMLElement): void { + const prevRootElement = this._rootElement; + + if (nextRootElement !== prevRootElement) { + const classNames = getCachedClassNameArray(this._config.theme, 'root'); + const pendingEditorState = this._pendingEditorState || this._editorState; + this._rootElement = nextRootElement; + resetEditor(this, prevRootElement, nextRootElement, pendingEditorState); + + if (prevRootElement !== null) { + // TODO: remove this flag once we no longer use UEv2 internally + if (!this._config.disableEvents) { + removeRootElementEvents(prevRootElement); + } + if (classNames != null) { + prevRootElement.classList.remove(...classNames); + } + } + + if (nextRootElement !== null) { + const windowObj = getDefaultView(nextRootElement); + const style = nextRootElement.style; + style.userSelect = 'text'; + style.whiteSpace = 'pre-wrap'; + style.wordBreak = 'break-word'; + nextRootElement.setAttribute('data-lexical-editor', 'true'); + this._window = windowObj; + this._dirtyType = FULL_RECONCILE; + initMutationObserver(this); + + this._updateTags.add('history-merge'); + + $commitPendingUpdates(this); + + // TODO: remove this flag once we no longer use UEv2 internally + if (!this._config.disableEvents) { + addRootElementEvents(nextRootElement, this); + } + if (classNames != null) { + nextRootElement.classList.add(...classNames); + } + } else { + // If content editable is unmounted we'll reset editor state back to original + // (or pending) editor state since there will be no reconciliation + this._editorState = pendingEditorState; + this._pendingEditorState = null; + this._window = null; + } + + triggerListeners('root', this, false, nextRootElement, prevRootElement); + } + } + + /** + * Gets the underlying HTMLElement associated with the LexicalNode for the given key. + * @returns the HTMLElement rendered by the LexicalNode associated with the key. + * @param key - the key of the LexicalNode. + */ + getElementByKey(key: NodeKey): HTMLElement | null { + return this._keyToDOMMap.get(key) || null; + } + + /** + * Gets the active editor state. + * @returns The editor state + */ + getEditorState(): EditorState { + return this._editorState; + } + + /** + * Imperatively set the EditorState. Triggers reconciliation like an update. + * @param editorState - the state to set the editor + * @param options - options for the update. + */ + setEditorState(editorState: EditorState, options?: EditorSetOptions): void { + if (editorState.isEmpty()) { + invariant( + false, + "setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.", + ); + } + + $flushRootMutations(this); + const pendingEditorState = this._pendingEditorState; + const tags = this._updateTags; + const tag = options !== undefined ? options.tag : null; + + if (pendingEditorState !== null && !pendingEditorState.isEmpty()) { + if (tag != null) { + tags.add(tag); + } + + $commitPendingUpdates(this); + } + + this._pendingEditorState = editorState; + this._dirtyType = FULL_RECONCILE; + this._dirtyElements.set('root', false); + this._compositionKey = null; + + if (tag != null) { + tags.add(tag); + } + + $commitPendingUpdates(this); + } + + /** + * Parses a SerializedEditorState (usually produced by {@link EditorState.toJSON}) and returns + * and EditorState object that can be, for example, passed to {@link LexicalEditor.setEditorState}. Typically, + * deserialization from JSON stored in a database uses this method. + * @param maybeStringifiedEditorState + * @param updateFn + * @returns + */ + parseEditorState( + maybeStringifiedEditorState: string | SerializedEditorState, + updateFn?: () => void, + ): EditorState { + const serializedEditorState = + typeof maybeStringifiedEditorState === 'string' + ? JSON.parse(maybeStringifiedEditorState) + : maybeStringifiedEditorState; + return parseEditorState(serializedEditorState, this, updateFn); + } + + /** + * Executes a read of the editor's state, with the + * editor context available (useful for exporting and read-only DOM + * operations). Much like update, but prevents any mutation of the + * editor's state. Any pending updates will be flushed immediately before + * the read. + * @param callbackFn - A function that has access to read-only editor state. + */ + read(callbackFn: () => T): T { + $commitPendingUpdates(this); + return this.getEditorState().read(callbackFn, {editor: this}); + } + + /** + * Executes an update to the editor state. The updateFn callback is the ONLY place + * where Lexical editor state can be safely mutated. + * @param updateFn - A function that has access to writable editor state. + * @param options - A bag of options to control the behavior of the update. + * @param options.onUpdate - A function to run once the update is complete. + * Useful for synchronizing updates in some cases. + * @param options.skipTransforms - Setting this to true will suppress all node + * transforms for this update cycle. + * @param options.tag - A tag to identify this update, in an update listener, for instance. + * Some tags are reserved by the core and control update behavior in different ways. + * @param options.discrete - If true, prevents this update from being batched, forcing it to + * run synchronously. + */ + update(updateFn: () => void, options?: EditorUpdateOptions): void { + updateEditor(this, updateFn, options); + } + + /** + * Focuses the editor + * @param callbackFn - A function to run after the editor is focused. + * @param options - A bag of options + * @param options.defaultSelection - Where to move selection when the editor is + * focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd. + */ + focus(callbackFn?: () => void, options: EditorFocusOptions = {}): void { + const rootElement = this._rootElement; + + if (rootElement !== null) { + // This ensures that iOS does not trigger caps lock upon focus + rootElement.setAttribute('autocapitalize', 'off'); + updateEditor( + this, + () => { + const selection = $getSelection(); + const root = $getRoot(); + + if (selection !== null) { + // Marking the selection dirty will force the selection back to it + selection.dirty = true; + } else if (root.getChildrenSize() !== 0) { + if (options.defaultSelection === 'rootStart') { + root.selectStart(); + } else { + root.selectEnd(); + } + } + }, + { + onUpdate: () => { + rootElement.removeAttribute('autocapitalize'); + if (callbackFn) { + callbackFn(); + } + }, + tag: 'focus', + }, + ); + // In the case where onUpdate doesn't fire (due to the focus update not + // occuring). + if (this._pendingEditorState === null) { + rootElement.removeAttribute('autocapitalize'); + } + } + } + + /** + * Removes focus from the editor. + */ + blur(): void { + const rootElement = this._rootElement; + + if (rootElement !== null) { + rootElement.blur(); + } + + const domSelection = getDOMSelection(this._window); + + if (domSelection !== null) { + domSelection.removeAllRanges(); + } + } + /** + * Returns true if the editor is editable, false otherwise. + * @returns True if the editor is editable, false otherwise. + */ + isEditable(): boolean { + return this._editable; + } + /** + * Sets the editable property of the editor. When false, the + * editor will not listen for user events on the underling contenteditable. + * @param editable - the value to set the editable mode to. + */ + setEditable(editable: boolean): void { + if (this._editable !== editable) { + this._editable = editable; + triggerListeners('editable', this, true, editable); + } + } + /** + * Returns a JSON-serializable javascript object NOT a JSON string. + * You still must call JSON.stringify (or something else) to turn the + * state into a string you can transfer over the wire and store in a database. + * + * See {@link LexicalNode.exportJSON} + * + * @returns A JSON-serializable javascript object + */ + toJSON(): SerializedEditor { + return { + editorState: this._editorState.toJSON(), + }; + } +} + +LexicalEditor.version = '0.17.1'; diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts b/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts new file mode 100644 index 000000000..f84d2e40a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalEditorState.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor} from './LexicalEditor'; +import type {LexicalNode, NodeMap, SerializedLexicalNode} from './LexicalNode'; +import type {BaseSelection} from './LexicalSelection'; +import type {SerializedElementNode} from './nodes/LexicalElementNode'; +import type {SerializedRootNode} from './nodes/LexicalRootNode'; + +import invariant from 'lexical/shared/invariant'; + +import {readEditorState} from './LexicalUpdates'; +import {$getRoot} from './LexicalUtils'; +import {$isElementNode} from './nodes/LexicalElementNode'; +import {$createRootNode} from './nodes/LexicalRootNode'; + +export interface SerializedEditorState< + T extends SerializedLexicalNode = SerializedLexicalNode, +> { + root: SerializedRootNode; +} + +export function editorStateHasDirtySelection( + editorState: EditorState, + editor: LexicalEditor, +): boolean { + const currentSelection = editor.getEditorState()._selection; + + const pendingSelection = editorState._selection; + + // Check if we need to update because of changes in selection + if (pendingSelection !== null) { + if (pendingSelection.dirty || !pendingSelection.is(currentSelection)) { + return true; + } + } else if (currentSelection !== null) { + return true; + } + + return false; +} + +export function cloneEditorState(current: EditorState): EditorState { + return new EditorState(new Map(current._nodeMap)); +} + +export function createEmptyEditorState(): EditorState { + return new EditorState(new Map([['root', $createRootNode()]])); +} + +function exportNodeToJSON( + node: LexicalNode, +): SerializedNode { + const serializedNode = node.exportJSON(); + const nodeClass = node.constructor; + + if (serializedNode.type !== nodeClass.getType()) { + invariant( + false, + 'LexicalNode: Node %s does not match the serialized type. Check if .exportJSON() is implemented and it is returning the correct type.', + nodeClass.name, + ); + } + + if ($isElementNode(node)) { + const serializedChildren = (serializedNode as SerializedElementNode) + .children; + if (!Array.isArray(serializedChildren)) { + invariant( + false, + 'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.', + nodeClass.name, + ); + } + + const children = node.getChildren(); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const serializedChildNode = exportNodeToJSON(child); + serializedChildren.push(serializedChildNode); + } + } + + // @ts-expect-error + return serializedNode; +} + +export interface EditorStateReadOptions { + editor?: LexicalEditor | null; +} + +export class EditorState { + _nodeMap: NodeMap; + _selection: null | BaseSelection; + _flushSync: boolean; + _readOnly: boolean; + + constructor(nodeMap: NodeMap, selection?: null | BaseSelection) { + this._nodeMap = nodeMap; + this._selection = selection || null; + this._flushSync = false; + this._readOnly = false; + } + + isEmpty(): boolean { + return this._nodeMap.size === 1 && this._selection === null; + } + + read(callbackFn: () => V, options?: EditorStateReadOptions): V { + return readEditorState( + (options && options.editor) || null, + this, + callbackFn, + ); + } + + clone(selection?: null | BaseSelection): EditorState { + const editorState = new EditorState( + this._nodeMap, + selection === undefined ? this._selection : selection, + ); + editorState._readOnly = true; + + return editorState; + } + toJSON(): SerializedEditorState { + return readEditorState(null, this, () => ({ + root: exportNodeToJSON($getRoot()), + })); + } +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts new file mode 100644 index 000000000..5fd671a76 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -0,0 +1,1385 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor} from './LexicalEditor'; +import type {NodeKey} from './LexicalNode'; +import type {ElementNode} from './nodes/LexicalElementNode'; +import type {TextNode} from './nodes/LexicalTextNode'; + +import { + CAN_USE_BEFORE_INPUT, + IS_ANDROID_CHROME, + IS_APPLE_WEBKIT, + IS_FIREFOX, + IS_IOS, + IS_SAFARI, +} from 'lexical/shared/environment'; +import invariant from 'lexical/shared/invariant'; + +import { + $getPreviousSelection, + $getRoot, + $getSelection, + $isElementNode, + $isNodeSelection, + $isRangeSelection, + $isRootNode, + $isTextNode, + $setCompositionKey, + BLUR_COMMAND, + CLICK_COMMAND, + CONTROLLED_TEXT_INSERTION_COMMAND, + COPY_COMMAND, + CUT_COMMAND, + DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, + DRAGEND_COMMAND, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + FOCUS_COMMAND, + FORMAT_TEXT_COMMAND, + INSERT_LINE_BREAK_COMMAND, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_DOWN_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_SPACE_COMMAND, + KEY_TAB_COMMAND, + MOVE_TO_END, + MOVE_TO_START, + ParagraphNode, + PASTE_COMMAND, + REDO_COMMAND, + REMOVE_TEXT_COMMAND, + SELECTION_CHANGE_COMMAND, + UNDO_COMMAND, +} from '.'; +import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; +import { + COMPOSITION_START_CHAR, + DOM_ELEMENT_TYPE, + DOM_TEXT_TYPE, + DOUBLE_LINE_BREAK, + IS_ALL_FORMATTING, +} from './LexicalConstants'; +import { + $internalCreateRangeSelection, + RangeSelection, +} from './LexicalSelection'; +import {getActiveEditor, updateEditor} from './LexicalUpdates'; +import { + $flushMutations, + $getNodeByKey, + $isSelectionCapturedInDecorator, + $isTokenOrSegmented, + $setSelection, + $shouldInsertTextAfterOrBeforeTextNode, + $updateSelectedTextFromDOM, + $updateTextNodeFromDOMContent, + dispatchCommand, + doesContainGrapheme, + getAnchorTextFromDOM, + getDOMSelection, + getDOMTextNode, + getEditorPropertyFromDOMNode, + getEditorsToPropagate, + getNearestEditorFromDOMNode, + getWindow, + isBackspace, + isBold, + isCopy, + isCut, + isDelete, + isDeleteBackward, + isDeleteForward, + isDeleteLineBackward, + isDeleteLineForward, + isDeleteWordBackward, + isDeleteWordForward, + isEscape, + isFirefoxClipboardEvents, + isItalic, + isLexicalEditor, + isLineBreak, + isModifier, + isMoveBackward, + isMoveDown, + isMoveForward, + isMoveToEnd, + isMoveToStart, + isMoveUp, + isOpenLineBreak, + isParagraph, + isRedo, + isSelectAll, + isSelectionWithinEditor, + isSpace, + isTab, + isUnderline, + isUndo, +} from './LexicalUtils'; + +type RootElementRemoveHandles = Array<() => void>; +type RootElementEvents = Array< + [ + string, + Record | ((event: Event, editor: LexicalEditor) => void), + ] +>; +const PASS_THROUGH_COMMAND = Object.freeze({}); +const ANDROID_COMPOSITION_LATENCY = 30; +const rootElementEvents: RootElementEvents = [ + ['keydown', onKeyDown], + ['pointerdown', onPointerDown], + ['compositionstart', onCompositionStart], + ['compositionend', onCompositionEnd], + ['input', onInput], + ['click', onClick], + ['cut', PASS_THROUGH_COMMAND], + ['copy', PASS_THROUGH_COMMAND], + ['dragstart', PASS_THROUGH_COMMAND], + ['dragover', PASS_THROUGH_COMMAND], + ['dragend', PASS_THROUGH_COMMAND], + ['paste', PASS_THROUGH_COMMAND], + ['focus', PASS_THROUGH_COMMAND], + ['blur', PASS_THROUGH_COMMAND], + ['drop', PASS_THROUGH_COMMAND], +]; + +if (CAN_USE_BEFORE_INPUT) { + rootElementEvents.push([ + 'beforeinput', + (event, editor) => onBeforeInput(event as InputEvent, editor), + ]); +} + +let lastKeyDownTimeStamp = 0; +let lastKeyCode: null | string = null; +let lastBeforeInputInsertTextTimeStamp = 0; +let unprocessedBeforeInputData: null | string = null; +const rootElementsRegistered = new WeakMap(); +let isSelectionChangeFromDOMUpdate = false; +let isSelectionChangeFromMouseDown = false; +let isInsertLineBreak = false; +let isFirefoxEndingComposition = false; +let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [ + 0, + '', + 0, + 'root', + 0, +]; + +// This function is used to determine if Lexical should attempt to override +// the default browser behavior for insertion of text and use its own internal +// heuristics. This is an extremely important function, and makes much of Lexical +// work as intended between different browsers and across word, line and character +// boundary/formats. It also is important for text replacement, node schemas and +// composition mechanics. + +function $shouldPreventDefaultAndInsertText( + selection: RangeSelection, + domTargetRange: null | StaticRange, + text: string, + timeStamp: number, + isBeforeInput: boolean, +): boolean { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const editor = getActiveEditor(); + const domSelection = getDOMSelection(editor._window); + const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null; + const anchorKey = anchor.key; + const backingAnchorElement = editor.getElementByKey(anchorKey); + const textLength = text.length; + + return ( + anchorKey !== focus.key || + // If we're working with a non-text node. + !$isTextNode(anchorNode) || + // If we are replacing a range with a single character or grapheme, and not composing. + (((!isBeforeInput && + (!CAN_USE_BEFORE_INPUT || + // We check to see if there has been + // a recent beforeinput event for "textInput". If there has been one in the last + // 50ms then we proceed as normal. However, if there is not, then this is likely + // a dangling `input` event caused by execCommand('insertText'). + lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) || + (anchorNode.isDirty() && textLength < 2) || + doesContainGrapheme(text)) && + anchor.offset !== focus.offset && + !anchorNode.isComposing()) || + // Any non standard text node. + $isTokenOrSegmented(anchorNode) || + // If the text length is more than a single character and we're either + // dealing with this in "beforeinput" or where the node has already recently + // been changed (thus is dirty). + (anchorNode.isDirty() && textLength > 1) || + // If the DOM selection element is not the same as the backing node during beforeinput. + ((isBeforeInput || !CAN_USE_BEFORE_INPUT) && + backingAnchorElement !== null && + !anchorNode.isComposing() && + domAnchorNode !== getDOMTextNode(backingAnchorElement)) || + // If TargetRange is not the same as the DOM selection; browser trying to edit random parts + // of the editor. + (domSelection !== null && + domTargetRange !== null && + (!domTargetRange.collapsed || + domTargetRange.startContainer !== domSelection.anchorNode || + domTargetRange.startOffset !== domSelection.anchorOffset)) || + // Check if we're changing from bold to italics, or some other format. + anchorNode.getFormat() !== selection.format || + anchorNode.getStyle() !== selection.style || + // One last set of heuristics to check against. + $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode) + ); +} + +function shouldSkipSelectionChange( + domNode: null | Node, + offset: number, +): boolean { + return ( + domNode !== null && + domNode.nodeValue !== null && + domNode.nodeType === DOM_TEXT_TYPE && + offset !== 0 && + offset !== domNode.nodeValue.length + ); +} + +function onSelectionChange( + domSelection: Selection, + editor: LexicalEditor, + isActive: boolean, +): void { + const { + anchorNode: anchorDOM, + anchorOffset, + focusNode: focusDOM, + focusOffset, + } = domSelection; + if (isSelectionChangeFromDOMUpdate) { + isSelectionChangeFromDOMUpdate = false; + + // If native DOM selection is on a DOM element, then + // we should continue as usual, as Lexical's selection + // may have normalized to a better child. If the DOM + // element is a text node, we can safely apply this + // optimization and skip the selection change entirely. + // We also need to check if the offset is at the boundary, + // because in this case, we might need to normalize to a + // sibling instead. + if ( + shouldSkipSelectionChange(anchorDOM, anchorOffset) && + shouldSkipSelectionChange(focusDOM, focusOffset) + ) { + return; + } + } + updateEditor(editor, () => { + // Non-active editor don't need any extra logic for selection, it only needs update + // to reconcile selection (set it to null) to ensure that only one editor has non-null selection. + if (!isActive) { + $setSelection(null); + return; + } + + if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) { + return; + } + + const selection = $getSelection(); + + // Update the selection format + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const anchorNode = anchor.getNode(); + + if (selection.isCollapsed()) { + // Badly interpreted range selection when collapsed - #1482 + if ( + domSelection.type === 'Range' && + domSelection.anchorNode === domSelection.focusNode + ) { + selection.dirty = true; + } + + // If we have marked a collapsed selection format, and we're + // within the given time range – then attempt to use that format + // instead of getting the format from the anchor node. + const windowEvent = getWindow(editor).event; + const currentTimeStamp = windowEvent + ? windowEvent.timeStamp + : performance.now(); + const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] = + collapsedSelectionFormat; + + const root = $getRoot(); + const isRootTextContentEmpty = + editor.isComposing() === false && root.getTextContent() === ''; + + if ( + currentTimeStamp < timeStamp + 200 && + anchor.offset === lastOffset && + anchor.key === lastKey + ) { + selection.format = lastFormat; + selection.style = lastStyle; + } else { + if (anchor.type === 'text') { + invariant( + $isTextNode(anchorNode), + 'Point.getNode() must return TextNode when type is text', + ); + selection.format = anchorNode.getFormat(); + selection.style = anchorNode.getStyle(); + } else if (anchor.type === 'element' && !isRootTextContentEmpty) { + const lastNode = anchor.getNode(); + selection.style = ''; + if ( + lastNode instanceof ParagraphNode && + lastNode.getChildrenSize() === 0 + ) { + selection.format = lastNode.getTextFormat(); + selection.style = lastNode.getTextStyle(); + } else { + selection.format = 0; + } + } + } + } else { + const anchorKey = anchor.key; + const focus = selection.focus; + const focusKey = focus.key; + const nodes = selection.getNodes(); + const nodesLength = nodes.length; + const isBackward = selection.isBackward(); + const startOffset = isBackward ? focusOffset : anchorOffset; + const endOffset = isBackward ? anchorOffset : focusOffset; + const startKey = isBackward ? focusKey : anchorKey; + const endKey = isBackward ? anchorKey : focusKey; + let combinedFormat = IS_ALL_FORMATTING; + let hasTextNodes = false; + for (let i = 0; i < nodesLength; i++) { + const node = nodes[i]; + const textContentSize = node.getTextContentSize(); + if ( + $isTextNode(node) && + textContentSize !== 0 && + // Exclude empty text nodes at boundaries resulting from user's selection + !( + (i === 0 && + node.__key === startKey && + startOffset === textContentSize) || + (i === nodesLength - 1 && + node.__key === endKey && + endOffset === 0) + ) + ) { + // TODO: what about style? + hasTextNodes = true; + combinedFormat &= node.getFormat(); + if (combinedFormat === 0) { + break; + } + } + } + + selection.format = hasTextNodes ? combinedFormat : 0; + } + } + + dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined); + }); +} + +// This is a work-around is mainly Chrome specific bug where if you select +// the contents of an empty block, you cannot easily unselect anything. +// This results in a tiny selection box that looks buggy/broken. This can +// also help other browsers when selection might "appear" lost, when it +// really isn't. +function onClick(event: PointerEvent, editor: LexicalEditor): void { + updateEditor(editor, () => { + const selection = $getSelection(); + const domSelection = getDOMSelection(editor._window); + const lastSelection = $getPreviousSelection(); + + if (domSelection) { + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const anchorNode = anchor.getNode(); + + if ( + anchor.type === 'element' && + anchor.offset === 0 && + selection.isCollapsed() && + !$isRootNode(anchorNode) && + $getRoot().getChildrenSize() === 1 && + anchorNode.getTopLevelElementOrThrow().isEmpty() && + lastSelection !== null && + selection.is(lastSelection) + ) { + domSelection.removeAllRanges(); + selection.dirty = true; + } else if (event.detail === 3 && !selection.isCollapsed()) { + // Tripple click causing selection to overflow into the nearest element. In that + // case visually it looks like a single element content is selected, focus node + // is actually at the beginning of the next element (if present) and any manipulations + // with selection (formatting) are affecting second element as well + const focus = selection.focus; + const focusNode = focus.getNode(); + if (anchorNode !== focusNode) { + if ($isElementNode(anchorNode)) { + anchorNode.select(0); + } else { + anchorNode.getParentOrThrow().select(0); + } + } + } + } else if (event.pointerType === 'touch') { + // This is used to update the selection on touch devices when the user clicks on text after a + // node selection. See isSelectionChangeFromMouseDown for the inverse + const domAnchorNode = domSelection.anchorNode; + if (domAnchorNode !== null) { + const nodeType = domAnchorNode.nodeType; + // If the user is attempting to click selection back onto text, then + // we should attempt create a range selection. + // When we click on an empty paragraph node or the end of a paragraph that ends + // with an image/poll, the nodeType will be ELEMENT_NODE + if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) { + const newSelection = $internalCreateRangeSelection( + lastSelection, + domSelection, + editor, + event, + ); + $setSelection(newSelection); + } + } + } + } + + dispatchCommand(editor, CLICK_COMMAND, event); + }); +} + +function onPointerDown(event: PointerEvent, editor: LexicalEditor) { + // TODO implement text drag & drop + const target = event.target; + const pointerType = event.pointerType; + if (target instanceof Node && pointerType !== 'touch') { + updateEditor(editor, () => { + // Drag & drop should not recompute selection until mouse up; otherwise the initially + // selected content is lost. + if (!$isSelectionCapturedInDecorator(target)) { + isSelectionChangeFromMouseDown = true; + } + }); + } +} + +function getTargetRange(event: InputEvent): null | StaticRange { + if (!event.getTargetRanges) { + return null; + } + const targetRanges = event.getTargetRanges(); + if (targetRanges.length === 0) { + return null; + } + return targetRanges[0]; +} + +function $canRemoveText( + anchorNode: TextNode | ElementNode, + focusNode: TextNode | ElementNode, +): boolean { + return ( + anchorNode !== focusNode || + $isElementNode(anchorNode) || + $isElementNode(focusNode) || + !anchorNode.isToken() || + !focusNode.isToken() + ); +} + +function isPossiblyAndroidKeyPress(timeStamp: number): boolean { + return ( + lastKeyCode === 'MediaLast' && + timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY + ); +} + +function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { + const inputType = event.inputType; + const targetRange = getTargetRange(event); + + // We let the browser do its own thing for composition. + if ( + inputType === 'deleteCompositionText' || + // If we're pasting in FF, we shouldn't get this event + // as the `paste` event should have triggered, unless the + // user has dom.event.clipboardevents.enabled disabled in + // about:config. In that case, we need to process the + // pasted content in the DOM mutation phase. + (IS_FIREFOX && isFirefoxClipboardEvents(editor)) + ) { + return; + } else if (inputType === 'insertCompositionText') { + return; + } + + updateEditor(editor, () => { + const selection = $getSelection(); + + if (inputType === 'deleteContentBackward') { + if (selection === null) { + // Use previous selection + const prevSelection = $getPreviousSelection(); + + if (!$isRangeSelection(prevSelection)) { + return; + } + + $setSelection(prevSelection.clone()); + } + + if ($isRangeSelection(selection)) { + const isSelectionAnchorSameAsFocus = + selection.anchor.key === selection.focus.key; + + if ( + isPossiblyAndroidKeyPress(event.timeStamp) && + editor.isComposing() && + isSelectionAnchorSameAsFocus + ) { + $setCompositionKey(null); + lastKeyDownTimeStamp = 0; + // Fixes an Android bug where selection flickers when backspacing + setTimeout(() => { + updateEditor(editor, () => { + $setCompositionKey(null); + }); + }, ANDROID_COMPOSITION_LATENCY); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + anchorNode.markDirty(); + selection.format = anchorNode.getFormat(); + invariant( + $isTextNode(anchorNode), + 'Anchor node must be a TextNode', + ); + selection.style = anchorNode.getStyle(); + } + } else { + $setCompositionKey(null); + event.preventDefault(); + // Chromium Android at the moment seems to ignore the preventDefault + // on 'deleteContentBackward' and still deletes the content. Which leads + // to multiple deletions. So we let the browser handle the deletion in this case. + const selectedNodeText = selection.anchor.getNode().getTextContent(); + const hasSelectedAllTextInNode = + selection.anchor.offset === 0 && + selection.focus.offset === selectedNodeText.length; + const shouldLetBrowserHandleDelete = + IS_ANDROID_CHROME && + isSelectionAnchorSameAsFocus && + !hasSelectedAllTextInNode; + if (!shouldLetBrowserHandleDelete) { + dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true); + } + } + return; + } + } + + if (!$isRangeSelection(selection)) { + return; + } + + const data = event.data; + + // This represents the case when two beforeinput events are triggered at the same time (without a + // full event loop ending at input). This happens with MacOS with the default keyboard settings, + // a combination of autocorrection + autocapitalization. + // Having Lexical run everything in controlled mode would fix the issue without additional code + // but this would kill the massive performance win from the most common typing event. + // Alternatively, when this happens we can prematurely update our EditorState based on the DOM + // content, a job that would usually be the input event's responsibility. + if (unprocessedBeforeInputData !== null) { + $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData); + } + + if ( + (!selection.dirty || unprocessedBeforeInputData !== null) && + selection.isCollapsed() && + !$isRootNode(selection.anchor.getNode()) && + targetRange !== null + ) { + selection.applyDOMRange(targetRange); + } + + unprocessedBeforeInputData = null; + + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + + if (inputType === 'insertText' || inputType === 'insertTranspose') { + if (data === '\n') { + event.preventDefault(); + dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false); + } else if (data === DOUBLE_LINE_BREAK) { + event.preventDefault(); + dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined); + } else if (data == null && event.dataTransfer) { + // Gets around a Safari text replacement bug. + const text = event.dataTransfer.getData('text/plain'); + event.preventDefault(); + selection.insertRawText(text); + } else if ( + data != null && + $shouldPreventDefaultAndInsertText( + selection, + targetRange, + data, + event.timeStamp, + true, + ) + ) { + event.preventDefault(); + dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data); + } else { + unprocessedBeforeInputData = data; + } + lastBeforeInputInsertTextTimeStamp = event.timeStamp; + return; + } + + // Prevent the browser from carrying out + // the input event, so we can control the + // output. + event.preventDefault(); + + switch (inputType) { + case 'insertFromYank': + case 'insertFromDrop': + case 'insertReplacementText': { + dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event); + break; + } + + case 'insertFromComposition': { + // This is the end of composition + $setCompositionKey(null); + dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event); + break; + } + + case 'insertLineBreak': { + // Used for Android + $setCompositionKey(null); + dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false); + break; + } + + case 'insertParagraph': { + // Used for Android + $setCompositionKey(null); + + // Safari does not provide the type "insertLineBreak". + // So instead, we need to infer it from the keyboard event. + // We do not apply this logic to iOS to allow newline auto-capitalization + // work without creating linebreaks when pressing Enter + if (isInsertLineBreak && !IS_IOS) { + isInsertLineBreak = false; + dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false); + } else { + dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined); + } + + break; + } + + case 'insertFromPaste': + case 'insertFromPasteAsQuotation': { + dispatchCommand(editor, PASTE_COMMAND, event); + break; + } + + case 'deleteByComposition': { + if ($canRemoveText(anchorNode, focusNode)) { + dispatchCommand(editor, REMOVE_TEXT_COMMAND, event); + } + + break; + } + + case 'deleteByDrag': + case 'deleteByCut': { + dispatchCommand(editor, REMOVE_TEXT_COMMAND, event); + break; + } + + case 'deleteContent': { + dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false); + break; + } + + case 'deleteWordBackward': { + dispatchCommand(editor, DELETE_WORD_COMMAND, true); + break; + } + + case 'deleteWordForward': { + dispatchCommand(editor, DELETE_WORD_COMMAND, false); + break; + } + + case 'deleteHardLineBackward': + case 'deleteSoftLineBackward': { + dispatchCommand(editor, DELETE_LINE_COMMAND, true); + break; + } + + case 'deleteContentForward': + case 'deleteHardLineForward': + case 'deleteSoftLineForward': { + dispatchCommand(editor, DELETE_LINE_COMMAND, false); + break; + } + + case 'formatStrikeThrough': { + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough'); + break; + } + + case 'formatBold': { + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); + break; + } + + case 'formatItalic': { + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic'); + break; + } + + case 'formatUnderline': { + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline'); + break; + } + + case 'historyUndo': { + dispatchCommand(editor, UNDO_COMMAND, undefined); + break; + } + + case 'historyRedo': { + dispatchCommand(editor, REDO_COMMAND, undefined); + break; + } + + default: + // NO-OP + } + }); +} + +function onInput(event: InputEvent, editor: LexicalEditor): void { + // We don't want the onInput to bubble, in the case of nested editors. + event.stopPropagation(); + updateEditor(editor, () => { + const selection = $getSelection(); + const data = event.data; + const targetRange = getTargetRange(event); + + if ( + data != null && + $isRangeSelection(selection) && + $shouldPreventDefaultAndInsertText( + selection, + targetRange, + data, + event.timeStamp, + false, + ) + ) { + // Given we're over-riding the default behavior, we will need + // to ensure to disable composition before dispatching the + // insertText command for when changing the sequence for FF. + if (isFirefoxEndingComposition) { + $onCompositionEndImpl(editor, data); + isFirefoxEndingComposition = false; + } + const anchor = selection.anchor; + const anchorNode = anchor.getNode(); + const domSelection = getDOMSelection(editor._window); + if (domSelection === null) { + return; + } + const isBackward = selection.isBackward(); + const startOffset = isBackward + ? selection.anchor.offset + : selection.focus.offset; + const endOffset = isBackward + ? selection.focus.offset + : selection.anchor.offset; + // If the content is the same as inserted, then don't dispatch an insertion. + // Given onInput doesn't take the current selection (it uses the previous) + // we can compare that against what the DOM currently says. + if ( + !CAN_USE_BEFORE_INPUT || + selection.isCollapsed() || + !$isTextNode(anchorNode) || + domSelection.anchorNode === null || + anchorNode.getTextContent().slice(0, startOffset) + + data + + anchorNode.getTextContent().slice(startOffset + endOffset) !== + getAnchorTextFromDOM(domSelection.anchorNode) + ) { + dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data); + } + + const textLength = data.length; + + // Another hack for FF, as it's possible that the IME is still + // open, even though compositionend has already fired (sigh). + if ( + IS_FIREFOX && + textLength > 1 && + event.inputType === 'insertCompositionText' && + !editor.isComposing() + ) { + selection.anchor.offset -= textLength; + } + + // This ensures consistency on Android. + if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) { + lastKeyDownTimeStamp = 0; + $setCompositionKey(null); + } + } else { + const characterData = data !== null ? data : undefined; + $updateSelectedTextFromDOM(false, editor, characterData); + + // onInput always fires after onCompositionEnd for FF. + if (isFirefoxEndingComposition) { + $onCompositionEndImpl(editor, data || undefined); + isFirefoxEndingComposition = false; + } + } + + // Also flush any other mutations that might have occurred + // since the change. + $flushMutations(); + }); + unprocessedBeforeInputData = null; +} + +function onCompositionStart( + event: CompositionEvent, + editor: LexicalEditor, +): void { + updateEditor(editor, () => { + const selection = $getSelection(); + + if ($isRangeSelection(selection) && !editor.isComposing()) { + const anchor = selection.anchor; + const node = selection.anchor.getNode(); + $setCompositionKey(anchor.key); + + if ( + // If it has been 30ms since the last keydown, then we should + // apply the empty space heuristic. We can't do this for Safari, + // as the keydown fires after composition start. + event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY || + // FF has issues around composing multibyte characters, so we also + // need to invoke the empty space heuristic below. + anchor.type === 'element' || + !selection.isCollapsed() || + node.getFormat() !== selection.format || + ($isTextNode(node) && node.getStyle() !== selection.style) + ) { + // We insert a zero width character, ready for the composition + // to get inserted into the new node we create. If + // we don't do this, Safari will fail on us because + // there is no text node matching the selection. + dispatchCommand( + editor, + CONTROLLED_TEXT_INSERTION_COMMAND, + COMPOSITION_START_CHAR, + ); + } + } + }); +} + +function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void { + const compositionKey = editor._compositionKey; + $setCompositionKey(null); + + // Handle termination of composition. + if (compositionKey !== null && data != null) { + // Composition can sometimes move to an adjacent DOM node when backspacing. + // So check for the empty case. + if (data === '') { + const node = $getNodeByKey(compositionKey); + const textNode = getDOMTextNode(editor.getElementByKey(compositionKey)); + + if ( + textNode !== null && + textNode.nodeValue !== null && + $isTextNode(node) + ) { + $updateTextNodeFromDOMContent( + node, + textNode.nodeValue, + null, + null, + true, + ); + } + + return; + } + + // Composition can sometimes be that of a new line. In which case, we need to + // handle that accordingly. + if (data[data.length - 1] === '\n') { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + // If the last character is a line break, we also need to insert + // a line break. + const focus = selection.focus; + selection.anchor.set(focus.key, focus.offset, focus.type); + dispatchCommand(editor, KEY_ENTER_COMMAND, null); + return; + } + } + } + + $updateSelectedTextFromDOM(true, editor, data); +} + +function onCompositionEnd( + event: CompositionEvent, + editor: LexicalEditor, +): void { + // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit, + // fire onInput before onCompositionEnd. To ensure the sequence works + // like Chrome/Webkit we use the isFirefoxEndingComposition flag to + // defer handling of onCompositionEnd in Firefox till we have processed + // the logic in onInput. + if (IS_FIREFOX) { + isFirefoxEndingComposition = true; + } else { + updateEditor(editor, () => { + $onCompositionEndImpl(editor, event.data); + }); + } +} + +function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void { + lastKeyDownTimeStamp = event.timeStamp; + lastKeyCode = event.key; + if (editor.isComposing()) { + return; + } + + const {key, shiftKey, ctrlKey, metaKey, altKey} = event; + + if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) { + return; + } + + if (key == null) { + return; + } + + if (isMoveForward(key, ctrlKey, altKey, metaKey)) { + dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event); + } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) { + dispatchCommand(editor, MOVE_TO_END, event); + } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) { + dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event); + } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) { + dispatchCommand(editor, MOVE_TO_START, event); + } else if (isMoveUp(key, ctrlKey, metaKey)) { + dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event); + } else if (isMoveDown(key, ctrlKey, metaKey)) { + dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event); + } else if (isLineBreak(key, shiftKey)) { + isInsertLineBreak = true; + dispatchCommand(editor, KEY_ENTER_COMMAND, event); + } else if (isSpace(key)) { + dispatchCommand(editor, KEY_SPACE_COMMAND, event); + } else if (isOpenLineBreak(key, ctrlKey)) { + event.preventDefault(); + isInsertLineBreak = true; + dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true); + } else if (isParagraph(key, shiftKey)) { + isInsertLineBreak = false; + dispatchCommand(editor, KEY_ENTER_COMMAND, event); + } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) { + if (isBackspace(key)) { + dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event); + } else { + event.preventDefault(); + dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true); + } + } else if (isEscape(key)) { + dispatchCommand(editor, KEY_ESCAPE_COMMAND, event); + } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) { + if (isDelete(key)) { + dispatchCommand(editor, KEY_DELETE_COMMAND, event); + } else { + event.preventDefault(); + dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false); + } + } else if (isDeleteWordBackward(key, altKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, DELETE_WORD_COMMAND, true); + } else if (isDeleteWordForward(key, altKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, DELETE_WORD_COMMAND, false); + } else if (isDeleteLineBackward(key, metaKey)) { + event.preventDefault(); + dispatchCommand(editor, DELETE_LINE_COMMAND, true); + } else if (isDeleteLineForward(key, metaKey)) { + event.preventDefault(); + dispatchCommand(editor, DELETE_LINE_COMMAND, false); + } else if (isBold(key, altKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold'); + } else if (isUnderline(key, altKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline'); + } else if (isItalic(key, altKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic'); + } else if (isTab(key, altKey, ctrlKey, metaKey)) { + dispatchCommand(editor, KEY_TAB_COMMAND, event); + } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, UNDO_COMMAND, undefined); + } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, REDO_COMMAND, undefined); + } else { + const prevSelection = editor._editorState._selection; + if ($isNodeSelection(prevSelection)) { + if (isCopy(key, shiftKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, COPY_COMMAND, event); + } else if (isCut(key, shiftKey, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, CUT_COMMAND, event); + } else if (isSelectAll(key, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, SELECT_ALL_COMMAND, event); + } + // FF does it well (no need to override behavior) + } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) { + event.preventDefault(); + dispatchCommand(editor, SELECT_ALL_COMMAND, event); + } + } + + if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) { + dispatchCommand(editor, KEY_MODIFIER_COMMAND, event); + } +} + +function getRootElementRemoveHandles( + rootElement: HTMLElement, +): RootElementRemoveHandles { + // @ts-expect-error: internal field + let eventHandles = rootElement.__lexicalEventHandles; + + if (eventHandles === undefined) { + eventHandles = []; + // @ts-expect-error: internal field + rootElement.__lexicalEventHandles = eventHandles; + } + + return eventHandles; +} + +// Mapping root editors to their active nested editors, contains nested editors +// mapping only, so if root editor is selected map will have no reference to free up memory +const activeNestedEditorsMap: Map = new Map(); + +function onDocumentSelectionChange(event: Event): void { + const target = event.target as null | Element | Document; + const targetWindow = + target == null + ? null + : target.nodeType === 9 + ? (target as Document).defaultView + : (target as Element).ownerDocument.defaultView; + const domSelection = getDOMSelection(targetWindow); + if (domSelection === null) { + return; + } + const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode); + if (nextActiveEditor === null) { + return; + } + + if (isSelectionChangeFromMouseDown) { + isSelectionChangeFromMouseDown = false; + updateEditor(nextActiveEditor, () => { + const lastSelection = $getPreviousSelection(); + const domAnchorNode = domSelection.anchorNode; + if (domAnchorNode === null) { + return; + } + const nodeType = domAnchorNode.nodeType; + // If the user is attempting to click selection back onto text, then + // we should attempt create a range selection. + // When we click on an empty paragraph node or the end of a paragraph that ends + // with an image/poll, the nodeType will be ELEMENT_NODE + if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) { + return; + } + const newSelection = $internalCreateRangeSelection( + lastSelection, + domSelection, + nextActiveEditor, + event, + ); + $setSelection(newSelection); + }); + } + + // When editor receives selection change event, we're checking if + // it has any sibling editors (within same parent editor) that were active + // before, and trigger selection change on it to nullify selection. + const editors = getEditorsToPropagate(nextActiveEditor); + const rootEditor = editors[editors.length - 1]; + const rootEditorKey = rootEditor._key; + const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey); + const prevActiveEditor = activeNestedEditor || rootEditor; + + if (prevActiveEditor !== nextActiveEditor) { + onSelectionChange(domSelection, prevActiveEditor, false); + } + + onSelectionChange(domSelection, nextActiveEditor, true); + + // If newly selected editor is nested, then add it to the map, clean map otherwise + if (nextActiveEditor !== rootEditor) { + activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor); + } else if (activeNestedEditor) { + activeNestedEditorsMap.delete(rootEditorKey); + } +} + +function stopLexicalPropagation(event: Event): void { + // We attach a special property to ensure the same event doesn't re-fire + // for parent editors. + // @ts-ignore + event._lexicalHandled = true; +} + +function hasStoppedLexicalPropagation(event: Event): boolean { + // @ts-ignore + const stopped = event._lexicalHandled === true; + return stopped; +} + +export type EventHandler = (event: Event, editor: LexicalEditor) => void; + +export function addRootElementEvents( + rootElement: HTMLElement, + editor: LexicalEditor, +): void { + // We only want to have a single global selectionchange event handler, shared + // between all editor instances. + const doc = rootElement.ownerDocument; + const documentRootElementsCount = rootElementsRegistered.get(doc); + if ( + documentRootElementsCount === undefined || + documentRootElementsCount < 1 + ) { + doc.addEventListener('selectionchange', onDocumentSelectionChange); + } + rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1); + + // @ts-expect-error: internal field + rootElement.__lexicalEditor = editor; + const removeHandles = getRootElementRemoveHandles(rootElement); + + for (let i = 0; i < rootElementEvents.length; i++) { + const [eventName, onEvent] = rootElementEvents[i]; + const eventHandler = + typeof onEvent === 'function' + ? (event: Event) => { + if (hasStoppedLexicalPropagation(event)) { + return; + } + stopLexicalPropagation(event); + if (editor.isEditable() || eventName === 'click') { + onEvent(event, editor); + } + } + : (event: Event) => { + if (hasStoppedLexicalPropagation(event)) { + return; + } + stopLexicalPropagation(event); + const isEditable = editor.isEditable(); + switch (eventName) { + case 'cut': + return ( + isEditable && + dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent) + ); + + case 'copy': + return dispatchCommand( + editor, + COPY_COMMAND, + event as ClipboardEvent, + ); + + case 'paste': + return ( + isEditable && + dispatchCommand( + editor, + PASTE_COMMAND, + event as ClipboardEvent, + ) + ); + + case 'dragstart': + return ( + isEditable && + dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent) + ); + + case 'dragover': + return ( + isEditable && + dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent) + ); + + case 'dragend': + return ( + isEditable && + dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent) + ); + + case 'focus': + return ( + isEditable && + dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent) + ); + + case 'blur': { + return ( + isEditable && + dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent) + ); + } + + case 'drop': + return ( + isEditable && + dispatchCommand(editor, DROP_COMMAND, event as DragEvent) + ); + } + }; + rootElement.addEventListener(eventName, eventHandler); + removeHandles.push(() => { + rootElement.removeEventListener(eventName, eventHandler); + }); + } +} + +export function removeRootElementEvents(rootElement: HTMLElement): void { + const doc = rootElement.ownerDocument; + const documentRootElementsCount = rootElementsRegistered.get(doc); + invariant( + documentRootElementsCount !== undefined, + 'Root element not registered', + ); + + // We only want to have a single global selectionchange event handler, shared + // between all editor instances. + const newCount = documentRootElementsCount - 1; + invariant(newCount >= 0, 'Root element count less than 0'); + rootElementsRegistered.set(doc, newCount); + if (newCount === 0) { + doc.removeEventListener('selectionchange', onDocumentSelectionChange); + } + + const editor = getEditorPropertyFromDOMNode(rootElement); + + if (isLexicalEditor(editor)) { + cleanActiveNestedEditorsMap(editor); + // @ts-expect-error: internal field + rootElement.__lexicalEditor = null; + } else if (editor) { + invariant( + false, + 'Attempted to remove event handlers from a node that does not belong to this build of Lexical', + ); + } + + const removeHandles = getRootElementRemoveHandles(rootElement); + + for (let i = 0; i < removeHandles.length; i++) { + removeHandles[i](); + } + + // @ts-expect-error: internal field + rootElement.__lexicalEventHandles = []; +} + +function cleanActiveNestedEditorsMap(editor: LexicalEditor) { + if (editor._parentEditor !== null) { + // For nested editor cleanup map if this editor was marked as active + const editors = getEditorsToPropagate(editor); + const rootEditor = editors[editors.length - 1]; + const rootEditorKey = rootEditor._key; + + if (activeNestedEditorsMap.get(rootEditorKey) === editor) { + activeNestedEditorsMap.delete(rootEditorKey); + } + } else { + // For top-level editors cleanup map + activeNestedEditorsMap.delete(editor._key); + } +} + +export function markSelectionChangeFromDOMUpdate(): void { + isSelectionChangeFromDOMUpdate = true; +} + +export function markCollapsedSelectionFormat( + format: number, + style: string, + offset: number, + key: NodeKey, + timeStamp: number, +): void { + collapsedSelectionFormat = [format, style, offset, key, timeStamp]; +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalGC.ts b/resources/js/wysiwyg/lexical/core/LexicalGC.ts new file mode 100644 index 000000000..9405ae6cf --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalGC.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {ElementNode} from '.'; +import type {LexicalEditor} from './LexicalEditor'; +import type {EditorState} from './LexicalEditorState'; +import type {NodeKey, NodeMap} from './LexicalNode'; + +import {$isElementNode} from '.'; +import {cloneDecorators} from './LexicalUtils'; + +export function $garbageCollectDetachedDecorators( + editor: LexicalEditor, + pendingEditorState: EditorState, +): void { + const currentDecorators = editor._decorators; + const pendingDecorators = editor._pendingDecorators; + let decorators = pendingDecorators || currentDecorators; + const nodeMap = pendingEditorState._nodeMap; + let key; + + for (key in decorators) { + if (!nodeMap.has(key)) { + if (decorators === currentDecorators) { + decorators = cloneDecorators(editor); + } + + delete decorators[key]; + } + } +} + +type IntentionallyMarkedAsDirtyElement = boolean; + +function $garbageCollectDetachedDeepChildNodes( + node: ElementNode, + parentKey: NodeKey, + prevNodeMap: NodeMap, + nodeMap: NodeMap, + nodeMapDelete: Array, + dirtyNodes: Map, +): void { + let child = node.getFirstChild(); + + while (child !== null) { + const childKey = child.__key; + // TODO Revise condition below, redundant? LexicalNode already cleans up children when moving Nodes + if (child.__parent === parentKey) { + if ($isElementNode(child)) { + $garbageCollectDetachedDeepChildNodes( + child, + childKey, + prevNodeMap, + nodeMap, + nodeMapDelete, + dirtyNodes, + ); + } + + // If we have created a node and it was dereferenced, then also + // remove it from out dirty nodes Set. + if (!prevNodeMap.has(childKey)) { + dirtyNodes.delete(childKey); + } + nodeMapDelete.push(childKey); + } + child = child.getNextSibling(); + } +} + +export function $garbageCollectDetachedNodes( + prevEditorState: EditorState, + editorState: EditorState, + dirtyLeaves: Set, + dirtyElements: Map, +): void { + const prevNodeMap = prevEditorState._nodeMap; + const nodeMap = editorState._nodeMap; + // Store dirtyElements in a queue for later deletion; deleting dirty subtrees too early will + // hinder accessing .__next on child nodes + const nodeMapDelete: Array = []; + + for (const [nodeKey] of dirtyElements) { + const node = nodeMap.get(nodeKey); + if (node !== undefined) { + // Garbage collect node and its children if they exist + if (!node.isAttached()) { + if ($isElementNode(node)) { + $garbageCollectDetachedDeepChildNodes( + node, + nodeKey, + prevNodeMap, + nodeMap, + nodeMapDelete, + dirtyElements, + ); + } + // If we have created a node and it was dereferenced, then also + // remove it from out dirty nodes Set. + if (!prevNodeMap.has(nodeKey)) { + dirtyElements.delete(nodeKey); + } + nodeMapDelete.push(nodeKey); + } + } + } + for (const nodeKey of nodeMapDelete) { + nodeMap.delete(nodeKey); + } + + for (const nodeKey of dirtyLeaves) { + const node = nodeMap.get(nodeKey); + if (node !== undefined && !node.isAttached()) { + if (!prevNodeMap.has(nodeKey)) { + dirtyLeaves.delete(nodeKey); + } + nodeMap.delete(nodeKey); + } + } +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts new file mode 100644 index 000000000..56f364501 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts @@ -0,0 +1,322 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {TextNode} from '.'; +import type {LexicalEditor} from './LexicalEditor'; +import type {BaseSelection} from './LexicalSelection'; + +import {IS_FIREFOX} from 'lexical/shared/environment'; + +import { + $getSelection, + $isDecoratorNode, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setSelection, +} from '.'; +import {DOM_TEXT_TYPE} from './LexicalConstants'; +import {updateEditor} from './LexicalUpdates'; +import { + $getNearestNodeFromDOMNode, + $getNodeFromDOMNode, + $updateTextNodeFromDOMContent, + getDOMSelection, + getWindow, + internalGetRoot, + isFirefoxClipboardEvents, +} from './LexicalUtils'; +// The time between a text entry event and the mutation observer firing. +const TEXT_MUTATION_VARIANCE = 100; + +let isProcessingMutations = false; +let lastTextEntryTimeStamp = 0; + +export function getIsProcessingMutations(): boolean { + return isProcessingMutations; +} + +function updateTimeStamp(event: Event) { + lastTextEntryTimeStamp = event.timeStamp; +} + +function initTextEntryListener(editor: LexicalEditor): void { + if (lastTextEntryTimeStamp === 0) { + getWindow(editor).addEventListener('textInput', updateTimeStamp, true); + } +} + +function isManagedLineBreak( + dom: Node, + target: Node, + editor: LexicalEditor, +): boolean { + return ( + // @ts-expect-error: internal field + target.__lexicalLineBreak === dom || + // @ts-ignore We intentionally add this to the Node. + dom[`__lexicalKey_${editor._key}`] !== undefined + ); +} + +function getLastSelection(editor: LexicalEditor): null | BaseSelection { + return editor.getEditorState().read(() => { + const selection = $getSelection(); + return selection !== null ? selection.clone() : null; + }); +} + +function $handleTextMutation( + target: Text, + node: TextNode, + editor: LexicalEditor, +): void { + const domSelection = getDOMSelection(editor._window); + let anchorOffset = null; + let focusOffset = null; + + if (domSelection !== null && domSelection.anchorNode === target) { + anchorOffset = domSelection.anchorOffset; + focusOffset = domSelection.focusOffset; + } + + const text = target.nodeValue; + if (text !== null) { + $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false); + } +} + +function shouldUpdateTextNodeFromMutation( + selection: null | BaseSelection, + targetDOM: Node, + targetNode: TextNode, +): boolean { + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + if ( + anchorNode.is(targetNode) && + selection.format !== anchorNode.getFormat() + ) { + return false; + } + } + return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); +} + +export function $flushMutations( + editor: LexicalEditor, + mutations: Array, + observer: MutationObserver, +): void { + isProcessingMutations = true; + const shouldFlushTextMutations = + performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE; + + try { + updateEditor(editor, () => { + const selection = $getSelection() || getLastSelection(editor); + const badDOMTargets = new Map(); + const rootElement = editor.getRootElement(); + // We use the current editor state, as that reflects what is + // actually "on screen". + const currentEditorState = editor._editorState; + const blockCursorElement = editor._blockCursorElement; + let shouldRevertSelection = false; + let possibleTextForFirefoxPaste = ''; + + for (let i = 0; i < mutations.length; i++) { + const mutation = mutations[i]; + const type = mutation.type; + const targetDOM = mutation.target; + let targetNode = $getNearestNodeFromDOMNode( + targetDOM, + currentEditorState, + ); + + if ( + (targetNode === null && targetDOM !== rootElement) || + $isDecoratorNode(targetNode) + ) { + continue; + } + + if (type === 'characterData') { + // Text mutations are deferred and passed to mutation listeners to be + // processed outside of the Lexical engine. + if ( + shouldFlushTextMutations && + $isTextNode(targetNode) && + shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode) + ) { + $handleTextMutation( + // nodeType === DOM_TEXT_TYPE is a Text DOM node + targetDOM as Text, + targetNode, + editor, + ); + } + } else if (type === 'childList') { + shouldRevertSelection = true; + // We attempt to "undo" any changes that have occurred outside + // of Lexical. We want Lexical's editor state to be source of truth. + // To the user, these will look like no-ops. + const addedDOMs = mutation.addedNodes; + + for (let s = 0; s < addedDOMs.length; s++) { + const addedDOM = addedDOMs[s]; + const node = $getNodeFromDOMNode(addedDOM); + const parentDOM = addedDOM.parentNode; + + if ( + parentDOM != null && + addedDOM !== blockCursorElement && + node === null && + (addedDOM.nodeName !== 'BR' || + !isManagedLineBreak(addedDOM, parentDOM, editor)) + ) { + if (IS_FIREFOX) { + const possibleText = + (addedDOM as HTMLElement).innerText || addedDOM.nodeValue; + + if (possibleText) { + possibleTextForFirefoxPaste += possibleText; + } + } + + parentDOM.removeChild(addedDOM); + } + } + + const removedDOMs = mutation.removedNodes; + const removedDOMsLength = removedDOMs.length; + + if (removedDOMsLength > 0) { + let unremovedBRs = 0; + + for (let s = 0; s < removedDOMsLength; s++) { + const removedDOM = removedDOMs[s]; + + if ( + (removedDOM.nodeName === 'BR' && + isManagedLineBreak(removedDOM, targetDOM, editor)) || + blockCursorElement === removedDOM + ) { + targetDOM.appendChild(removedDOM); + unremovedBRs++; + } + } + + if (removedDOMsLength !== unremovedBRs) { + if (targetDOM === rootElement) { + targetNode = internalGetRoot(currentEditorState); + } + + badDOMTargets.set(targetDOM, targetNode); + } + } + } + } + + // Now we process each of the unique target nodes, attempting + // to restore their contents back to the source of truth, which + // is Lexical's "current" editor state. This is basically like + // an internal revert on the DOM. + if (badDOMTargets.size > 0) { + for (const [targetDOM, targetNode] of badDOMTargets) { + if ($isElementNode(targetNode)) { + const childKeys = targetNode.getChildrenKeys(); + let currentDOM = targetDOM.firstChild; + + for (let s = 0; s < childKeys.length; s++) { + const key = childKeys[s]; + const correctDOM = editor.getElementByKey(key); + + if (correctDOM === null) { + continue; + } + + if (currentDOM == null) { + targetDOM.appendChild(correctDOM); + currentDOM = correctDOM; + } else if (currentDOM !== correctDOM) { + targetDOM.replaceChild(correctDOM, currentDOM); + } + + currentDOM = currentDOM.nextSibling; + } + } else if ($isTextNode(targetNode)) { + targetNode.markDirty(); + } + } + } + + // Capture all the mutations made during this function. This + // also prevents us having to process them on the next cycle + // of onMutation, as these mutations were made by us. + const records = observer.takeRecords(); + + // Check for any random auto-added
    elements, and remove them. + // These get added by the browser when we undo the above mutations + // and this can lead to a broken UI. + if (records.length > 0) { + for (let i = 0; i < records.length; i++) { + const record = records[i]; + const addedNodes = record.addedNodes; + const target = record.target; + + for (let s = 0; s < addedNodes.length; s++) { + const addedDOM = addedNodes[s]; + const parentDOM = addedDOM.parentNode; + + if ( + parentDOM != null && + addedDOM.nodeName === 'BR' && + !isManagedLineBreak(addedDOM, target, editor) + ) { + parentDOM.removeChild(addedDOM); + } + } + } + + // Clear any of those removal mutations + observer.takeRecords(); + } + + if (selection !== null) { + if (shouldRevertSelection) { + selection.dirty = true; + $setSelection(selection); + } + + if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) { + selection.insertRawText(possibleTextForFirefoxPaste); + } + } + }); + } finally { + isProcessingMutations = false; + } +} + +export function $flushRootMutations(editor: LexicalEditor): void { + const observer = editor._observer; + + if (observer !== null) { + const mutations = observer.takeRecords(); + $flushMutations(editor, mutations, observer); + } +} + +export function initMutationObserver(editor: LexicalEditor): void { + initTextEntryListener(editor); + editor._observer = new MutationObserver( + (mutations: Array, observer: MutationObserver) => { + $flushMutations(editor, mutations, observer); + }, + ); +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts new file mode 100644 index 000000000..c6bc2e642 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -0,0 +1,1221 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/* eslint-disable no-constant-condition */ +import type {EditorConfig, LexicalEditor} from './LexicalEditor'; +import type {BaseSelection, RangeSelection} from './LexicalSelection'; +import type {Klass, KlassConstructor} from 'lexical'; + +import invariant from 'lexical/shared/invariant'; + +import { + $createParagraphNode, + $isDecoratorNode, + $isElementNode, + $isRootNode, + $isTextNode, + type DecoratorNode, + ElementNode, +} from '.'; +import { + $getSelection, + $isNodeSelection, + $isRangeSelection, + $moveSelectionPointToEnd, + $updateElementSelectionOnCreateDeleteNode, + moveSelectionPointToSibling, +} from './LexicalSelection'; +import { + errorOnReadOnly, + getActiveEditor, + getActiveEditorState, +} from './LexicalUpdates'; +import { + $cloneWithProperties, + $getCompositionKey, + $getNodeByKey, + $isRootOrShadowRoot, + $maybeMoveChildrenSelectionToParent, + $setCompositionKey, + $setNodeKey, + $setSelection, + errorOnInsertTextNodeOnRoot, + internalMarkNodeAsDirty, + removeFromParent, +} from './LexicalUtils'; + +export type NodeMap = Map; + +export type SerializedLexicalNode = { + type: string; + version: number; +}; + +export function $removeNode( + nodeToRemove: LexicalNode, + restoreSelection: boolean, + preserveEmptyParent?: boolean, +): void { + errorOnReadOnly(); + const key = nodeToRemove.__key; + const parent = nodeToRemove.getParent(); + if (parent === null) { + return; + } + const selection = $maybeMoveChildrenSelectionToParent(nodeToRemove); + let selectionMoved = false; + if ($isRangeSelection(selection) && restoreSelection) { + const anchor = selection.anchor; + const focus = selection.focus; + if (anchor.key === key) { + moveSelectionPointToSibling( + anchor, + nodeToRemove, + parent, + nodeToRemove.getPreviousSibling(), + nodeToRemove.getNextSibling(), + ); + selectionMoved = true; + } + if (focus.key === key) { + moveSelectionPointToSibling( + focus, + nodeToRemove, + parent, + nodeToRemove.getPreviousSibling(), + nodeToRemove.getNextSibling(), + ); + selectionMoved = true; + } + } else if ( + $isNodeSelection(selection) && + restoreSelection && + nodeToRemove.isSelected() + ) { + nodeToRemove.selectPrevious(); + } + + if ($isRangeSelection(selection) && restoreSelection && !selectionMoved) { + // Doing this is O(n) so lets avoid it unless we need to do it + const index = nodeToRemove.getIndexWithinParent(); + removeFromParent(nodeToRemove); + $updateElementSelectionOnCreateDeleteNode(selection, parent, index, -1); + } else { + removeFromParent(nodeToRemove); + } + + if ( + !preserveEmptyParent && + !$isRootOrShadowRoot(parent) && + !parent.canBeEmpty() && + parent.isEmpty() + ) { + $removeNode(parent, restoreSelection); + } + if (restoreSelection && $isRootNode(parent) && parent.isEmpty()) { + parent.selectEnd(); + } +} + +export type DOMConversion = { + conversion: DOMConversionFn; + priority?: 0 | 1 | 2 | 3 | 4; +}; + +export type DOMConversionFn = ( + element: T, +) => DOMConversionOutput | null; + +export type DOMChildConversion = ( + lexicalNode: LexicalNode, + parentLexicalNode: LexicalNode | null | undefined, +) => LexicalNode | null | undefined; + +export type DOMConversionMap = Record< + NodeName, + (node: T) => DOMConversion | null +>; +type NodeName = string; + +export type DOMConversionOutput = { + after?: (childLexicalNodes: Array) => Array; + forChild?: DOMChildConversion; + node: null | LexicalNode | Array; +}; + +export type DOMExportOutputMap = Map< + Klass, + (editor: LexicalEditor, target: LexicalNode) => DOMExportOutput +>; + +export type DOMExportOutput = { + after?: ( + generatedElement: HTMLElement | Text | null | undefined, + ) => HTMLElement | Text | null | undefined; + element: HTMLElement | Text | null; +}; + +export type NodeKey = string; + +export class LexicalNode { + // Allow us to look up the type including static props + ['constructor']!: KlassConstructor; + /** @internal */ + __type: string; + /** @internal */ + //@ts-ignore We set the key in the constructor. + __key: string; + /** @internal */ + __parent: null | NodeKey; + /** @internal */ + __prev: null | NodeKey; + /** @internal */ + __next: null | NodeKey; + + // Flow doesn't support abstract classes unfortunately, so we can't _force_ + // subclasses of Node to implement statics. All subclasses of Node should have + // a static getType and clone method though. We define getType and clone here so we can call it + // on any Node, and we throw this error by default since the subclass should provide + // their own implementation. + /** + * Returns the string type of this node. Every node must + * implement this and it MUST BE UNIQUE amongst nodes registered + * on the editor. + * + */ + static getType(): string { + invariant( + false, + 'LexicalNode: Node %s does not implement .getType().', + this.name, + ); + } + + /** + * Clones this node, creating a new node with a different key + * and adding it to the EditorState (but not attaching it anywhere!). All nodes must + * implement this method. + * + */ + static clone(_data: unknown): LexicalNode { + invariant( + false, + 'LexicalNode: Node %s does not implement .clone().', + this.name, + ); + } + + /** + * Perform any state updates on the clone of prevNode that are not already + * handled by the constructor call in the static clone method. If you have + * state to update in your clone that is not handled directly by the + * constructor, it is advisable to override this method but it is required + * to include a call to `super.afterCloneFrom(prevNode)` in your + * implementation. This is only intended to be called by + * {@link $cloneWithProperties} function or via a super call. + * + * @example + * ```ts + * class ClassesTextNode extends TextNode { + * // Not shown: static getType, static importJSON, exportJSON, createDOM, updateDOM + * __classes = new Set(); + * static clone(node: ClassesTextNode): ClassesTextNode { + * // The inherited TextNode constructor is used here, so + * // classes is not set by this method. + * return new ClassesTextNode(node.__text, node.__key); + * } + * afterCloneFrom(node: this): void { + * // This calls TextNode.afterCloneFrom and LexicalNode.afterCloneFrom + * // for necessary state updates + * super.afterCloneFrom(node); + * this.__addClasses(node.__classes); + * } + * // This method is a private implementation detail, it is not + * // suitable for the public API because it does not call getWritable + * __addClasses(classNames: Iterable): this { + * for (const className of classNames) { + * this.__classes.add(className); + * } + * return this; + * } + * addClass(...classNames: string[]): this { + * return this.getWritable().__addClasses(classNames); + * } + * removeClass(...classNames: string[]): this { + * const node = this.getWritable(); + * for (const className of classNames) { + * this.__classes.delete(className); + * } + * return this; + * } + * getClasses(): Set { + * return this.getLatest().__classes; + * } + * } + * ``` + * + */ + afterCloneFrom(prevNode: this) { + this.__parent = prevNode.__parent; + this.__next = prevNode.__next; + this.__prev = prevNode.__prev; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static importDOM?: () => DOMConversionMap | null; + + constructor(key?: NodeKey) { + this.__type = this.constructor.getType(); + this.__parent = null; + this.__prev = null; + this.__next = null; + $setNodeKey(this, key); + + if (__DEV__) { + if (this.__type !== 'root') { + errorOnReadOnly(); + errorOnTypeKlassMismatch(this.__type, this.constructor); + } + } + } + // Getters and Traversers + + /** + * Returns the string type of this node. + */ + getType(): string { + return this.__type; + } + + isInline(): boolean { + invariant( + false, + 'LexicalNode: Node %s does not implement .isInline().', + this.constructor.name, + ); + } + + /** + * Returns true if there is a path between this node and the RootNode, false otherwise. + * This is a way of determining if the node is "attached" EditorState. Unattached nodes + * won't be reconciled and will ultimatelt be cleaned up by the Lexical GC. + */ + isAttached(): boolean { + let nodeKey: string | null = this.__key; + while (nodeKey !== null) { + if (nodeKey === 'root') { + return true; + } + + const node: LexicalNode | null = $getNodeByKey(nodeKey); + + if (node === null) { + break; + } + nodeKey = node.__parent; + } + return false; + } + + /** + * Returns true if this node is contained within the provided Selection., false otherwise. + * Relies on the algorithms implemented in {@link BaseSelection.getNodes} to determine + * what's included. + * + * @param selection - The selection that we want to determine if the node is in. + */ + isSelected(selection?: null | BaseSelection): boolean { + const targetSelection = selection || $getSelection(); + if (targetSelection == null) { + return false; + } + + const isSelected = targetSelection + .getNodes() + .some((n) => n.__key === this.__key); + + if ($isTextNode(this)) { + return isSelected; + } + // For inline images inside of element nodes. + // Without this change the image will be selected if the cursor is before or after it. + const isElementRangeSelection = + $isRangeSelection(targetSelection) && + targetSelection.anchor.type === 'element' && + targetSelection.focus.type === 'element'; + + if (isElementRangeSelection) { + if (targetSelection.isCollapsed()) { + return false; + } + + const parentNode = this.getParent(); + if ($isDecoratorNode(this) && this.isInline() && parentNode) { + const firstPoint = targetSelection.isBackward() + ? targetSelection.focus + : targetSelection.anchor; + const firstElement = firstPoint.getNode() as ElementNode; + if ( + firstPoint.offset === firstElement.getChildrenSize() && + firstElement.is(parentNode) && + firstElement.getLastChildOrThrow().is(this) + ) { + return false; + } + } + } + return isSelected; + } + + /** + * Returns this nodes key. + */ + getKey(): NodeKey { + // Key is stable between copies + return this.__key; + } + + /** + * Returns the zero-based index of this node within the parent. + */ + getIndexWithinParent(): number { + const parent = this.getParent(); + if (parent === null) { + return -1; + } + let node = parent.getFirstChild(); + let index = 0; + while (node !== null) { + if (this.is(node)) { + return index; + } + index++; + node = node.getNextSibling(); + } + return -1; + } + + /** + * Returns the parent of this node, or null if none is found. + */ + getParent(): T | null { + const parent = this.getLatest().__parent; + if (parent === null) { + return null; + } + return $getNodeByKey(parent); + } + + /** + * Returns the parent of this node, or throws if none is found. + */ + getParentOrThrow(): T { + const parent = this.getParent(); + if (parent === null) { + invariant(false, 'Expected node %s to have a parent.', this.__key); + } + return parent; + } + + /** + * Returns the highest (in the EditorState tree) + * non-root ancestor of this node, or null if none is found. See {@link lexical!$isRootOrShadowRoot} + * for more information on which Elements comprise "roots". + */ + getTopLevelElement(): ElementNode | DecoratorNode | null { + let node: ElementNode | this | null = this; + while (node !== null) { + const parent: ElementNode | null = node.getParent(); + if ($isRootOrShadowRoot(parent)) { + invariant( + $isElementNode(node) || (node === this && $isDecoratorNode(node)), + 'Children of root nodes must be elements or decorators', + ); + return node; + } + node = parent; + } + return null; + } + + /** + * Returns the highest (in the EditorState tree) + * non-root ancestor of this node, or throws if none is found. See {@link lexical!$isRootOrShadowRoot} + * for more information on which Elements comprise "roots". + */ + getTopLevelElementOrThrow(): ElementNode | DecoratorNode { + const parent = this.getTopLevelElement(); + if (parent === null) { + invariant( + false, + 'Expected node %s to have a top parent element.', + this.__key, + ); + } + return parent; + } + + /** + * Returns a list of the every ancestor of this node, + * all the way up to the RootNode. + * + */ + getParents(): Array { + const parents: Array = []; + let node = this.getParent(); + while (node !== null) { + parents.push(node); + node = node.getParent(); + } + return parents; + } + + /** + * Returns a list of the keys of every ancestor of this node, + * all the way up to the RootNode. + * + */ + getParentKeys(): Array { + const parents = []; + let node = this.getParent(); + while (node !== null) { + parents.push(node.__key); + node = node.getParent(); + } + return parents; + } + + /** + * Returns the "previous" siblings - that is, the node that comes + * before this one in the same parent. + * + */ + getPreviousSibling(): T | null { + const self = this.getLatest(); + const prevKey = self.__prev; + return prevKey === null ? null : $getNodeByKey(prevKey); + } + + /** + * Returns the "previous" siblings - that is, the nodes that come between + * this one and the first child of it's parent, inclusive. + * + */ + getPreviousSiblings(): Array { + const siblings: Array = []; + const parent = this.getParent(); + if (parent === null) { + return siblings; + } + let node: null | T = parent.getFirstChild(); + while (node !== null) { + if (node.is(this)) { + break; + } + siblings.push(node); + node = node.getNextSibling(); + } + return siblings; + } + + /** + * Returns the "next" siblings - that is, the node that comes + * after this one in the same parent + * + */ + getNextSibling(): T | null { + const self = this.getLatest(); + const nextKey = self.__next; + return nextKey === null ? null : $getNodeByKey(nextKey); + } + + /** + * Returns all "next" siblings - that is, the nodes that come between this + * one and the last child of it's parent, inclusive. + * + */ + getNextSiblings(): Array { + const siblings: Array = []; + let node: null | T = this.getNextSibling(); + while (node !== null) { + siblings.push(node); + node = node.getNextSibling(); + } + return siblings; + } + + /** + * Returns the closest common ancestor of this node and the provided one or null + * if one cannot be found. + * + * @param node - the other node to find the common ancestor of. + */ + getCommonAncestor( + node: LexicalNode, + ): T | null { + const a = this.getParents(); + const b = node.getParents(); + if ($isElementNode(this)) { + a.unshift(this); + } + if ($isElementNode(node)) { + b.unshift(node); + } + const aLength = a.length; + const bLength = b.length; + if (aLength === 0 || bLength === 0 || a[aLength - 1] !== b[bLength - 1]) { + return null; + } + const bSet = new Set(b); + for (let i = 0; i < aLength; i++) { + const ancestor = a[i] as T; + if (bSet.has(ancestor)) { + return ancestor; + } + } + return null; + } + + /** + * Returns true if the provided node is the exact same one as this node, from Lexical's perspective. + * Always use this instead of referential equality. + * + * @param object - the node to perform the equality comparison on. + */ + is(object: LexicalNode | null | undefined): boolean { + if (object == null) { + return false; + } + return this.__key === object.__key; + } + + /** + * Returns true if this node logical precedes the target node in the editor state. + * + * @param targetNode - the node we're testing to see if it's after this one. + */ + isBefore(targetNode: LexicalNode): boolean { + if (this === targetNode) { + return false; + } + if (targetNode.isParentOf(this)) { + return true; + } + if (this.isParentOf(targetNode)) { + return false; + } + const commonAncestor = this.getCommonAncestor(targetNode); + let indexA = 0; + let indexB = 0; + let node: this | ElementNode | LexicalNode = this; + while (true) { + const parent: ElementNode = node.getParentOrThrow(); + if (parent === commonAncestor) { + indexA = node.getIndexWithinParent(); + break; + } + node = parent; + } + node = targetNode; + while (true) { + const parent: ElementNode = node.getParentOrThrow(); + if (parent === commonAncestor) { + indexB = node.getIndexWithinParent(); + break; + } + node = parent; + } + return indexA < indexB; + } + + /** + * Returns true if this node is the parent of the target node, false otherwise. + * + * @param targetNode - the would-be child node. + */ + isParentOf(targetNode: LexicalNode): boolean { + const key = this.__key; + if (key === targetNode.__key) { + return false; + } + let node: ElementNode | LexicalNode | null = targetNode; + while (node !== null) { + if (node.__key === key) { + return true; + } + node = node.getParent(); + } + return false; + } + + // TO-DO: this function can be simplified a lot + /** + * Returns a list of nodes that are between this node and + * the target node in the EditorState. + * + * @param targetNode - the node that marks the other end of the range of nodes to be returned. + */ + getNodesBetween(targetNode: LexicalNode): Array { + const isBefore = this.isBefore(targetNode); + const nodes = []; + const visited = new Set(); + let node: LexicalNode | this | null = this; + while (true) { + if (node === null) { + break; + } + const key = node.__key; + if (!visited.has(key)) { + visited.add(key); + nodes.push(node); + } + if (node === targetNode) { + break; + } + const child: LexicalNode | null = $isElementNode(node) + ? isBefore + ? node.getFirstChild() + : node.getLastChild() + : null; + if (child !== null) { + node = child; + continue; + } + const nextSibling: LexicalNode | null = isBefore + ? node.getNextSibling() + : node.getPreviousSibling(); + if (nextSibling !== null) { + node = nextSibling; + continue; + } + const parent: LexicalNode | null = node.getParentOrThrow(); + if (!visited.has(parent.__key)) { + nodes.push(parent); + } + if (parent === targetNode) { + break; + } + let parentSibling = null; + let ancestor: LexicalNode | null = parent; + do { + if (ancestor === null) { + invariant(false, 'getNodesBetween: ancestor is null'); + } + parentSibling = isBefore + ? ancestor.getNextSibling() + : ancestor.getPreviousSibling(); + ancestor = ancestor.getParent(); + if (ancestor !== null) { + if (parentSibling === null && !visited.has(ancestor.__key)) { + nodes.push(ancestor); + } + } else { + break; + } + } while (parentSibling === null); + node = parentSibling; + } + if (!isBefore) { + nodes.reverse(); + } + return nodes; + } + + /** + * Returns true if this node has been marked dirty during this update cycle. + * + */ + isDirty(): boolean { + const editor = getActiveEditor(); + const dirtyLeaves = editor._dirtyLeaves; + return dirtyLeaves !== null && dirtyLeaves.has(this.__key); + } + + /** + * Returns the latest version of the node from the active EditorState. + * This is used to avoid getting values from stale node references. + * + */ + getLatest(): this { + const latest = $getNodeByKey(this.__key); + if (latest === null) { + invariant( + false, + 'Lexical node does not exist in active editor state. Avoid using the same node references between nested closures from editorState.read/editor.update.', + ); + } + return latest; + } + + /** + * Returns a mutable version of the node using {@link $cloneWithProperties} + * if necessary. Will throw an error if called outside of a Lexical Editor + * {@link LexicalEditor.update} callback. + * + */ + getWritable(): this { + errorOnReadOnly(); + const editorState = getActiveEditorState(); + const editor = getActiveEditor(); + const nodeMap = editorState._nodeMap; + const key = this.__key; + // Ensure we get the latest node from pending state + const latestNode = this.getLatest(); + const cloneNotNeeded = editor._cloneNotNeeded; + const selection = $getSelection(); + if (selection !== null) { + selection.setCachedNodes(null); + } + if (cloneNotNeeded.has(key)) { + // Transforms clear the dirty node set on each iteration to keep track on newly dirty nodes + internalMarkNodeAsDirty(latestNode); + return latestNode; + } + const mutableNode = $cloneWithProperties(latestNode); + cloneNotNeeded.add(key); + internalMarkNodeAsDirty(mutableNode); + // Update reference in node map + nodeMap.set(key, mutableNode); + + return mutableNode; + } + + /** + * Returns the text content of the node. Override this for + * custom nodes that should have a representation in plain text + * format (for copy + paste, for example) + * + */ + getTextContent(): string { + return ''; + } + + /** + * Returns the length of the string produced by calling getTextContent on this node. + * + */ + getTextContentSize(): number { + return this.getTextContent().length; + } + + // View + + /** + * Called during the reconciliation process to determine which nodes + * to insert into the DOM for this Lexical Node. + * + * This method must return exactly one HTMLElement. Nested elements are not supported. + * + * Do not attempt to update the Lexical EditorState during this phase of the update lifecyle. + * + * @param _config - allows access to things like the EditorTheme (to apply classes) during reconciliation. + * @param _editor - allows access to the editor for context during reconciliation. + * + * */ + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + invariant(false, 'createDOM: base method not extended'); + } + + /** + * Called when a node changes and should update the DOM + * in whatever way is necessary to make it align with any changes that might + * have happened during the update. + * + * Returning "true" here will cause lexical to unmount and recreate the DOM node + * (by calling createDOM). You would need to do this if the element tag changes, + * for instance. + * + * */ + updateDOM( + _prevNode: unknown, + _dom: HTMLElement, + _config: EditorConfig, + ): boolean { + invariant(false, 'updateDOM: base method not extended'); + } + + /** + * Controls how the this node is serialized to HTML. This is important for + * copy and paste between Lexical and non-Lexical editors, or Lexical editors with different namespaces, + * in which case the primary transfer format is HTML. It's also important if you're serializing + * to HTML for any other reason via {@link @lexical/html!$generateHtmlFromNodes}. You could + * also use this method to build your own HTML renderer. + * + * */ + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config, editor); + return {element}; + } + + /** + * Controls how the this node is serialized to JSON. This is important for + * copy and paste between Lexical editors sharing the same namespace. It's also important + * if you're serializing to JSON for persistent storage somewhere. + * See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html). + * + * */ + exportJSON(): SerializedLexicalNode { + invariant(false, 'exportJSON: base method not extended'); + } + + /** + * Controls how the this node is deserialized from JSON. This is usually boilerplate, + * but provides an abstraction between the node implementation and serialized interface that can + * be important if you ever make breaking changes to a node schema (by adding or removing properties). + * See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html). + * + * */ + static importJSON(_serializedNode: SerializedLexicalNode): LexicalNode { + invariant( + false, + 'LexicalNode: Node %s does not implement .importJSON().', + this.name, + ); + } + /** + * @experimental + * + * Registers the returned function as a transform on the node during + * Editor initialization. Most such use cases should be addressed via + * the {@link LexicalEditor.registerNodeTransform} API. + * + * Experimental - use at your own risk. + */ + static transform(): ((node: LexicalNode) => void) | null { + return null; + } + + // Setters and mutators + + /** + * Removes this LexicalNode from the EditorState. If the node isn't re-inserted + * somewhere, the Lexical garbage collector will eventually clean it up. + * + * @param preserveEmptyParent - If falsy, the node's parent will be removed if + * it's empty after the removal operation. This is the default behavior, subject to + * other node heuristics such as {@link ElementNode#canBeEmpty} + * */ + remove(preserveEmptyParent?: boolean): void { + $removeNode(this, true, preserveEmptyParent); + } + + /** + * Replaces this LexicalNode with the provided node, optionally transferring the children + * of the replaced node to the replacing node. + * + * @param replaceWith - The node to replace this one with. + * @param includeChildren - Whether or not to transfer the children of this node to the replacing node. + * */ + replace(replaceWith: N, includeChildren?: boolean): N { + errorOnReadOnly(); + let selection = $getSelection(); + if (selection !== null) { + selection = selection.clone(); + } + errorOnInsertTextNodeOnRoot(this, replaceWith); + const self = this.getLatest(); + const toReplaceKey = this.__key; + const key = replaceWith.__key; + const writableReplaceWith = replaceWith.getWritable(); + const writableParent = this.getParentOrThrow().getWritable(); + const size = writableParent.__size; + removeFromParent(writableReplaceWith); + const prevSibling = self.getPreviousSibling(); + const nextSibling = self.getNextSibling(); + const prevKey = self.__prev; + const nextKey = self.__next; + const parentKey = self.__parent; + $removeNode(self, false, true); + + if (prevSibling === null) { + writableParent.__first = key; + } else { + const writablePrevSibling = prevSibling.getWritable(); + writablePrevSibling.__next = key; + } + writableReplaceWith.__prev = prevKey; + if (nextSibling === null) { + writableParent.__last = key; + } else { + const writableNextSibling = nextSibling.getWritable(); + writableNextSibling.__prev = key; + } + writableReplaceWith.__next = nextKey; + writableReplaceWith.__parent = parentKey; + writableParent.__size = size; + if (includeChildren) { + invariant( + $isElementNode(this) && $isElementNode(writableReplaceWith), + 'includeChildren should only be true for ElementNodes', + ); + this.getChildren().forEach((child: LexicalNode) => { + writableReplaceWith.append(child); + }); + } + if ($isRangeSelection(selection)) { + $setSelection(selection); + const anchor = selection.anchor; + const focus = selection.focus; + if (anchor.key === toReplaceKey) { + $moveSelectionPointToEnd(anchor, writableReplaceWith); + } + if (focus.key === toReplaceKey) { + $moveSelectionPointToEnd(focus, writableReplaceWith); + } + } + if ($getCompositionKey() === toReplaceKey) { + $setCompositionKey(key); + } + return writableReplaceWith; + } + + /** + * Inserts a node after this LexicalNode (as the next sibling). + * + * @param nodeToInsert - The node to insert after this one. + * @param restoreSelection - Whether or not to attempt to resolve the + * selection to the appropriate place after the operation is complete. + * */ + insertAfter(nodeToInsert: LexicalNode, restoreSelection = true): LexicalNode { + errorOnReadOnly(); + errorOnInsertTextNodeOnRoot(this, nodeToInsert); + const writableSelf = this.getWritable(); + const writableNodeToInsert = nodeToInsert.getWritable(); + const oldParent = writableNodeToInsert.getParent(); + const selection = $getSelection(); + let elementAnchorSelectionOnNode = false; + let elementFocusSelectionOnNode = false; + if (oldParent !== null) { + // TODO: this is O(n), can we improve? + const oldIndex = nodeToInsert.getIndexWithinParent(); + removeFromParent(writableNodeToInsert); + if ($isRangeSelection(selection)) { + const oldParentKey = oldParent.__key; + const anchor = selection.anchor; + const focus = selection.focus; + elementAnchorSelectionOnNode = + anchor.type === 'element' && + anchor.key === oldParentKey && + anchor.offset === oldIndex + 1; + elementFocusSelectionOnNode = + focus.type === 'element' && + focus.key === oldParentKey && + focus.offset === oldIndex + 1; + } + } + const nextSibling = this.getNextSibling(); + const writableParent = this.getParentOrThrow().getWritable(); + const insertKey = writableNodeToInsert.__key; + const nextKey = writableSelf.__next; + if (nextSibling === null) { + writableParent.__last = insertKey; + } else { + const writableNextSibling = nextSibling.getWritable(); + writableNextSibling.__prev = insertKey; + } + writableParent.__size++; + writableSelf.__next = insertKey; + writableNodeToInsert.__next = nextKey; + writableNodeToInsert.__prev = writableSelf.__key; + writableNodeToInsert.__parent = writableSelf.__parent; + if (restoreSelection && $isRangeSelection(selection)) { + const index = this.getIndexWithinParent(); + $updateElementSelectionOnCreateDeleteNode( + selection, + writableParent, + index + 1, + ); + const writableParentKey = writableParent.__key; + if (elementAnchorSelectionOnNode) { + selection.anchor.set(writableParentKey, index + 2, 'element'); + } + if (elementFocusSelectionOnNode) { + selection.focus.set(writableParentKey, index + 2, 'element'); + } + } + return nodeToInsert; + } + + /** + * Inserts a node before this LexicalNode (as the previous sibling). + * + * @param nodeToInsert - The node to insert before this one. + * @param restoreSelection - Whether or not to attempt to resolve the + * selection to the appropriate place after the operation is complete. + * */ + insertBefore( + nodeToInsert: LexicalNode, + restoreSelection = true, + ): LexicalNode { + errorOnReadOnly(); + errorOnInsertTextNodeOnRoot(this, nodeToInsert); + const writableSelf = this.getWritable(); + const writableNodeToInsert = nodeToInsert.getWritable(); + const insertKey = writableNodeToInsert.__key; + removeFromParent(writableNodeToInsert); + const prevSibling = this.getPreviousSibling(); + const writableParent = this.getParentOrThrow().getWritable(); + const prevKey = writableSelf.__prev; + // TODO: this is O(n), can we improve? + const index = this.getIndexWithinParent(); + if (prevSibling === null) { + writableParent.__first = insertKey; + } else { + const writablePrevSibling = prevSibling.getWritable(); + writablePrevSibling.__next = insertKey; + } + writableParent.__size++; + writableSelf.__prev = insertKey; + writableNodeToInsert.__prev = prevKey; + writableNodeToInsert.__next = writableSelf.__key; + writableNodeToInsert.__parent = writableSelf.__parent; + const selection = $getSelection(); + if (restoreSelection && $isRangeSelection(selection)) { + const parent = this.getParentOrThrow(); + $updateElementSelectionOnCreateDeleteNode(selection, parent, index); + } + return nodeToInsert; + } + + /** + * Whether or not this node has a required parent. Used during copy + paste operations + * to normalize nodes that would otherwise be orphaned. For example, ListItemNodes without + * a ListNode parent or TextNodes with a ParagraphNode parent. + * + * */ + isParentRequired(): boolean { + return false; + } + + /** + * The creation logic for any required parent. Should be implemented if {@link isParentRequired} returns true. + * + * */ + createParentElementNode(): ElementNode { + return $createParagraphNode(); + } + + selectStart(): RangeSelection { + return this.selectPrevious(); + } + + selectEnd(): RangeSelection { + return this.selectNext(0, 0); + } + + /** + * Moves selection to the previous sibling of this node, at the specified offsets. + * + * @param anchorOffset - The anchor offset for selection. + * @param focusOffset - The focus offset for selection + * */ + selectPrevious(anchorOffset?: number, focusOffset?: number): RangeSelection { + errorOnReadOnly(); + const prevSibling = this.getPreviousSibling(); + const parent = this.getParentOrThrow(); + if (prevSibling === null) { + return parent.select(0, 0); + } + if ($isElementNode(prevSibling)) { + return prevSibling.select(); + } else if (!$isTextNode(prevSibling)) { + const index = prevSibling.getIndexWithinParent() + 1; + return parent.select(index, index); + } + return prevSibling.select(anchorOffset, focusOffset); + } + + /** + * Moves selection to the next sibling of this node, at the specified offsets. + * + * @param anchorOffset - The anchor offset for selection. + * @param focusOffset - The focus offset for selection + * */ + selectNext(anchorOffset?: number, focusOffset?: number): RangeSelection { + errorOnReadOnly(); + const nextSibling = this.getNextSibling(); + const parent = this.getParentOrThrow(); + if (nextSibling === null) { + return parent.select(); + } + if ($isElementNode(nextSibling)) { + return nextSibling.select(0, 0); + } else if (!$isTextNode(nextSibling)) { + const index = nextSibling.getIndexWithinParent(); + return parent.select(index, index); + } + return nextSibling.select(anchorOffset, focusOffset); + } + + /** + * Marks a node dirty, triggering transforms and + * forcing it to be reconciled during the update cycle. + * + * */ + markDirty(): void { + this.getWritable(); + } +} + +function errorOnTypeKlassMismatch( + type: string, + klass: Klass, +): void { + const registeredNode = getActiveEditor()._nodes.get(type); + // Common error - split in its own invariant + if (registeredNode === undefined) { + invariant( + false, + 'Create node: Attempted to create node %s that was not configured to be used on the editor.', + klass.name, + ); + } + const editorKlass = registeredNode.klass; + if (editorKlass !== klass) { + invariant( + false, + 'Create node: Type %s in node %s does not match registered node %s with the same type', + type, + klass.name, + editorKlass.name, + ); + } +} + +/** + * Insert a series of nodes after this LexicalNode (as next siblings) + * + * @param firstToInsert - The first node to insert after this one. + * @param lastToInsert - The last node to insert after this one. Must be a + * later sibling of FirstNode. If not provided, it will be its last sibling. + */ +export function insertRangeAfter( + node: LexicalNode, + firstToInsert: LexicalNode, + lastToInsert?: LexicalNode, +) { + const lastToInsert2 = + lastToInsert || firstToInsert.getParentOrThrow().getLastChild()!; + let current = firstToInsert; + const nodesToInsert = [firstToInsert]; + while (current !== lastToInsert2) { + if (!current.getNextSibling()) { + invariant( + false, + 'insertRangeAfter: lastToInsert must be a later sibling of firstToInsert', + ); + } + current = current.getNextSibling()!; + nodesToInsert.push(current); + } + + let currentNode: LexicalNode = node; + for (const nodeToInsert of nodesToInsert) { + currentNode = currentNode.insertAfter(nodeToInsert); + } +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts b/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts new file mode 100644 index 000000000..59a7be644 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalNormalization.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {RangeSelection, TextNode} from '.'; +import type {PointType} from './LexicalSelection'; + +import {$isElementNode, $isTextNode} from '.'; +import {getActiveEditor} from './LexicalUpdates'; + +function $canSimpleTextNodesBeMerged( + node1: TextNode, + node2: TextNode, +): boolean { + const node1Mode = node1.__mode; + const node1Format = node1.__format; + const node1Style = node1.__style; + const node2Mode = node2.__mode; + const node2Format = node2.__format; + const node2Style = node2.__style; + return ( + (node1Mode === null || node1Mode === node2Mode) && + (node1Format === null || node1Format === node2Format) && + (node1Style === null || node1Style === node2Style) + ); +} + +function $mergeTextNodes(node1: TextNode, node2: TextNode): TextNode { + const writableNode1 = node1.mergeWithSibling(node2); + + const normalizedNodes = getActiveEditor()._normalizedNodes; + + normalizedNodes.add(node1.__key); + normalizedNodes.add(node2.__key); + return writableNode1; +} + +export function $normalizeTextNode(textNode: TextNode): void { + let node = textNode; + + if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) { + node.remove(); + return; + } + + // Backward + let previousNode; + + while ( + (previousNode = node.getPreviousSibling()) !== null && + $isTextNode(previousNode) && + previousNode.isSimpleText() && + !previousNode.isUnmergeable() + ) { + if (previousNode.__text === '') { + previousNode.remove(); + } else if ($canSimpleTextNodesBeMerged(previousNode, node)) { + node = $mergeTextNodes(previousNode, node); + break; + } else { + break; + } + } + + // Forward + let nextNode; + + while ( + (nextNode = node.getNextSibling()) !== null && + $isTextNode(nextNode) && + nextNode.isSimpleText() && + !nextNode.isUnmergeable() + ) { + if (nextNode.__text === '') { + nextNode.remove(); + } else if ($canSimpleTextNodesBeMerged(node, nextNode)) { + node = $mergeTextNodes(node, nextNode); + break; + } else { + break; + } + } +} + +export function $normalizeSelection(selection: RangeSelection): RangeSelection { + $normalizePoint(selection.anchor); + $normalizePoint(selection.focus); + return selection; +} + +function $normalizePoint(point: PointType): void { + while (point.type === 'element') { + const node = point.getNode(); + const offset = point.offset; + let nextNode; + let nextOffsetAtEnd; + if (offset === node.getChildrenSize()) { + nextNode = node.getChildAtIndex(offset - 1); + nextOffsetAtEnd = true; + } else { + nextNode = node.getChildAtIndex(offset); + nextOffsetAtEnd = false; + } + if ($isTextNode(nextNode)) { + point.set( + nextNode.__key, + nextOffsetAtEnd ? nextNode.getTextContentSize() : 0, + 'text', + ); + break; + } else if (!$isElementNode(nextNode)) { + break; + } + point.set( + nextNode.__key, + nextOffsetAtEnd ? nextNode.getChildrenSize() : 0, + 'element', + ); + } +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts new file mode 100644 index 000000000..0162d2281 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -0,0 +1,943 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + EditorConfig, + LexicalEditor, + MutatedNodes, + MutationListeners, + RegisteredNodes, +} from './LexicalEditor'; +import type {NodeKey, NodeMap} from './LexicalNode'; +import type {ElementNode} from './nodes/LexicalElementNode'; + +import invariant from 'lexical/shared/invariant'; +import normalizeClassNames from 'lexical/shared/normalizeClassNames'; + +import { + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isParagraphNode, + $isRootNode, + $isTextNode, +} from '.'; +import { + DOUBLE_LINE_BREAK, + FULL_RECONCILE, + IS_ALIGN_CENTER, + IS_ALIGN_END, + IS_ALIGN_JUSTIFY, + IS_ALIGN_LEFT, + IS_ALIGN_RIGHT, + IS_ALIGN_START, +} from './LexicalConstants'; +import {EditorState} from './LexicalEditorState'; +import { + $textContentRequiresDoubleLinebreakAtEnd, + cloneDecorators, + getElementByKeyOrThrow, + getTextDirection, + setMutatedNode, +} from './LexicalUtils'; + +type IntentionallyMarkedAsDirtyElement = boolean; + +let subTreeTextContent = ''; +let subTreeDirectionedTextContent = ''; +let subTreeTextFormat: number | null = null; +let subTreeTextStyle: string = ''; +let editorTextContent = ''; +let activeEditorConfig: EditorConfig; +let activeEditor: LexicalEditor; +let activeEditorNodes: RegisteredNodes; +let treatAllNodesAsDirty = false; +let activeEditorStateReadOnly = false; +let activeMutationListeners: MutationListeners; +let activeTextDirection: 'ltr' | 'rtl' | null = null; +let activeDirtyElements: Map; +let activeDirtyLeaves: Set; +let activePrevNodeMap: NodeMap; +let activeNextNodeMap: NodeMap; +let activePrevKeyToDOMMap: Map; +let mutatedNodes: MutatedNodes; + +function destroyNode(key: NodeKey, parentDOM: null | HTMLElement): void { + const node = activePrevNodeMap.get(key); + + if (parentDOM !== null) { + const dom = getPrevElementByKeyOrThrow(key); + if (dom.parentNode === parentDOM) { + parentDOM.removeChild(dom); + } + } + + // This logic is really important, otherwise we will leak DOM nodes + // when their corresponding LexicalNodes are removed from the editor state. + if (!activeNextNodeMap.has(key)) { + activeEditor._keyToDOMMap.delete(key); + } + + if ($isElementNode(node)) { + const children = createChildrenArray(node, activePrevNodeMap); + destroyChildren(children, 0, children.length - 1, null); + } + + if (node !== undefined) { + setMutatedNode( + mutatedNodes, + activeEditorNodes, + activeMutationListeners, + node, + 'destroyed', + ); + } +} + +function destroyChildren( + children: Array, + _startIndex: number, + endIndex: number, + dom: null | HTMLElement, +): void { + let startIndex = _startIndex; + + for (; startIndex <= endIndex; ++startIndex) { + const child = children[startIndex]; + + if (child !== undefined) { + destroyNode(child, dom); + } + } +} + +function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void { + domStyle.setProperty('text-align', value); +} + +const DEFAULT_INDENT_VALUE = '40px'; + +function setElementIndent(dom: HTMLElement, indent: number): void { + const indentClassName = activeEditorConfig.theme.indent; + + if (typeof indentClassName === 'string') { + const elementHasClassName = dom.classList.contains(indentClassName); + + if (indent > 0 && !elementHasClassName) { + dom.classList.add(indentClassName); + } else if (indent < 1 && elementHasClassName) { + dom.classList.remove(indentClassName); + } + } + + const indentationBaseValue = + getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') || + DEFAULT_INDENT_VALUE; + + dom.style.setProperty( + 'padding-inline-start', + indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`, + ); +} + +function setElementFormat(dom: HTMLElement, format: number): void { + const domStyle = dom.style; + + if (format === 0) { + setTextAlign(domStyle, ''); + } else if (format === IS_ALIGN_LEFT) { + setTextAlign(domStyle, 'left'); + } else if (format === IS_ALIGN_CENTER) { + setTextAlign(domStyle, 'center'); + } else if (format === IS_ALIGN_RIGHT) { + setTextAlign(domStyle, 'right'); + } else if (format === IS_ALIGN_JUSTIFY) { + setTextAlign(domStyle, 'justify'); + } else if (format === IS_ALIGN_START) { + setTextAlign(domStyle, 'start'); + } else if (format === IS_ALIGN_END) { + setTextAlign(domStyle, 'end'); + } +} + +function $createNode( + key: NodeKey, + parentDOM: null | HTMLElement, + insertDOM: null | Node, +): HTMLElement { + const node = activeNextNodeMap.get(key); + + if (node === undefined) { + invariant(false, 'createNode: node does not exist in nodeMap'); + } + const dom = node.createDOM(activeEditorConfig, activeEditor); + storeDOMWithKey(key, dom, activeEditor); + + // This helps preserve the text, and stops spell check tools from + // merging or break the spans (which happens if they are missing + // this attribute). + if ($isTextNode(node)) { + dom.setAttribute('data-lexical-text', 'true'); + } else if ($isDecoratorNode(node)) { + dom.setAttribute('data-lexical-decorator', 'true'); + } + + if ($isElementNode(node)) { + const indent = node.__indent; + const childrenSize = node.__size; + + if (indent !== 0) { + setElementIndent(dom, indent); + } + if (childrenSize !== 0) { + const endIndex = childrenSize - 1; + const children = createChildrenArray(node, activeNextNodeMap); + $createChildrenWithDirection(children, endIndex, node, dom); + } + const format = node.__format; + + if (format !== 0) { + setElementFormat(dom, format); + } + if (!node.isInline()) { + reconcileElementTerminatingLineBreak(null, node, dom); + } + if ($textContentRequiresDoubleLinebreakAtEnd(node)) { + subTreeTextContent += DOUBLE_LINE_BREAK; + editorTextContent += DOUBLE_LINE_BREAK; + } + } else { + const text = node.getTextContent(); + + if ($isDecoratorNode(node)) { + const decorator = node.decorate(activeEditor, activeEditorConfig); + + if (decorator !== null) { + reconcileDecorator(key, decorator); + } + // Decorators are always non editable + dom.contentEditable = 'false'; + } else if ($isTextNode(node)) { + if (!node.isDirectionless()) { + subTreeDirectionedTextContent += text; + } + } + subTreeTextContent += text; + editorTextContent += text; + } + + if (parentDOM !== null) { + if (insertDOM != null) { + parentDOM.insertBefore(dom, insertDOM); + } else { + // @ts-expect-error: internal field + const possibleLineBreak = parentDOM.__lexicalLineBreak; + + if (possibleLineBreak != null) { + parentDOM.insertBefore(dom, possibleLineBreak); + } else { + parentDOM.appendChild(dom); + } + } + } + + if (__DEV__) { + // Freeze the node in DEV to prevent accidental mutations + Object.freeze(node); + } + + setMutatedNode( + mutatedNodes, + activeEditorNodes, + activeMutationListeners, + node, + 'created', + ); + return dom; +} + +function $createChildrenWithDirection( + children: Array, + endIndex: number, + element: ElementNode, + dom: HTMLElement, +): void { + const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent; + subTreeDirectionedTextContent = ''; + $createChildren(children, element, 0, endIndex, dom, null); + reconcileBlockDirection(element, dom); + subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent; +} + +function $createChildren( + children: Array, + element: ElementNode, + _startIndex: number, + endIndex: number, + dom: null | HTMLElement, + insertDOM: null | HTMLElement, +): void { + const previousSubTreeTextContent = subTreeTextContent; + subTreeTextContent = ''; + let startIndex = _startIndex; + + for (; startIndex <= endIndex; ++startIndex) { + $createNode(children[startIndex], dom, insertDOM); + const node = activeNextNodeMap.get(children[startIndex]); + if (node !== null && $isTextNode(node)) { + if (subTreeTextFormat === null) { + subTreeTextFormat = node.getFormat(); + } + if (subTreeTextStyle === '') { + subTreeTextStyle = node.getStyle(); + } + } + } + if ($textContentRequiresDoubleLinebreakAtEnd(element)) { + subTreeTextContent += DOUBLE_LINE_BREAK; + } + // @ts-expect-error: internal field + dom.__lexicalTextContent = subTreeTextContent; + subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; +} + +function isLastChildLineBreakOrDecorator( + childKey: NodeKey, + nodeMap: NodeMap, +): boolean { + const node = nodeMap.get(childKey); + return $isLineBreakNode(node) || ($isDecoratorNode(node) && node.isInline()); +} + +// If we end an element with a LineBreakNode, then we need to add an additional
    +function reconcileElementTerminatingLineBreak( + prevElement: null | ElementNode, + nextElement: ElementNode, + dom: HTMLElement, +): void { + const prevLineBreak = + prevElement !== null && + (prevElement.__size === 0 || + isLastChildLineBreakOrDecorator( + prevElement.__last as NodeKey, + activePrevNodeMap, + )); + const nextLineBreak = + nextElement.__size === 0 || + isLastChildLineBreakOrDecorator( + nextElement.__last as NodeKey, + activeNextNodeMap, + ); + + if (prevLineBreak) { + if (!nextLineBreak) { + // @ts-expect-error: internal field + const element = dom.__lexicalLineBreak; + + if (element != null) { + try { + dom.removeChild(element); + } catch (error) { + if (typeof error === 'object' && error != null) { + const msg = `${error.toString()} Parent: ${dom.tagName}, child: ${ + element.tagName + }.`; + throw new Error(msg); + } else { + throw error; + } + } + } + + // @ts-expect-error: internal field + dom.__lexicalLineBreak = null; + } + } else if (nextLineBreak) { + const element = document.createElement('br'); + // @ts-expect-error: internal field + dom.__lexicalLineBreak = element; + dom.appendChild(element); + } +} + +function reconcileParagraphFormat(element: ElementNode): void { + if ( + $isParagraphNode(element) && + subTreeTextFormat != null && + subTreeTextFormat !== element.__textFormat && + !activeEditorStateReadOnly + ) { + element.setTextFormat(subTreeTextFormat); + element.setTextStyle(subTreeTextStyle); + } +} + +function reconcileParagraphStyle(element: ElementNode): void { + if ( + $isParagraphNode(element) && + subTreeTextStyle !== '' && + subTreeTextStyle !== element.__textStyle && + !activeEditorStateReadOnly + ) { + element.setTextStyle(subTreeTextStyle); + } +} + +function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void { + const previousSubTreeDirectionTextContent: string = + // @ts-expect-error: internal field + dom.__lexicalDirTextContent; + // @ts-expect-error: internal field + const previousDirection: string = dom.__lexicalDir; + + if ( + previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent || + previousDirection !== activeTextDirection + ) { + const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === ''; + const direction = hasEmptyDirectionedTextContent + ? activeTextDirection + : getTextDirection(subTreeDirectionedTextContent); + + if (direction !== previousDirection) { + const classList = dom.classList; + const theme = activeEditorConfig.theme; + let previousDirectionTheme = + previousDirection !== null ? theme[previousDirection] : undefined; + let nextDirectionTheme = + direction !== null ? theme[direction] : undefined; + + // Remove the old theme classes if they exist + if (previousDirectionTheme !== undefined) { + if (typeof previousDirectionTheme === 'string') { + const classNamesArr = normalizeClassNames(previousDirectionTheme); + previousDirectionTheme = theme[previousDirection] = classNamesArr; + } + + // @ts-ignore: intentional + classList.remove(...previousDirectionTheme); + } + + if ( + direction === null || + (hasEmptyDirectionedTextContent && direction === 'ltr') + ) { + // Remove direction + dom.removeAttribute('dir'); + } else { + // Apply the new theme classes if they exist + if (nextDirectionTheme !== undefined) { + if (typeof nextDirectionTheme === 'string') { + const classNamesArr = normalizeClassNames(nextDirectionTheme); + // @ts-expect-error: intentional + nextDirectionTheme = theme[direction] = classNamesArr; + } + + if (nextDirectionTheme !== undefined) { + classList.add(...nextDirectionTheme); + } + } + + // Update direction + dom.dir = direction; + } + + if (!activeEditorStateReadOnly) { + const writableNode = element.getWritable(); + writableNode.__dir = direction; + } + } + + activeTextDirection = direction; + // @ts-expect-error: internal field + dom.__lexicalDirTextContent = subTreeDirectionedTextContent; + // @ts-expect-error: internal field + dom.__lexicalDir = direction; + } +} + +function $reconcileChildrenWithDirection( + prevElement: ElementNode, + nextElement: ElementNode, + dom: HTMLElement, +): void { + const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent; + subTreeDirectionedTextContent = ''; + subTreeTextFormat = null; + subTreeTextStyle = ''; + $reconcileChildren(prevElement, nextElement, dom); + reconcileBlockDirection(nextElement, dom); + reconcileParagraphFormat(nextElement); + reconcileParagraphStyle(nextElement); + subTreeDirectionedTextContent = previousSubTreeDirectionTextContent; +} + +function createChildrenArray( + element: ElementNode, + nodeMap: NodeMap, +): Array { + const children = []; + let nodeKey = element.__first; + while (nodeKey !== null) { + const node = nodeMap.get(nodeKey); + if (node === undefined) { + invariant(false, 'createChildrenArray: node does not exist in nodeMap'); + } + children.push(nodeKey); + nodeKey = node.__next; + } + return children; +} + +function $reconcileChildren( + prevElement: ElementNode, + nextElement: ElementNode, + dom: HTMLElement, +): void { + const previousSubTreeTextContent = subTreeTextContent; + const prevChildrenSize = prevElement.__size; + const nextChildrenSize = nextElement.__size; + subTreeTextContent = ''; + + if (prevChildrenSize === 1 && nextChildrenSize === 1) { + const prevFirstChildKey = prevElement.__first as NodeKey; + const nextFrstChildKey = nextElement.__first as NodeKey; + if (prevFirstChildKey === nextFrstChildKey) { + $reconcileNode(prevFirstChildKey, dom); + } else { + const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey); + const replacementDOM = $createNode(nextFrstChildKey, null, null); + try { + dom.replaceChild(replacementDOM, lastDOM); + } catch (error) { + if (typeof error === 'object' && error != null) { + const msg = `${error.toString()} Parent: ${ + dom.tagName + }, new child: {tag: ${ + replacementDOM.tagName + } key: ${nextFrstChildKey}}, old child: {tag: ${ + lastDOM.tagName + }, key: ${prevFirstChildKey}}.`; + throw new Error(msg); + } else { + throw error; + } + } + destroyNode(prevFirstChildKey, null); + } + const nextChildNode = activeNextNodeMap.get(nextFrstChildKey); + if ($isTextNode(nextChildNode)) { + if (subTreeTextFormat === null) { + subTreeTextFormat = nextChildNode.getFormat(); + } + if (subTreeTextStyle === '') { + subTreeTextStyle = nextChildNode.getStyle(); + } + } + } else { + const prevChildren = createChildrenArray(prevElement, activePrevNodeMap); + const nextChildren = createChildrenArray(nextElement, activeNextNodeMap); + + if (prevChildrenSize === 0) { + if (nextChildrenSize !== 0) { + $createChildren( + nextChildren, + nextElement, + 0, + nextChildrenSize - 1, + dom, + null, + ); + } + } else if (nextChildrenSize === 0) { + if (prevChildrenSize !== 0) { + // @ts-expect-error: internal field + const lexicalLineBreak = dom.__lexicalLineBreak; + const canUseFastPath = lexicalLineBreak == null; + destroyChildren( + prevChildren, + 0, + prevChildrenSize - 1, + canUseFastPath ? null : dom, + ); + + if (canUseFastPath) { + // Fast path for removing DOM nodes + dom.textContent = ''; + } + } + } else { + $reconcileNodeChildren( + nextElement, + prevChildren, + nextChildren, + prevChildrenSize, + nextChildrenSize, + dom, + ); + } + } + + if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) { + subTreeTextContent += DOUBLE_LINE_BREAK; + } + + // @ts-expect-error: internal field + dom.__lexicalTextContent = subTreeTextContent; + subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; +} + +function $reconcileNode( + key: NodeKey, + parentDOM: HTMLElement | null, +): HTMLElement { + const prevNode = activePrevNodeMap.get(key); + let nextNode = activeNextNodeMap.get(key); + + if (prevNode === undefined || nextNode === undefined) { + invariant( + false, + 'reconcileNode: prevNode or nextNode does not exist in nodeMap', + ); + } + + const isDirty = + treatAllNodesAsDirty || + activeDirtyLeaves.has(key) || + activeDirtyElements.has(key); + const dom = getElementByKeyOrThrow(activeEditor, key); + + // If the node key points to the same instance in both states + // and isn't dirty, we just update the text content cache + // and return the existing DOM Node. + if (prevNode === nextNode && !isDirty) { + if ($isElementNode(prevNode)) { + // @ts-expect-error: internal field + const previousSubTreeTextContent = dom.__lexicalTextContent; + + if (previousSubTreeTextContent !== undefined) { + subTreeTextContent += previousSubTreeTextContent; + editorTextContent += previousSubTreeTextContent; + } + + // @ts-expect-error: internal field + const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent; + + if (previousSubTreeDirectionTextContent !== undefined) { + subTreeDirectionedTextContent += previousSubTreeDirectionTextContent; + } + } else { + const text = prevNode.getTextContent(); + + if ($isTextNode(prevNode) && !prevNode.isDirectionless()) { + subTreeDirectionedTextContent += text; + } + + editorTextContent += text; + subTreeTextContent += text; + } + + return dom; + } + // If the node key doesn't point to the same instance in both maps, + // it means it were cloned. If they're also dirty, we mark them as mutated. + if (prevNode !== nextNode && isDirty) { + setMutatedNode( + mutatedNodes, + activeEditorNodes, + activeMutationListeners, + nextNode, + 'updated', + ); + } + + // Update node. If it returns true, we need to unmount and re-create the node + if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) { + const replacementDOM = $createNode(key, null, null); + + if (parentDOM === null) { + invariant(false, 'reconcileNode: parentDOM is null'); + } + + parentDOM.replaceChild(replacementDOM, dom); + destroyNode(key, null); + return replacementDOM; + } + + if ($isElementNode(prevNode) && $isElementNode(nextNode)) { + // Reconcile element children + const nextIndent = nextNode.__indent; + + if (nextIndent !== prevNode.__indent) { + setElementIndent(dom, nextIndent); + } + + const nextFormat = nextNode.__format; + + if (nextFormat !== prevNode.__format) { + setElementFormat(dom, nextFormat); + } + if (isDirty) { + $reconcileChildrenWithDirection(prevNode, nextNode, dom); + if (!$isRootNode(nextNode) && !nextNode.isInline()) { + reconcileElementTerminatingLineBreak(prevNode, nextNode, dom); + } + } + + if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) { + subTreeTextContent += DOUBLE_LINE_BREAK; + editorTextContent += DOUBLE_LINE_BREAK; + } + } else { + const text = nextNode.getTextContent(); + + if ($isDecoratorNode(nextNode)) { + const decorator = nextNode.decorate(activeEditor, activeEditorConfig); + + if (decorator !== null) { + reconcileDecorator(key, decorator); + } + } else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) { + // Handle text content, for LTR, LTR cases. + subTreeDirectionedTextContent += text; + } + + subTreeTextContent += text; + editorTextContent += text; + } + + if ( + !activeEditorStateReadOnly && + $isRootNode(nextNode) && + nextNode.__cachedText !== editorTextContent + ) { + // Cache the latest text content. + const nextRootNode = nextNode.getWritable(); + nextRootNode.__cachedText = editorTextContent; + nextNode = nextRootNode; + } + + if (__DEV__) { + // Freeze the node in DEV to prevent accidental mutations + Object.freeze(nextNode); + } + + return dom; +} + +function reconcileDecorator(key: NodeKey, decorator: unknown): void { + let pendingDecorators = activeEditor._pendingDecorators; + const currentDecorators = activeEditor._decorators; + + if (pendingDecorators === null) { + if (currentDecorators[key] === decorator) { + return; + } + + pendingDecorators = cloneDecorators(activeEditor); + } + + pendingDecorators[key] = decorator; +} + +function getFirstChild(element: HTMLElement): Node | null { + return element.firstChild; +} + +function getNextSibling(element: HTMLElement): Node | null { + let nextSibling = element.nextSibling; + if ( + nextSibling !== null && + nextSibling === activeEditor._blockCursorElement + ) { + nextSibling = nextSibling.nextSibling; + } + return nextSibling; +} + +function $reconcileNodeChildren( + nextElement: ElementNode, + prevChildren: Array, + nextChildren: Array, + prevChildrenLength: number, + nextChildrenLength: number, + dom: HTMLElement, +): void { + const prevEndIndex = prevChildrenLength - 1; + const nextEndIndex = nextChildrenLength - 1; + let prevChildrenSet: Set | undefined; + let nextChildrenSet: Set | undefined; + let siblingDOM: null | Node = getFirstChild(dom); + let prevIndex = 0; + let nextIndex = 0; + + while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) { + const prevKey = prevChildren[prevIndex]; + const nextKey = nextChildren[nextIndex]; + + if (prevKey === nextKey) { + siblingDOM = getNextSibling($reconcileNode(nextKey, dom)); + prevIndex++; + nextIndex++; + } else { + if (prevChildrenSet === undefined) { + prevChildrenSet = new Set(prevChildren); + } + + if (nextChildrenSet === undefined) { + nextChildrenSet = new Set(nextChildren); + } + + const nextHasPrevKey = nextChildrenSet.has(prevKey); + const prevHasNextKey = prevChildrenSet.has(nextKey); + + if (!nextHasPrevKey) { + // Remove prev + siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey)); + destroyNode(prevKey, dom); + prevIndex++; + } else if (!prevHasNextKey) { + // Create next + $createNode(nextKey, dom, siblingDOM); + nextIndex++; + } else { + // Move next + const childDOM = getElementByKeyOrThrow(activeEditor, nextKey); + + if (childDOM === siblingDOM) { + siblingDOM = getNextSibling($reconcileNode(nextKey, dom)); + } else { + if (siblingDOM != null) { + dom.insertBefore(childDOM, siblingDOM); + } else { + dom.appendChild(childDOM); + } + + $reconcileNode(nextKey, dom); + } + + prevIndex++; + nextIndex++; + } + } + + const node = activeNextNodeMap.get(nextKey); + if (node !== null && $isTextNode(node)) { + if (subTreeTextFormat === null) { + subTreeTextFormat = node.getFormat(); + } + if (subTreeTextStyle === '') { + subTreeTextStyle = node.getStyle(); + } + } + } + + const appendNewChildren = prevIndex > prevEndIndex; + const removeOldChildren = nextIndex > nextEndIndex; + + if (appendNewChildren && !removeOldChildren) { + const previousNode = nextChildren[nextEndIndex + 1]; + const insertDOM = + previousNode === undefined + ? null + : activeEditor.getElementByKey(previousNode); + $createChildren( + nextChildren, + nextElement, + nextIndex, + nextEndIndex, + dom, + insertDOM, + ); + } else if (removeOldChildren && !appendNewChildren) { + destroyChildren(prevChildren, prevIndex, prevEndIndex, dom); + } +} + +export function $reconcileRoot( + prevEditorState: EditorState, + nextEditorState: EditorState, + editor: LexicalEditor, + dirtyType: 0 | 1 | 2, + dirtyElements: Map, + dirtyLeaves: Set, +): MutatedNodes { + // We cache text content to make retrieval more efficient. + // The cache must be rebuilt during reconciliation to account for any changes. + subTreeTextContent = ''; + editorTextContent = ''; + subTreeDirectionedTextContent = ''; + // Rather than pass around a load of arguments through the stack recursively + // we instead set them as bindings within the scope of the module. + treatAllNodesAsDirty = dirtyType === FULL_RECONCILE; + activeTextDirection = null; + activeEditor = editor; + activeEditorConfig = editor._config; + activeEditorNodes = editor._nodes; + activeMutationListeners = activeEditor._listeners.mutation; + activeDirtyElements = dirtyElements; + activeDirtyLeaves = dirtyLeaves; + activePrevNodeMap = prevEditorState._nodeMap; + activeNextNodeMap = nextEditorState._nodeMap; + activeEditorStateReadOnly = nextEditorState._readOnly; + activePrevKeyToDOMMap = new Map(editor._keyToDOMMap); + // We keep track of mutated nodes so we can trigger mutation + // listeners later in the update cycle. + const currentMutatedNodes = new Map(); + mutatedNodes = currentMutatedNodes; + $reconcileNode('root', null); + // We don't want a bunch of void checks throughout the scope + // so instead we make it seem that these values are always set. + // We also want to make sure we clear them down, otherwise we + // can leak memory. + // @ts-ignore + activeEditor = undefined; + // @ts-ignore + activeEditorNodes = undefined; + // @ts-ignore + activeDirtyElements = undefined; + // @ts-ignore + activeDirtyLeaves = undefined; + // @ts-ignore + activePrevNodeMap = undefined; + // @ts-ignore + activeNextNodeMap = undefined; + // @ts-ignore + activeEditorConfig = undefined; + // @ts-ignore + activePrevKeyToDOMMap = undefined; + // @ts-ignore + mutatedNodes = undefined; + + return currentMutatedNodes; +} + +export function storeDOMWithKey( + key: NodeKey, + dom: HTMLElement, + editor: LexicalEditor, +): void { + const keyToDOMMap = editor._keyToDOMMap; + // @ts-ignore We intentionally add this to the Node. + dom['__lexicalKey_' + editor._key] = key; + keyToDOMMap.set(key, dom); +} + +function getPrevElementByKeyOrThrow(key: NodeKey): HTMLElement { + const element = activePrevKeyToDOMMap.get(key); + + if (element === undefined) { + invariant( + false, + 'Reconciliation: could not find DOM element for node key %s', + key, + ); + } + + return element; +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts new file mode 100644 index 000000000..db18cfc4a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -0,0 +1,2835 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor} from './LexicalEditor'; +import type {EditorState} from './LexicalEditorState'; +import type {NodeKey} from './LexicalNode'; +import type {ElementNode} from './nodes/LexicalElementNode'; +import type {TextFormatType} from './nodes/LexicalTextNode'; + +import invariant from 'lexical/shared/invariant'; + +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isRootNode, + $isTextNode, + $setSelection, + SELECTION_CHANGE_COMMAND, + TextNode, +} from '.'; +import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants'; +import { + markCollapsedSelectionFormat, + markSelectionChangeFromDOMUpdate, +} from './LexicalEvents'; +import {getIsProcessingMutations} from './LexicalMutations'; +import {insertRangeAfter, LexicalNode} from './LexicalNode'; +import { + getActiveEditor, + getActiveEditorState, + isCurrentlyReadOnlyMode, +} from './LexicalUpdates'; +import { + $getAdjacentNode, + $getAncestor, + $getCompositionKey, + $getNearestRootOrShadowRoot, + $getNodeByKey, + $getNodeFromDOM, + $getRoot, + $hasAncestor, + $isTokenOrSegmented, + $setCompositionKey, + doesContainGrapheme, + getDOMSelection, + getDOMTextNode, + getElementByKeyOrThrow, + getTextNodeOffset, + INTERNAL_$isBlock, + isSelectionCapturedInDecoratorInput, + isSelectionWithinEditor, + removeDOMBlockCursorElement, + scrollIntoViewIfNeeded, + toggleTextFormatType, +} from './LexicalUtils'; +import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; + +export type TextPointType = { + _selection: BaseSelection; + getNode: () => TextNode; + is: (point: PointType) => boolean; + isBefore: (point: PointType) => boolean; + key: NodeKey; + offset: number; + set: (key: NodeKey, offset: number, type: 'text' | 'element') => void; + type: 'text'; +}; + +export type ElementPointType = { + _selection: BaseSelection; + getNode: () => ElementNode; + is: (point: PointType) => boolean; + isBefore: (point: PointType) => boolean; + key: NodeKey; + offset: number; + set: (key: NodeKey, offset: number, type: 'text' | 'element') => void; + type: 'element'; +}; + +export type PointType = TextPointType | ElementPointType; + +export class Point { + key: NodeKey; + offset: number; + type: 'text' | 'element'; + _selection: BaseSelection | null; + + constructor(key: NodeKey, offset: number, type: 'text' | 'element') { + this._selection = null; + this.key = key; + this.offset = offset; + this.type = type; + } + + is(point: PointType): boolean { + return ( + this.key === point.key && + this.offset === point.offset && + this.type === point.type + ); + } + + isBefore(b: PointType): boolean { + let aNode = this.getNode(); + let bNode = b.getNode(); + const aOffset = this.offset; + const bOffset = b.offset; + + if ($isElementNode(aNode)) { + const aNodeDescendant = aNode.getDescendantByIndex(aOffset); + aNode = aNodeDescendant != null ? aNodeDescendant : aNode; + } + if ($isElementNode(bNode)) { + const bNodeDescendant = bNode.getDescendantByIndex(bOffset); + bNode = bNodeDescendant != null ? bNodeDescendant : bNode; + } + if (aNode === bNode) { + return aOffset < bOffset; + } + return aNode.isBefore(bNode); + } + + getNode(): LexicalNode { + const key = this.key; + const node = $getNodeByKey(key); + if (node === null) { + invariant(false, 'Point.getNode: node not found'); + } + return node; + } + + set(key: NodeKey, offset: number, type: 'text' | 'element'): void { + const selection = this._selection; + const oldKey = this.key; + this.key = key; + this.offset = offset; + this.type = type; + if (!isCurrentlyReadOnlyMode()) { + if ($getCompositionKey() === oldKey) { + $setCompositionKey(key); + } + if (selection !== null) { + selection.setCachedNodes(null); + selection.dirty = true; + } + } + } +} + +export function $createPoint( + key: NodeKey, + offset: number, + type: 'text' | 'element', +): PointType { + // @ts-expect-error: intentionally cast as we use a class for perf reasons + return new Point(key, offset, type); +} + +function selectPointOnNode(point: PointType, node: LexicalNode): void { + let key = node.__key; + let offset = point.offset; + let type: 'element' | 'text' = 'element'; + if ($isTextNode(node)) { + type = 'text'; + const textContentLength = node.getTextContentSize(); + if (offset > textContentLength) { + offset = textContentLength; + } + } else if (!$isElementNode(node)) { + const nextSibling = node.getNextSibling(); + if ($isTextNode(nextSibling)) { + key = nextSibling.__key; + offset = 0; + type = 'text'; + } else { + const parentNode = node.getParent(); + if (parentNode) { + key = parentNode.__key; + offset = node.getIndexWithinParent() + 1; + } + } + } + point.set(key, offset, type); +} + +export function $moveSelectionPointToEnd( + point: PointType, + node: LexicalNode, +): void { + if ($isElementNode(node)) { + const lastNode = node.getLastDescendant(); + if ($isElementNode(lastNode) || $isTextNode(lastNode)) { + selectPointOnNode(point, lastNode); + } else { + selectPointOnNode(point, node); + } + } else { + selectPointOnNode(point, node); + } +} + +function $transferStartingElementPointToTextPoint( + start: ElementPointType, + end: PointType, + format: number, + style: string, +): void { + const element = start.getNode(); + const placementNode = element.getChildAtIndex(start.offset); + const textNode = $createTextNode(); + const target = $isRootNode(element) + ? $createParagraphNode().append(textNode) + : textNode; + textNode.setFormat(format); + textNode.setStyle(style); + if (placementNode === null) { + element.append(target); + } else { + placementNode.insertBefore(target); + } + // Transfer the element point to a text point. + if (start.is(end)) { + end.set(textNode.__key, 0, 'text'); + } + start.set(textNode.__key, 0, 'text'); +} + +function $setPointValues( + point: PointType, + key: NodeKey, + offset: number, + type: 'text' | 'element', +): void { + point.key = key; + point.offset = offset; + point.type = type; +} + +export interface BaseSelection { + _cachedNodes: Array | null; + dirty: boolean; + + clone(): BaseSelection; + extract(): Array; + getNodes(): Array; + getTextContent(): string; + insertText(text: string): void; + insertRawText(text: string): void; + is(selection: null | BaseSelection): boolean; + insertNodes(nodes: Array): void; + getStartEndPoints(): null | [PointType, PointType]; + isCollapsed(): boolean; + isBackward(): boolean; + getCachedNodes(): LexicalNode[] | null; + setCachedNodes(nodes: LexicalNode[] | null): void; +} + +export class NodeSelection implements BaseSelection { + _nodes: Set; + _cachedNodes: Array | null; + dirty: boolean; + + constructor(objects: Set) { + this._cachedNodes = null; + this._nodes = objects; + this.dirty = false; + } + + getCachedNodes(): LexicalNode[] | null { + return this._cachedNodes; + } + + setCachedNodes(nodes: LexicalNode[] | null): void { + this._cachedNodes = nodes; + } + + is(selection: null | BaseSelection): boolean { + if (!$isNodeSelection(selection)) { + return false; + } + const a: Set = this._nodes; + const b: Set = selection._nodes; + return a.size === b.size && Array.from(a).every((key) => b.has(key)); + } + + isCollapsed(): boolean { + return false; + } + + isBackward(): boolean { + return false; + } + + getStartEndPoints(): null { + return null; + } + + add(key: NodeKey): void { + this.dirty = true; + this._nodes.add(key); + this._cachedNodes = null; + } + + delete(key: NodeKey): void { + this.dirty = true; + this._nodes.delete(key); + this._cachedNodes = null; + } + + clear(): void { + this.dirty = true; + this._nodes.clear(); + this._cachedNodes = null; + } + + has(key: NodeKey): boolean { + return this._nodes.has(key); + } + + clone(): NodeSelection { + return new NodeSelection(new Set(this._nodes)); + } + + extract(): Array { + return this.getNodes(); + } + + insertRawText(text: string): void { + // Do nothing? + } + + insertText(): void { + // Do nothing? + } + + insertNodes(nodes: Array) { + const selectedNodes = this.getNodes(); + const selectedNodesLength = selectedNodes.length; + const lastSelectedNode = selectedNodes[selectedNodesLength - 1]; + let selectionAtEnd: RangeSelection; + // Insert nodes + if ($isTextNode(lastSelectedNode)) { + selectionAtEnd = lastSelectedNode.select(); + } else { + const index = lastSelectedNode.getIndexWithinParent() + 1; + selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index); + } + selectionAtEnd.insertNodes(nodes); + // Remove selected nodes + for (let i = 0; i < selectedNodesLength; i++) { + selectedNodes[i].remove(); + } + } + + getNodes(): Array { + const cachedNodes = this._cachedNodes; + if (cachedNodes !== null) { + return cachedNodes; + } + const objects = this._nodes; + const nodes = []; + for (const object of objects) { + const node = $getNodeByKey(object); + if (node !== null) { + nodes.push(node); + } + } + if (!isCurrentlyReadOnlyMode()) { + this._cachedNodes = nodes; + } + return nodes; + } + + getTextContent(): string { + const nodes = this.getNodes(); + let textContent = ''; + for (let i = 0; i < nodes.length; i++) { + textContent += nodes[i].getTextContent(); + } + return textContent; + } +} + +export function $isRangeSelection(x: unknown): x is RangeSelection { + return x instanceof RangeSelection; +} + +export class RangeSelection implements BaseSelection { + format: number; + style: string; + anchor: PointType; + focus: PointType; + _cachedNodes: Array | null; + dirty: boolean; + + constructor( + anchor: PointType, + focus: PointType, + format: number, + style: string, + ) { + this.anchor = anchor; + this.focus = focus; + anchor._selection = this; + focus._selection = this; + this._cachedNodes = null; + this.format = format; + this.style = style; + this.dirty = false; + } + + getCachedNodes(): LexicalNode[] | null { + return this._cachedNodes; + } + + setCachedNodes(nodes: LexicalNode[] | null): void { + this._cachedNodes = nodes; + } + + /** + * Used to check if the provided selections is equal to this one by value, + * inluding anchor, focus, format, and style properties. + * @param selection - the Selection to compare this one to. + * @returns true if the Selections are equal, false otherwise. + */ + is(selection: null | BaseSelection): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + return ( + this.anchor.is(selection.anchor) && + this.focus.is(selection.focus) && + this.format === selection.format && + this.style === selection.style + ); + } + + /** + * Returns whether the Selection is "collapsed", meaning the anchor and focus are + * the same node and have the same offset. + * + * @returns true if the Selection is collapsed, false otherwise. + */ + isCollapsed(): boolean { + return this.anchor.is(this.focus); + } + + /** + * Gets all the nodes in the Selection. Uses caching to make it generally suitable + * for use in hot paths. + * + * @returns an Array containing all the nodes in the Selection + */ + getNodes(): Array { + const cachedNodes = this._cachedNodes; + if (cachedNodes !== null) { + return cachedNodes; + } + const anchor = this.anchor; + const focus = this.focus; + const isBefore = anchor.isBefore(focus); + const firstPoint = isBefore ? anchor : focus; + const lastPoint = isBefore ? focus : anchor; + let firstNode = firstPoint.getNode(); + let lastNode = lastPoint.getNode(); + const startOffset = firstPoint.offset; + const endOffset = lastPoint.offset; + + if ($isElementNode(firstNode)) { + const firstNodeDescendant = + firstNode.getDescendantByIndex(startOffset); + firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode; + } + if ($isElementNode(lastNode)) { + let lastNodeDescendant = + lastNode.getDescendantByIndex(endOffset); + // We don't want to over-select, as node selection infers the child before + // the last descendant, not including that descendant. + if ( + lastNodeDescendant !== null && + lastNodeDescendant !== firstNode && + lastNode.getChildAtIndex(endOffset) === lastNodeDescendant + ) { + lastNodeDescendant = lastNodeDescendant.getPreviousSibling(); + } + lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode; + } + + let nodes: Array; + + if (firstNode.is(lastNode)) { + if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) { + nodes = []; + } else { + nodes = [firstNode]; + } + } else { + nodes = firstNode.getNodesBetween(lastNode); + } + if (!isCurrentlyReadOnlyMode()) { + this._cachedNodes = nodes; + } + return nodes; + } + + /** + * Sets this Selection to be of type "text" at the provided anchor and focus values. + * + * @param anchorNode - the anchor node to set on the Selection + * @param anchorOffset - the offset to set on the Selection + * @param focusNode - the focus node to set on the Selection + * @param focusOffset - the focus offset to set on the Selection + */ + setTextNodeRange( + anchorNode: TextNode, + anchorOffset: number, + focusNode: TextNode, + focusOffset: number, + ): void { + $setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text'); + $setPointValues(this.focus, focusNode.__key, focusOffset, 'text'); + this._cachedNodes = null; + this.dirty = true; + } + + /** + * Gets the (plain) text content of all the nodes in the selection. + * + * @returns a string representing the text content of all the nodes in the Selection + */ + getTextContent(): string { + const nodes = this.getNodes(); + if (nodes.length === 0) { + return ''; + } + const firstNode = nodes[0]; + const lastNode = nodes[nodes.length - 1]; + const anchor = this.anchor; + const focus = this.focus; + const isBefore = anchor.isBefore(focus); + const [anchorOffset, focusOffset] = $getCharacterOffsets(this); + let textContent = ''; + let prevWasElement = true; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isElementNode(node) && !node.isInline()) { + if (!prevWasElement) { + textContent += '\n'; + } + if (node.isEmpty()) { + prevWasElement = false; + } else { + prevWasElement = true; + } + } else { + prevWasElement = false; + if ($isTextNode(node)) { + let text = node.getTextContent(); + if (node === firstNode) { + if (node === lastNode) { + if ( + anchor.type !== 'element' || + focus.type !== 'element' || + focus.offset === anchor.offset + ) { + text = + anchorOffset < focusOffset + ? text.slice(anchorOffset, focusOffset) + : text.slice(focusOffset, anchorOffset); + } + } else { + text = isBefore + ? text.slice(anchorOffset) + : text.slice(focusOffset); + } + } else if (node === lastNode) { + text = isBefore + ? text.slice(0, focusOffset) + : text.slice(0, anchorOffset); + } + textContent += text; + } else if ( + ($isDecoratorNode(node) || $isLineBreakNode(node)) && + (node !== lastNode || !this.isCollapsed()) + ) { + textContent += node.getTextContent(); + } + } + } + return textContent; + } + + /** + * Attempts to map a DOM selection range onto this Lexical Selection, + * setting the anchor, focus, and type accordingly + * + * @param range a DOM Selection range conforming to the StaticRange interface. + */ + applyDOMRange(range: StaticRange): void { + const editor = getActiveEditor(); + const currentEditorState = editor.getEditorState(); + const lastSelection = currentEditorState._selection; + const resolvedSelectionPoints = $internalResolveSelectionPoints( + range.startContainer, + range.startOffset, + range.endContainer, + range.endOffset, + editor, + lastSelection, + ); + if (resolvedSelectionPoints === null) { + return; + } + const [anchorPoint, focusPoint] = resolvedSelectionPoints; + $setPointValues( + this.anchor, + anchorPoint.key, + anchorPoint.offset, + anchorPoint.type, + ); + $setPointValues( + this.focus, + focusPoint.key, + focusPoint.offset, + focusPoint.type, + ); + this._cachedNodes = null; + } + + /** + * Creates a new RangeSelection, copying over all the property values from this one. + * + * @returns a new RangeSelection with the same property values as this one. + */ + clone(): RangeSelection { + const anchor = this.anchor; + const focus = this.focus; + const selection = new RangeSelection( + $createPoint(anchor.key, anchor.offset, anchor.type), + $createPoint(focus.key, focus.offset, focus.type), + this.format, + this.style, + ); + return selection; + } + + /** + * Toggles the provided format on all the TextNodes in the Selection. + * + * @param format a string TextFormatType to toggle on the TextNodes in the selection + */ + toggleFormat(format: TextFormatType): void { + this.format = toggleTextFormatType(this.format, format, null); + this.dirty = true; + } + + /** + * Sets the value of the style property on the Selection + * + * @param style - the style to set at the value of the style property. + */ + setStyle(style: string): void { + this.style = style; + this.dirty = true; + } + + /** + * Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection + * has the specified format. + * + * @param type the TextFormatType to check for. + * @returns true if the provided format is currently toggled on on the Selection, false otherwise. + */ + hasFormat(type: TextFormatType): boolean { + const formatFlag = TEXT_TYPE_TO_FORMAT[type]; + return (this.format & formatFlag) !== 0; + } + + /** + * Attempts to insert the provided text into the EditorState at the current Selection. + * converts tabs, newlines, and carriage returns into LexicalNodes. + * + * @param text the text to insert into the Selection + */ + insertRawText(text: string): void { + const parts = text.split(/(\r?\n|\t)/); + const nodes = []; + const length = parts.length; + for (let i = 0; i < length; i++) { + const part = parts[i]; + if (part === '\n' || part === '\r\n') { + nodes.push($createLineBreakNode()); + } else if (part === '\t') { + nodes.push($createTabNode()); + } else { + nodes.push($createTextNode(part)); + } + } + this.insertNodes(nodes); + } + + /** + * Attempts to insert the provided text into the EditorState at the current Selection as a new + * Lexical TextNode, according to a series of insertion heuristics based on the selection type and position. + * + * @param text the text to insert into the Selection + */ + insertText(text: string): void { + const anchor = this.anchor; + const focus = this.focus; + const format = this.format; + const style = this.style; + let firstPoint = anchor; + let endPoint = focus; + if (!this.isCollapsed() && focus.isBefore(anchor)) { + firstPoint = focus; + endPoint = anchor; + } + if (firstPoint.type === 'element') { + $transferStartingElementPointToTextPoint( + firstPoint, + endPoint, + format, + style, + ); + } + const startOffset = firstPoint.offset; + let endOffset = endPoint.offset; + const selectedNodes = this.getNodes(); + const selectedNodesLength = selectedNodes.length; + let firstNode: TextNode = selectedNodes[0] as TextNode; + + if (!$isTextNode(firstNode)) { + invariant(false, 'insertText: first node is not a text node'); + } + const firstNodeText = firstNode.getTextContent(); + const firstNodeTextLength = firstNodeText.length; + const firstNodeParent = firstNode.getParentOrThrow(); + const lastIndex = selectedNodesLength - 1; + let lastNode = selectedNodes[lastIndex]; + + if (selectedNodesLength === 1 && endPoint.type === 'element') { + endOffset = firstNodeTextLength; + endPoint.set(firstPoint.key, endOffset, 'text'); + } + + if ( + this.isCollapsed() && + startOffset === firstNodeTextLength && + (firstNode.isSegmented() || + firstNode.isToken() || + !firstNode.canInsertTextAfter() || + (!firstNodeParent.canInsertTextAfter() && + firstNode.getNextSibling() === null)) + ) { + let nextSibling = firstNode.getNextSibling(); + if ( + !$isTextNode(nextSibling) || + !nextSibling.canInsertTextBefore() || + $isTokenOrSegmented(nextSibling) + ) { + nextSibling = $createTextNode(); + nextSibling.setFormat(format); + nextSibling.setStyle(style); + if (!firstNodeParent.canInsertTextAfter()) { + firstNodeParent.insertAfter(nextSibling); + } else { + firstNode.insertAfter(nextSibling); + } + } + nextSibling.select(0, 0); + firstNode = nextSibling; + if (text !== '') { + this.insertText(text); + return; + } + } else if ( + this.isCollapsed() && + startOffset === 0 && + (firstNode.isSegmented() || + firstNode.isToken() || + !firstNode.canInsertTextBefore() || + (!firstNodeParent.canInsertTextBefore() && + firstNode.getPreviousSibling() === null)) + ) { + let prevSibling = firstNode.getPreviousSibling(); + if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) { + prevSibling = $createTextNode(); + prevSibling.setFormat(format); + if (!firstNodeParent.canInsertTextBefore()) { + firstNodeParent.insertBefore(prevSibling); + } else { + firstNode.insertBefore(prevSibling); + } + } + prevSibling.select(); + firstNode = prevSibling; + if (text !== '') { + this.insertText(text); + return; + } + } else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) { + const textNode = $createTextNode(firstNode.getTextContent()); + textNode.setFormat(format); + firstNode.replace(textNode); + firstNode = textNode; + } else if (!this.isCollapsed() && text !== '') { + // When the firstNode or lastNode parents are elements that + // do not allow text to be inserted before or after, we first + // clear the content. Then we normalize selection, then insert + // the new content. + const lastNodeParent = lastNode.getParent(); + + if ( + !firstNodeParent.canInsertTextBefore() || + !firstNodeParent.canInsertTextAfter() || + ($isElementNode(lastNodeParent) && + (!lastNodeParent.canInsertTextBefore() || + !lastNodeParent.canInsertTextAfter())) + ) { + this.insertText(''); + $normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null); + this.insertText(text); + return; + } + } + + if (selectedNodesLength === 1) { + if (firstNode.isToken()) { + const textNode = $createTextNode(text); + textNode.select(); + firstNode.replace(textNode); + return; + } + const firstNodeFormat = firstNode.getFormat(); + const firstNodeStyle = firstNode.getStyle(); + + if ( + startOffset === endOffset && + (firstNodeFormat !== format || firstNodeStyle !== style) + ) { + if (firstNode.getTextContent() === '') { + firstNode.setFormat(format); + firstNode.setStyle(style); + } else { + const textNode = $createTextNode(text); + textNode.setFormat(format); + textNode.setStyle(style); + textNode.select(); + if (startOffset === 0) { + firstNode.insertBefore(textNode, false); + } else { + const [targetNode] = firstNode.splitText(startOffset); + targetNode.insertAfter(textNode, false); + } + // When composing, we need to adjust the anchor offset so that + // we correctly replace that right range. + if (textNode.isComposing() && this.anchor.type === 'text') { + this.anchor.offset -= text.length; + } + return; + } + } else if ($isTabNode(firstNode)) { + // We don't need to check for delCount because there is only the entire selected node case + // that can hit here for content size 1 and with canInsertTextBeforeAfter false + const textNode = $createTextNode(text); + textNode.setFormat(format); + textNode.setStyle(style); + textNode.select(); + firstNode.replace(textNode); + return; + } + const delCount = endOffset - startOffset; + + firstNode = firstNode.spliceText(startOffset, delCount, text, true); + if (firstNode.getTextContent() === '') { + firstNode.remove(); + } else if (this.anchor.type === 'text') { + if (firstNode.isComposing()) { + // When composing, we need to adjust the anchor offset so that + // we correctly replace that right range. + this.anchor.offset -= text.length; + } else { + this.format = firstNodeFormat; + this.style = firstNodeStyle; + } + } + } else { + const markedNodeKeysForKeep = new Set([ + ...firstNode.getParentKeys(), + ...lastNode.getParentKeys(), + ]); + + // We have to get the parent elements before the next section, + // as in that section we might mutate the lastNode. + const firstElement = $isElementNode(firstNode) + ? firstNode + : firstNode.getParentOrThrow(); + let lastElement = $isElementNode(lastNode) + ? lastNode + : lastNode.getParentOrThrow(); + let lastElementChild = lastNode; + + // If the last element is inline, we should instead look at getting + // the nodes of its parent, rather than itself. This behavior will + // then better match how text node insertions work. We will need to + // also update the last element's child accordingly as we do this. + if (!firstElement.is(lastElement) && lastElement.isInline()) { + // Keep traversing till we have a non-inline element parent. + do { + lastElementChild = lastElement; + lastElement = lastElement.getParentOrThrow(); + } while (lastElement.isInline()); + } + + // Handle mutations to the last node. + if ( + (endPoint.type === 'text' && + (endOffset !== 0 || lastNode.getTextContent() === '')) || + (endPoint.type === 'element' && + lastNode.getIndexWithinParent() < endOffset) + ) { + if ( + $isTextNode(lastNode) && + !lastNode.isToken() && + endOffset !== lastNode.getTextContentSize() + ) { + if (lastNode.isSegmented()) { + const textNode = $createTextNode(lastNode.getTextContent()); + lastNode.replace(textNode); + lastNode = textNode; + } + // root node selections only select whole nodes, so no text splice is necessary + if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') { + lastNode = (lastNode as TextNode).spliceText(0, endOffset, ''); + } + markedNodeKeysForKeep.add(lastNode.__key); + } else { + const lastNodeParent = lastNode.getParentOrThrow(); + if ( + !lastNodeParent.canBeEmpty() && + lastNodeParent.getChildrenSize() === 1 + ) { + lastNodeParent.remove(); + } else { + lastNode.remove(); + } + } + } else { + markedNodeKeysForKeep.add(lastNode.__key); + } + + // Either move the remaining nodes of the last parent to after + // the first child, or remove them entirely. If the last parent + // is the same as the first parent, this logic also works. + const lastNodeChildren = lastElement.getChildren(); + const selectedNodesSet = new Set(selectedNodes); + const firstAndLastElementsAreEqual = firstElement.is(lastElement); + + // We choose a target to insert all nodes after. In the case of having + // and inline starting parent element with a starting node that has no + // siblings, we should insert after the starting parent element, otherwise + // we will incorrectly merge into the starting parent element. + // TODO: should we keep on traversing parents if we're inside another + // nested inline element? + const insertionTarget = + firstElement.isInline() && firstNode.getNextSibling() === null + ? firstElement + : firstNode; + + for (let i = lastNodeChildren.length - 1; i >= 0; i--) { + const lastNodeChild = lastNodeChildren[i]; + + if ( + lastNodeChild.is(firstNode) || + ($isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode)) + ) { + break; + } + + if (lastNodeChild.isAttached()) { + if ( + !selectedNodesSet.has(lastNodeChild) || + lastNodeChild.is(lastElementChild) + ) { + if (!firstAndLastElementsAreEqual) { + insertionTarget.insertAfter(lastNodeChild, false); + } + } else { + lastNodeChild.remove(); + } + } + } + + if (!firstAndLastElementsAreEqual) { + // Check if we have already moved out all the nodes of the + // last parent, and if so, traverse the parent tree and mark + // them all as being able to deleted too. + let parent: ElementNode | null = lastElement; + let lastRemovedParent = null; + + while (parent !== null) { + const children = parent.getChildren(); + const childrenLength = children.length; + if ( + childrenLength === 0 || + children[childrenLength - 1].is(lastRemovedParent) + ) { + markedNodeKeysForKeep.delete(parent.__key); + lastRemovedParent = parent; + } + parent = parent.getParent(); + } + } + + // Ensure we do splicing after moving of nodes, as splicing + // can have side-effects (in the case of hashtags). + if (!firstNode.isToken()) { + firstNode = firstNode.spliceText( + startOffset, + firstNodeTextLength - startOffset, + text, + true, + ); + if (firstNode.getTextContent() === '') { + firstNode.remove(); + } else if (firstNode.isComposing() && this.anchor.type === 'text') { + // When composing, we need to adjust the anchor offset so that + // we correctly replace that right range. + this.anchor.offset -= text.length; + } + } else if (startOffset === firstNodeTextLength) { + firstNode.select(); + } else { + const textNode = $createTextNode(text); + textNode.select(); + firstNode.replace(textNode); + } + + // Remove all selected nodes that haven't already been removed. + for (let i = 1; i < selectedNodesLength; i++) { + const selectedNode = selectedNodes[i]; + const key = selectedNode.__key; + if (!markedNodeKeysForKeep.has(key)) { + selectedNode.remove(); + } + } + } + } + + /** + * Removes the text in the Selection, adjusting the EditorState accordingly. + */ + removeText(): void { + this.insertText(''); + } + + /** + * Applies the provided format to the TextNodes in the Selection, splitting or + * merging nodes as necessary. + * + * @param formatType the format type to apply to the nodes in the Selection. + */ + formatText(formatType: TextFormatType): void { + if (this.isCollapsed()) { + this.toggleFormat(formatType); + // When changing format, we should stop composition + $setCompositionKey(null); + return; + } + + const selectedNodes = this.getNodes(); + const selectedTextNodes: Array = []; + for (const selectedNode of selectedNodes) { + if ($isTextNode(selectedNode)) { + selectedTextNodes.push(selectedNode); + } + } + + const selectedTextNodesLength = selectedTextNodes.length; + if (selectedTextNodesLength === 0) { + this.toggleFormat(formatType); + // When changing format, we should stop composition + $setCompositionKey(null); + return; + } + + const anchor = this.anchor; + const focus = this.focus; + const isBackward = this.isBackward(); + const startPoint = isBackward ? focus : anchor; + const endPoint = isBackward ? anchor : focus; + + let firstIndex = 0; + let firstNode = selectedTextNodes[0]; + let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset; + + // In case selection started at the end of text node use next text node + if ( + startPoint.type === 'text' && + startOffset === firstNode.getTextContentSize() + ) { + firstIndex = 1; + firstNode = selectedTextNodes[1]; + startOffset = 0; + } + + if (firstNode == null) { + return; + } + + const firstNextFormat = firstNode.getFormatFlags(formatType, null); + + const lastIndex = selectedTextNodesLength - 1; + let lastNode = selectedTextNodes[lastIndex]; + const endOffset = + endPoint.type === 'text' + ? endPoint.offset + : lastNode.getTextContentSize(); + + // Single node selected + if (firstNode.is(lastNode)) { + // No actual text is selected, so do nothing. + if (startOffset === endOffset) { + return; + } + // The entire node is selected or it is token, so just format it + if ( + $isTokenOrSegmented(firstNode) || + (startOffset === 0 && endOffset === firstNode.getTextContentSize()) + ) { + firstNode.setFormat(firstNextFormat); + } else { + // Node is partially selected, so split it into two nodes + // add style the selected one. + const splitNodes = firstNode.splitText(startOffset, endOffset); + const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]; + replacement.setFormat(firstNextFormat); + + // Update selection only if starts/ends on text node + if (startPoint.type === 'text') { + startPoint.set(replacement.__key, 0, 'text'); + } + if (endPoint.type === 'text') { + endPoint.set(replacement.__key, endOffset - startOffset, 'text'); + } + } + + this.format = firstNextFormat; + + return; + } + // Multiple nodes selected + // The entire first node isn't selected, so split it + if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) { + [, firstNode as TextNode] = firstNode.splitText(startOffset); + startOffset = 0; + } + firstNode.setFormat(firstNextFormat); + + const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat); + // If the offset is 0, it means no actual characters are selected, + // so we skip formatting the last node altogether. + if (endOffset > 0) { + if ( + endOffset !== lastNode.getTextContentSize() && + !$isTokenOrSegmented(lastNode) + ) { + [lastNode as TextNode] = lastNode.splitText(endOffset); + } + lastNode.setFormat(lastNextFormat); + } + + // Process all text nodes in between + for (let i = firstIndex + 1; i < lastIndex; i++) { + const textNode = selectedTextNodes[i]; + const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat); + textNode.setFormat(nextFormat); + } + + // Update selection only if starts/ends on text node + if (startPoint.type === 'text') { + startPoint.set(firstNode.__key, startOffset, 'text'); + } + if (endPoint.type === 'text') { + endPoint.set(lastNode.__key, endOffset, 'text'); + } + + this.format = firstNextFormat | lastNextFormat; + } + + /** + * Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the + * current Selection according to a set of heuristics that determine how surrounding nodes + * should be changed, replaced, or moved to accomodate the incoming ones. + * + * @param nodes - the nodes to insert + */ + insertNodes(nodes: Array): void { + if (nodes.length === 0) { + return; + } + if (this.anchor.key === 'root') { + this.insertParagraph(); + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'Expected RangeSelection after insertParagraph', + ); + return selection.insertNodes(nodes); + } + + const firstPoint = this.isBackward() ? this.focus : this.anchor; + const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock)!; + + const last = nodes[nodes.length - 1]!; + + // CASE 1: insert inside a code block + if ('__language' in firstBlock && $isElementNode(firstBlock)) { + if ('__language' in nodes[0]) { + this.insertText(nodes[0].getTextContent()); + } else { + const index = $removeTextAndSplitBlock(this); + firstBlock.splice(index, 0, nodes); + last.selectEnd(); + } + return; + } + + // CASE 2: All elements of the array are inline + const notInline = (node: LexicalNode) => + ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline(); + + if (!nodes.some(notInline)) { + invariant( + $isElementNode(firstBlock), + "Expected 'firstBlock' to be an ElementNode", + ); + const index = $removeTextAndSplitBlock(this); + firstBlock.splice(index, 0, nodes); + last.selectEnd(); + return; + } + + // CASE 3: At least 1 element of the array is not inline + const blocksParent = $wrapInlineNodes(nodes); + const nodeToSelect = blocksParent.getLastDescendant()!; + const blocks = blocksParent.getChildren(); + const isMergeable = (node: LexicalNode): node is ElementNode => + $isElementNode(node) && + INTERNAL_$isBlock(node) && + !node.isEmpty() && + $isElementNode(firstBlock) && + (!firstBlock.isEmpty() || firstBlock.canMergeWhenEmpty()); + + const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty(); + const insertedParagraph = shouldInsert ? this.insertParagraph() : null; + const lastToInsert = blocks[blocks.length - 1]; + let firstToInsert = blocks[0]; + if (isMergeable(firstToInsert)) { + invariant( + $isElementNode(firstBlock), + "Expected 'firstBlock' to be an ElementNode", + ); + firstBlock.append(...firstToInsert.getChildren()); + firstToInsert = blocks[1]; + } + if (firstToInsert) { + insertRangeAfter(firstBlock, firstToInsert); + } + const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock)!; + + if ( + insertedParagraph && + $isElementNode(lastInsertedBlock) && + (insertedParagraph.canMergeWhenEmpty() || INTERNAL_$isBlock(lastToInsert)) + ) { + lastInsertedBlock.append(...insertedParagraph.getChildren()); + insertedParagraph.remove(); + } + if ($isElementNode(firstBlock) && firstBlock.isEmpty()) { + firstBlock.remove(); + } + + nodeToSelect.selectEnd(); + + // To understand this take a look at the test "can wrap post-linebreak nodes into new element" + const lastChild = $isElementNode(firstBlock) + ? firstBlock.getLastChild() + : null; + if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) { + lastChild.remove(); + } + } + + /** + * Inserts a new ParagraphNode into the EditorState at the current Selection + * + * @returns the newly inserted node. + */ + insertParagraph(): ElementNode | null { + if (this.anchor.key === 'root') { + const paragraph = $createParagraphNode(); + $getRoot().splice(this.anchor.offset, 0, [paragraph]); + paragraph.select(); + return paragraph; + } + const index = $removeTextAndSplitBlock(this); + const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock)!; + invariant($isElementNode(block), 'Expected ancestor to be an ElementNode'); + const firstToAppend = block.getChildAtIndex(index); + const nodesToInsert = firstToAppend + ? [firstToAppend, ...firstToAppend.getNextSiblings()] + : []; + const newBlock = block.insertNewAfter(this, false) as ElementNode | null; + if (newBlock) { + newBlock.append(...nodesToInsert); + newBlock.selectStart(); + return newBlock; + } + // if newBlock is null, it means that block is of type CodeNode. + return null; + } + + /** + * Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the + * current Selection. + */ + insertLineBreak(selectStart?: boolean): void { + const lineBreak = $createLineBreakNode(); + this.insertNodes([lineBreak]); + // this is used in MacOS with the command 'ctrl-O' (openLineBreak) + if (selectStart) { + const parent = lineBreak.getParentOrThrow(); + const index = lineBreak.getIndexWithinParent(); + parent.select(index, index); + } + } + + /** + * Extracts the nodes in the Selection, splitting nodes where necessary + * to get offset-level precision. + * + * @returns The nodes in the Selection + */ + extract(): Array { + const selectedNodes = this.getNodes(); + const selectedNodesLength = selectedNodes.length; + const lastIndex = selectedNodesLength - 1; + const anchor = this.anchor; + const focus = this.focus; + let firstNode = selectedNodes[0]; + let lastNode = selectedNodes[lastIndex]; + const [anchorOffset, focusOffset] = $getCharacterOffsets(this); + + if (selectedNodesLength === 0) { + return []; + } else if (selectedNodesLength === 1) { + if ($isTextNode(firstNode) && !this.isCollapsed()) { + const startOffset = + anchorOffset > focusOffset ? focusOffset : anchorOffset; + const endOffset = + anchorOffset > focusOffset ? anchorOffset : focusOffset; + const splitNodes = firstNode.splitText(startOffset, endOffset); + const node = startOffset === 0 ? splitNodes[0] : splitNodes[1]; + return node != null ? [node] : []; + } + return [firstNode]; + } + const isBefore = anchor.isBefore(focus); + + if ($isTextNode(firstNode)) { + const startOffset = isBefore ? anchorOffset : focusOffset; + if (startOffset === firstNode.getTextContentSize()) { + selectedNodes.shift(); + } else if (startOffset !== 0) { + [, firstNode] = firstNode.splitText(startOffset); + selectedNodes[0] = firstNode; + } + } + if ($isTextNode(lastNode)) { + const lastNodeText = lastNode.getTextContent(); + const lastNodeTextLength = lastNodeText.length; + const endOffset = isBefore ? focusOffset : anchorOffset; + if (endOffset === 0) { + selectedNodes.pop(); + } else if (endOffset !== lastNodeTextLength) { + [lastNode] = lastNode.splitText(endOffset); + selectedNodes[lastIndex] = lastNode; + } + } + return selectedNodes; + } + + /** + * Modifies the Selection according to the parameters and a set of heuristics that account for + * various node types. Can be used to safely move or extend selection by one logical "unit" without + * dealing explicitly with all the possible node types. + * + * @param alter the type of modification to perform + * @param isBackward whether or not selection is backwards + * @param granularity the granularity at which to apply the modification + */ + modify( + alter: 'move' | 'extend', + isBackward: boolean, + granularity: 'character' | 'word' | 'lineboundary', + ): void { + const focus = this.focus; + const anchor = this.anchor; + const collapse = alter === 'move'; + + // Handle the selection movement around decorators. + const possibleNode = $getAdjacentNode(focus, isBackward); + if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) { + // Make it possible to move selection from range selection to + // node selection on the node. + if (collapse && possibleNode.isKeyboardSelectable()) { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(possibleNode.__key); + $setSelection(nodeSelection); + return; + } + const sibling = isBackward + ? possibleNode.getPreviousSibling() + : possibleNode.getNextSibling(); + + if (!$isTextNode(sibling)) { + const parent = possibleNode.getParentOrThrow(); + let offset; + let elementKey; + + if ($isElementNode(sibling)) { + elementKey = sibling.__key; + offset = isBackward ? sibling.getChildrenSize() : 0; + } else { + offset = possibleNode.getIndexWithinParent(); + elementKey = parent.__key; + if (!isBackward) { + offset++; + } + } + focus.set(elementKey, offset, 'element'); + if (collapse) { + anchor.set(elementKey, offset, 'element'); + } + return; + } else { + const siblingKey = sibling.__key; + const offset = isBackward ? sibling.getTextContent().length : 0; + focus.set(siblingKey, offset, 'text'); + if (collapse) { + anchor.set(siblingKey, offset, 'text'); + } + return; + } + } + const editor = getActiveEditor(); + const domSelection = getDOMSelection(editor._window); + + if (!domSelection) { + return; + } + const blockCursorElement = editor._blockCursorElement; + const rootElement = editor._rootElement; + // Remove the block cursor element if it exists. This will ensure selection + // works as intended. If we leave it in the DOM all sorts of strange bugs + // occur. :/ + if ( + rootElement !== null && + blockCursorElement !== null && + $isElementNode(possibleNode) && + !possibleNode.isInline() && + !possibleNode.canBeEmpty() + ) { + removeDOMBlockCursorElement(blockCursorElement, editor, rootElement); + } + // We use the DOM selection.modify API here to "tell" us what the selection + // will be. We then use it to update the Lexical selection accordingly. This + // is much more reliable than waiting for a beforeinput and using the ranges + // from getTargetRanges(), and is also better than trying to do it ourselves + // using Intl.Segmenter or other workarounds that struggle with word segments + // and line segments (especially with word wrapping and non-Roman languages). + moveNativeSelection( + domSelection, + alter, + isBackward ? 'backward' : 'forward', + granularity, + ); + // Guard against no ranges + if (domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0); + // Apply the DOM selection to our Lexical selection. + const anchorNode = this.anchor.getNode(); + const root = $isRootNode(anchorNode) + ? anchorNode + : $getNearestRootOrShadowRoot(anchorNode); + this.applyDOMRange(range); + this.dirty = true; + if (!collapse) { + // Validate selection; make sure that the new extended selection respects shadow roots + const nodes = this.getNodes(); + const validNodes = []; + let shrinkSelection = false; + for (let i = 0; i < nodes.length; i++) { + const nextNode = nodes[i]; + if ($hasAncestor(nextNode, root)) { + validNodes.push(nextNode); + } else { + shrinkSelection = true; + } + } + if (shrinkSelection && validNodes.length > 0) { + // validNodes length check is a safeguard against an invalid selection; as getNodes() + // will return an empty array in this case + if (isBackward) { + const firstValidNode = validNodes[0]; + if ($isElementNode(firstValidNode)) { + firstValidNode.selectStart(); + } else { + firstValidNode.getParentOrThrow().selectStart(); + } + } else { + const lastValidNode = validNodes[validNodes.length - 1]; + if ($isElementNode(lastValidNode)) { + lastValidNode.selectEnd(); + } else { + lastValidNode.getParentOrThrow().selectEnd(); + } + } + } + + // Because a range works on start and end, we might need to flip + // the anchor and focus points to match what the DOM has, not what + // the range has specifically. + if ( + domSelection.anchorNode !== range.startContainer || + domSelection.anchorOffset !== range.startOffset + ) { + $swapPoints(this); + } + } + } + } + /** + * Helper for handling forward character and word deletion that prevents element nodes + * like a table, columns layout being destroyed + * + * @param anchor the anchor + * @param anchorNode the anchor node in the selection + * @param isBackward whether or not selection is backwards + */ + forwardDeletion( + anchor: PointType, + anchorNode: TextNode | ElementNode, + isBackward: boolean, + ): boolean { + if ( + !isBackward && + // Delete forward handle case + ((anchor.type === 'element' && + $isElementNode(anchorNode) && + anchor.offset === anchorNode.getChildrenSize()) || + (anchor.type === 'text' && + anchor.offset === anchorNode.getTextContentSize())) + ) { + const parent = anchorNode.getParent(); + const nextSibling = + anchorNode.getNextSibling() || + (parent === null ? null : parent.getNextSibling()); + + if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) { + return true; + } + } + return false; + } + + /** + * Performs one logical character deletion operation on the EditorState based on the current Selection. + * Handles different node types. + * + * @param isBackward whether or not the selection is backwards. + */ + deleteCharacter(isBackward: boolean): void { + const wasCollapsed = this.isCollapsed(); + if (this.isCollapsed()) { + const anchor = this.anchor; + let anchorNode: TextNode | ElementNode | null = anchor.getNode(); + if (this.forwardDeletion(anchor, anchorNode, isBackward)) { + return; + } + + // Handle the deletion around decorators. + const focus = this.focus; + const possibleNode = $getAdjacentNode(focus, isBackward); + if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) { + // Make it possible to move selection from range selection to + // node selection on the node. + if ( + possibleNode.isKeyboardSelectable() && + $isElementNode(anchorNode) && + anchorNode.getChildrenSize() === 0 + ) { + anchorNode.remove(); + const nodeSelection = $createNodeSelection(); + nodeSelection.add(possibleNode.__key); + $setSelection(nodeSelection); + } else { + possibleNode.remove(); + const editor = getActiveEditor(); + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + } + return; + } else if ( + !isBackward && + $isElementNode(possibleNode) && + $isElementNode(anchorNode) && + anchorNode.isEmpty() + ) { + anchorNode.remove(); + possibleNode.selectStart(); + return; + } + this.modify('extend', isBackward, 'character'); + + if (!this.isCollapsed()) { + const focusNode = focus.type === 'text' ? focus.getNode() : null; + anchorNode = anchor.type === 'text' ? anchor.getNode() : null; + + if (focusNode !== null && focusNode.isSegmented()) { + const offset = focus.offset; + const textContentSize = focusNode.getTextContentSize(); + if ( + focusNode.is(anchorNode) || + (isBackward && offset !== textContentSize) || + (!isBackward && offset !== 0) + ) { + $removeSegment(focusNode, isBackward, offset); + return; + } + } else if (anchorNode !== null && anchorNode.isSegmented()) { + const offset = anchor.offset; + const textContentSize = anchorNode.getTextContentSize(); + if ( + anchorNode.is(focusNode) || + (isBackward && offset !== 0) || + (!isBackward && offset !== textContentSize) + ) { + $removeSegment(anchorNode, isBackward, offset); + return; + } + } + $updateCaretSelectionForUnicodeCharacter(this, isBackward); + } else if (isBackward && anchor.offset === 0) { + // Special handling around rich text nodes + const element = + anchor.type === 'element' + ? anchor.getNode() + : anchor.getNode().getParentOrThrow(); + if (element.collapseAtStart(this)) { + return; + } + } + } + this.removeText(); + if ( + isBackward && + !wasCollapsed && + this.isCollapsed() && + this.anchor.type === 'element' && + this.anchor.offset === 0 + ) { + const anchorNode = this.anchor.getNode(); + if ( + anchorNode.isEmpty() && + $isRootNode(anchorNode.getParent()) && + anchorNode.getIndexWithinParent() === 0 + ) { + anchorNode.collapseAtStart(this); + } + } + } + + /** + * Performs one logical line deletion operation on the EditorState based on the current Selection. + * Handles different node types. + * + * @param isBackward whether or not the selection is backwards. + */ + deleteLine(isBackward: boolean): void { + if (this.isCollapsed()) { + // Since `domSelection.modify('extend', ..., 'lineboundary')` works well for text selections + // but doesn't properly handle selections which end on elements, a space character is added + // for such selections transforming their anchor's type to 'text' + const anchorIsElement = this.anchor.type === 'element'; + if (anchorIsElement) { + this.insertText(' '); + } + + this.modify('extend', isBackward, 'lineboundary'); + + // If selection is extended to cover text edge then extend it one character more + // to delete its parent element. Otherwise text content will be deleted but empty + // parent node will remain + const endPoint = isBackward ? this.focus : this.anchor; + if (endPoint.offset === 0) { + this.modify('extend', isBackward, 'character'); + } + + // Adjusts selection to include an extra character added for element anchors to remove it + if (anchorIsElement) { + const startPoint = isBackward ? this.anchor : this.focus; + startPoint.set(startPoint.key, startPoint.offset + 1, startPoint.type); + } + } + this.removeText(); + } + + /** + * Performs one logical word deletion operation on the EditorState based on the current Selection. + * Handles different node types. + * + * @param isBackward whether or not the selection is backwards. + */ + deleteWord(isBackward: boolean): void { + if (this.isCollapsed()) { + const anchor = this.anchor; + const anchorNode: TextNode | ElementNode | null = anchor.getNode(); + if (this.forwardDeletion(anchor, anchorNode, isBackward)) { + return; + } + this.modify('extend', isBackward, 'word'); + } + this.removeText(); + } + + /** + * Returns whether the Selection is "backwards", meaning the focus + * logically precedes the anchor in the EditorState. + * @returns true if the Selection is backwards, false otherwise. + */ + isBackward(): boolean { + return this.focus.isBefore(this.anchor); + } + + getStartEndPoints(): null | [PointType, PointType] { + return [this.anchor, this.focus]; + } +} + +export function $isNodeSelection(x: unknown): x is NodeSelection { + return x instanceof NodeSelection; +} + +function getCharacterOffset(point: PointType): number { + const offset = point.offset; + if (point.type === 'text') { + return offset; + } + + const parent = point.getNode(); + return offset === parent.getChildrenSize() + ? parent.getTextContent().length + : 0; +} + +export function $getCharacterOffsets( + selection: BaseSelection, +): [number, number] { + const anchorAndFocus = selection.getStartEndPoints(); + if (anchorAndFocus === null) { + return [0, 0]; + } + const [anchor, focus] = anchorAndFocus; + if ( + anchor.type === 'element' && + focus.type === 'element' && + anchor.key === focus.key && + anchor.offset === focus.offset + ) { + return [0, 0]; + } + return [getCharacterOffset(anchor), getCharacterOffset(focus)]; +} + +function $swapPoints(selection: RangeSelection): void { + const focus = selection.focus; + const anchor = selection.anchor; + const anchorKey = anchor.key; + const anchorOffset = anchor.offset; + const anchorType = anchor.type; + + $setPointValues(anchor, focus.key, focus.offset, focus.type); + $setPointValues(focus, anchorKey, anchorOffset, anchorType); + selection._cachedNodes = null; +} + +function moveNativeSelection( + domSelection: Selection, + alter: 'move' | 'extend', + direction: 'backward' | 'forward' | 'left' | 'right', + granularity: 'character' | 'word' | 'lineboundary', +): void { + // Selection.modify() method applies a change to the current selection or cursor position, + // but is still non-standard in some browsers. + domSelection.modify(alter, direction, granularity); +} + +function $updateCaretSelectionForUnicodeCharacter( + selection: RangeSelection, + isBackward: boolean, +): void { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + + if ( + anchorNode === focusNode && + anchor.type === 'text' && + focus.type === 'text' + ) { + // Handling of multibyte characters + const anchorOffset = anchor.offset; + const focusOffset = focus.offset; + const isBefore = anchorOffset < focusOffset; + const startOffset = isBefore ? anchorOffset : focusOffset; + const endOffset = isBefore ? focusOffset : anchorOffset; + const characterOffset = endOffset - 1; + + if (startOffset !== characterOffset) { + const text = anchorNode.getTextContent().slice(startOffset, endOffset); + if (!doesContainGrapheme(text)) { + if (isBackward) { + focus.offset = characterOffset; + } else { + anchor.offset = characterOffset; + } + } + } + } else { + // TODO Handling of multibyte characters + } +} + +function $removeSegment( + node: TextNode, + isBackward: boolean, + offset: number, +): void { + const textNode = node; + const textContent = textNode.getTextContent(); + const split = textContent.split(/(?=\s)/g); + const splitLength = split.length; + let segmentOffset = 0; + let restoreOffset: number | undefined = 0; + + for (let i = 0; i < splitLength; i++) { + const text = split[i]; + const isLast = i === splitLength - 1; + restoreOffset = segmentOffset; + segmentOffset += text.length; + + if ( + (isBackward && segmentOffset === offset) || + segmentOffset > offset || + isLast + ) { + split.splice(i, 1); + if (isLast) { + restoreOffset = undefined; + } + break; + } + } + const nextTextContent = split.join('').trim(); + + if (nextTextContent === '') { + textNode.remove(); + } else { + textNode.setTextContent(nextTextContent); + textNode.select(restoreOffset, restoreOffset); + } +} + +function shouldResolveAncestor( + resolvedElement: ElementNode, + resolvedOffset: number, + lastPoint: null | PointType, +): boolean { + const parent = resolvedElement.getParent(); + return ( + lastPoint === null || + parent === null || + !parent.canBeEmpty() || + parent !== lastPoint.getNode() + ); +} + +function $internalResolveSelectionPoint( + dom: Node, + offset: number, + lastPoint: null | PointType, + editor: LexicalEditor, +): null | PointType { + let resolvedOffset = offset; + let resolvedNode: TextNode | LexicalNode | null; + // If we have selection on an element, we will + // need to figure out (using the offset) what text + // node should be selected. + + if (dom.nodeType === DOM_ELEMENT_TYPE) { + // Resolve element to a ElementNode, or TextNode, or null + let moveSelectionToEnd = false; + // Given we're moving selection to another node, selection is + // definitely dirty. + // We use the anchor to find which child node to select + const childNodes = dom.childNodes; + const childNodesLength = childNodes.length; + const blockCursorElement = editor._blockCursorElement; + // If the anchor is the same as length, then this means we + // need to select the very last text node. + if (resolvedOffset === childNodesLength) { + moveSelectionToEnd = true; + resolvedOffset = childNodesLength - 1; + } + let childDOM = childNodes[resolvedOffset]; + let hasBlockCursor = false; + if (childDOM === blockCursorElement) { + childDOM = childNodes[resolvedOffset + 1]; + hasBlockCursor = true; + } else if (blockCursorElement !== null) { + const blockCursorElementParent = blockCursorElement.parentNode; + if (dom === blockCursorElementParent) { + const blockCursorOffset = Array.prototype.indexOf.call( + blockCursorElementParent.children, + blockCursorElement, + ); + if (offset > blockCursorOffset) { + resolvedOffset--; + } + } + } + resolvedNode = $getNodeFromDOM(childDOM); + + if ($isTextNode(resolvedNode)) { + resolvedOffset = getTextNodeOffset(resolvedNode, moveSelectionToEnd); + } else { + let resolvedElement = $getNodeFromDOM(dom); + // Ensure resolvedElement is actually a element. + if (resolvedElement === null) { + return null; + } + if ($isElementNode(resolvedElement)) { + resolvedOffset = Math.min( + resolvedElement.getChildrenSize(), + resolvedOffset, + ); + let child = resolvedElement.getChildAtIndex(resolvedOffset); + if ( + $isElementNode(child) && + shouldResolveAncestor(child, resolvedOffset, lastPoint) + ) { + const descendant = moveSelectionToEnd + ? child.getLastDescendant() + : child.getFirstDescendant(); + if (descendant === null) { + resolvedElement = child; + } else { + child = descendant; + resolvedElement = $isElementNode(child) + ? child + : child.getParentOrThrow(); + } + resolvedOffset = 0; + } + if ($isTextNode(child)) { + resolvedNode = child; + resolvedElement = null; + resolvedOffset = getTextNodeOffset(child, moveSelectionToEnd); + } else if ( + child !== resolvedElement && + moveSelectionToEnd && + !hasBlockCursor + ) { + resolvedOffset++; + } + } else { + const index = resolvedElement.getIndexWithinParent(); + // When selecting decorators, there can be some selection issues when using resolvedOffset, + // and instead we should be checking if we're using the offset + if ( + offset === 0 && + $isDecoratorNode(resolvedElement) && + $getNodeFromDOM(dom) === resolvedElement + ) { + resolvedOffset = index; + } else { + resolvedOffset = index + 1; + } + resolvedElement = resolvedElement.getParentOrThrow(); + } + if ($isElementNode(resolvedElement)) { + return $createPoint(resolvedElement.__key, resolvedOffset, 'element'); + } + } + } else { + // TextNode or null + resolvedNode = $getNodeFromDOM(dom); + } + if (!$isTextNode(resolvedNode)) { + return null; + } + return $createPoint(resolvedNode.__key, resolvedOffset, 'text'); +} + +function resolveSelectionPointOnBoundary( + point: TextPointType, + isBackward: boolean, + isCollapsed: boolean, +): void { + const offset = point.offset; + const node = point.getNode(); + + if (offset === 0) { + const prevSibling = node.getPreviousSibling(); + const parent = node.getParent(); + + if (!isBackward) { + if ( + $isElementNode(prevSibling) && + !isCollapsed && + prevSibling.isInline() + ) { + point.key = prevSibling.__key; + point.offset = prevSibling.getChildrenSize(); + // @ts-expect-error: intentional + point.type = 'element'; + } else if ($isTextNode(prevSibling)) { + point.key = prevSibling.__key; + point.offset = prevSibling.getTextContent().length; + } + } else if ( + (isCollapsed || !isBackward) && + prevSibling === null && + $isElementNode(parent) && + parent.isInline() + ) { + const parentSibling = parent.getPreviousSibling(); + if ($isTextNode(parentSibling)) { + point.key = parentSibling.__key; + point.offset = parentSibling.getTextContent().length; + } + } + } else if (offset === node.getTextContent().length) { + const nextSibling = node.getNextSibling(); + const parent = node.getParent(); + + if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) { + point.key = nextSibling.__key; + point.offset = 0; + // @ts-expect-error: intentional + point.type = 'element'; + } else if ( + (isCollapsed || isBackward) && + nextSibling === null && + $isElementNode(parent) && + parent.isInline() && + !parent.canInsertTextAfter() + ) { + const parentSibling = parent.getNextSibling(); + if ($isTextNode(parentSibling)) { + point.key = parentSibling.__key; + point.offset = 0; + } + } + } +} + +function $normalizeSelectionPointsForBoundaries( + anchor: PointType, + focus: PointType, + lastSelection: null | BaseSelection, +): void { + if (anchor.type === 'text' && focus.type === 'text') { + const isBackward = anchor.isBefore(focus); + const isCollapsed = anchor.is(focus); + + // Attempt to normalize the offset to the previous sibling if we're at the + // start of a text node and the sibling is a text node or inline element. + resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed); + resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed); + + if (isCollapsed) { + focus.key = anchor.key; + focus.offset = anchor.offset; + focus.type = anchor.type; + } + const editor = getActiveEditor(); + + if ( + editor.isComposing() && + editor._compositionKey !== anchor.key && + $isRangeSelection(lastSelection) + ) { + const lastAnchor = lastSelection.anchor; + const lastFocus = lastSelection.focus; + $setPointValues( + anchor, + lastAnchor.key, + lastAnchor.offset, + lastAnchor.type, + ); + $setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type); + } + } +} + +function $internalResolveSelectionPoints( + anchorDOM: null | Node, + anchorOffset: number, + focusDOM: null | Node, + focusOffset: number, + editor: LexicalEditor, + lastSelection: null | BaseSelection, +): null | [PointType, PointType] { + if ( + anchorDOM === null || + focusDOM === null || + !isSelectionWithinEditor(editor, anchorDOM, focusDOM) + ) { + return null; + } + const resolvedAnchorPoint = $internalResolveSelectionPoint( + anchorDOM, + anchorOffset, + $isRangeSelection(lastSelection) ? lastSelection.anchor : null, + editor, + ); + if (resolvedAnchorPoint === null) { + return null; + } + const resolvedFocusPoint = $internalResolveSelectionPoint( + focusDOM, + focusOffset, + $isRangeSelection(lastSelection) ? lastSelection.focus : null, + editor, + ); + if (resolvedFocusPoint === null) { + return null; + } + if ( + resolvedAnchorPoint.type === 'element' && + resolvedFocusPoint.type === 'element' + ) { + const anchorNode = $getNodeFromDOM(anchorDOM); + const focusNode = $getNodeFromDOM(focusDOM); + // Ensure if we're selecting the content of a decorator that we + // return null for this point, as it's not in the controlled scope + // of Lexical. + if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) { + return null; + } + } + + // Handle normalization of selection when it is at the boundaries. + $normalizeSelectionPointsForBoundaries( + resolvedAnchorPoint, + resolvedFocusPoint, + lastSelection, + ); + + return [resolvedAnchorPoint, resolvedFocusPoint]; +} + +export function $isBlockElementNode( + node: LexicalNode | null | undefined, +): node is ElementNode { + return $isElementNode(node) && !node.isInline(); +} + +// This is used to make a selection when the existing +// selection is null, i.e. forcing selection on the editor +// when it current exists outside the editor. + +export function $internalMakeRangeSelection( + anchorKey: NodeKey, + anchorOffset: number, + focusKey: NodeKey, + focusOffset: number, + anchorType: 'text' | 'element', + focusType: 'text' | 'element', +): RangeSelection { + const editorState = getActiveEditorState(); + const selection = new RangeSelection( + $createPoint(anchorKey, anchorOffset, anchorType), + $createPoint(focusKey, focusOffset, focusType), + 0, + '', + ); + selection.dirty = true; + editorState._selection = selection; + return selection; +} + +export function $createRangeSelection(): RangeSelection { + const anchor = $createPoint('root', 0, 'element'); + const focus = $createPoint('root', 0, 'element'); + return new RangeSelection(anchor, focus, 0, ''); +} + +export function $createNodeSelection(): NodeSelection { + return new NodeSelection(new Set()); +} + +export function $internalCreateSelection( + editor: LexicalEditor, +): null | BaseSelection { + const currentEditorState = editor.getEditorState(); + const lastSelection = currentEditorState._selection; + const domSelection = getDOMSelection(editor._window); + + if ($isRangeSelection(lastSelection) || lastSelection == null) { + return $internalCreateRangeSelection( + lastSelection, + domSelection, + editor, + null, + ); + } + return lastSelection.clone(); +} + +export function $createRangeSelectionFromDom( + domSelection: Selection | null, + editor: LexicalEditor, +): null | RangeSelection { + return $internalCreateRangeSelection(null, domSelection, editor, null); +} + +export function $internalCreateRangeSelection( + lastSelection: null | BaseSelection, + domSelection: Selection | null, + editor: LexicalEditor, + event: UIEvent | Event | null, +): null | RangeSelection { + const windowObj = editor._window; + if (windowObj === null) { + return null; + } + // When we create a selection, we try to use the previous + // selection where possible, unless an actual user selection + // change has occurred. When we do need to create a new selection + // we validate we can have text nodes for both anchor and focus + // nodes. If that holds true, we then return that selection + // as a mutable object that we use for the editor state for this + // update cycle. If a selection gets changed, and requires a + // update to native DOM selection, it gets marked as "dirty". + // If the selection changes, but matches with the existing + // DOM selection, then we only need to sync it. Otherwise, + // we generally bail out of doing an update to selection during + // reconciliation unless there are dirty nodes that need + // reconciling. + + const windowEvent = event || windowObj.event; + const eventType = windowEvent ? windowEvent.type : undefined; + const isSelectionChange = eventType === 'selectionchange'; + const useDOMSelection = + !getIsProcessingMutations() && + (isSelectionChange || + eventType === 'beforeinput' || + eventType === 'compositionstart' || + eventType === 'compositionend' || + (eventType === 'click' && + windowEvent && + (windowEvent as InputEvent).detail === 3) || + eventType === 'drop' || + eventType === undefined); + let anchorDOM, focusDOM, anchorOffset, focusOffset; + + if (!$isRangeSelection(lastSelection) || useDOMSelection) { + if (domSelection === null) { + return null; + } + anchorDOM = domSelection.anchorNode; + focusDOM = domSelection.focusNode; + anchorOffset = domSelection.anchorOffset; + focusOffset = domSelection.focusOffset; + if ( + isSelectionChange && + $isRangeSelection(lastSelection) && + !isSelectionWithinEditor(editor, anchorDOM, focusDOM) + ) { + return lastSelection.clone(); + } + } else { + return lastSelection.clone(); + } + // Let's resolve the text nodes from the offsets and DOM nodes we have from + // native selection. + const resolvedSelectionPoints = $internalResolveSelectionPoints( + anchorDOM, + anchorOffset, + focusDOM, + focusOffset, + editor, + lastSelection, + ); + if (resolvedSelectionPoints === null) { + return null; + } + const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints; + return new RangeSelection( + resolvedAnchorPoint, + resolvedFocusPoint, + !$isRangeSelection(lastSelection) ? 0 : lastSelection.format, + !$isRangeSelection(lastSelection) ? '' : lastSelection.style, + ); +} + +export function $getSelection(): null | BaseSelection { + const editorState = getActiveEditorState(); + return editorState._selection; +} + +export function $getPreviousSelection(): null | BaseSelection { + const editor = getActiveEditor(); + return editor._editorState._selection; +} + +export function $updateElementSelectionOnCreateDeleteNode( + selection: RangeSelection, + parentNode: LexicalNode, + nodeOffset: number, + times = 1, +): void { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) { + return; + } + const parentKey = parentNode.__key; + // Single node. We shift selection but never redimension it + if (selection.isCollapsed()) { + const selectionOffset = anchor.offset; + if ( + (nodeOffset <= selectionOffset && times > 0) || + (nodeOffset < selectionOffset && times < 0) + ) { + const newSelectionOffset = Math.max(0, selectionOffset + times); + anchor.set(parentKey, newSelectionOffset, 'element'); + focus.set(parentKey, newSelectionOffset, 'element'); + // The new selection might point to text nodes, try to resolve them + $updateSelectionResolveTextNodes(selection); + } + } else { + // Multiple nodes selected. We shift or redimension selection + const isBackward = selection.isBackward(); + const firstPoint = isBackward ? focus : anchor; + const firstPointNode = firstPoint.getNode(); + const lastPoint = isBackward ? anchor : focus; + const lastPointNode = lastPoint.getNode(); + if (parentNode.is(firstPointNode)) { + const firstPointOffset = firstPoint.offset; + if ( + (nodeOffset <= firstPointOffset && times > 0) || + (nodeOffset < firstPointOffset && times < 0) + ) { + firstPoint.set( + parentKey, + Math.max(0, firstPointOffset + times), + 'element', + ); + } + } + if (parentNode.is(lastPointNode)) { + const lastPointOffset = lastPoint.offset; + if ( + (nodeOffset <= lastPointOffset && times > 0) || + (nodeOffset < lastPointOffset && times < 0) + ) { + lastPoint.set( + parentKey, + Math.max(0, lastPointOffset + times), + 'element', + ); + } + } + } + // The new selection might point to text nodes, try to resolve them + $updateSelectionResolveTextNodes(selection); +} + +function $updateSelectionResolveTextNodes(selection: RangeSelection): void { + const anchor = selection.anchor; + const anchorOffset = anchor.offset; + const focus = selection.focus; + const focusOffset = focus.offset; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + if (selection.isCollapsed()) { + if (!$isElementNode(anchorNode)) { + return; + } + const childSize = anchorNode.getChildrenSize(); + const anchorOffsetAtEnd = anchorOffset >= childSize; + const child = anchorOffsetAtEnd + ? anchorNode.getChildAtIndex(childSize - 1) + : anchorNode.getChildAtIndex(anchorOffset); + if ($isTextNode(child)) { + let newOffset = 0; + if (anchorOffsetAtEnd) { + newOffset = child.getTextContentSize(); + } + anchor.set(child.__key, newOffset, 'text'); + focus.set(child.__key, newOffset, 'text'); + } + return; + } + if ($isElementNode(anchorNode)) { + const childSize = anchorNode.getChildrenSize(); + const anchorOffsetAtEnd = anchorOffset >= childSize; + const child = anchorOffsetAtEnd + ? anchorNode.getChildAtIndex(childSize - 1) + : anchorNode.getChildAtIndex(anchorOffset); + if ($isTextNode(child)) { + let newOffset = 0; + if (anchorOffsetAtEnd) { + newOffset = child.getTextContentSize(); + } + anchor.set(child.__key, newOffset, 'text'); + } + } + if ($isElementNode(focusNode)) { + const childSize = focusNode.getChildrenSize(); + const focusOffsetAtEnd = focusOffset >= childSize; + const child = focusOffsetAtEnd + ? focusNode.getChildAtIndex(childSize - 1) + : focusNode.getChildAtIndex(focusOffset); + if ($isTextNode(child)) { + let newOffset = 0; + if (focusOffsetAtEnd) { + newOffset = child.getTextContentSize(); + } + focus.set(child.__key, newOffset, 'text'); + } + } +} + +export function applySelectionTransforms( + nextEditorState: EditorState, + editor: LexicalEditor, +): void { + const prevEditorState = editor.getEditorState(); + const prevSelection = prevEditorState._selection; + const nextSelection = nextEditorState._selection; + if ($isRangeSelection(nextSelection)) { + const anchor = nextSelection.anchor; + const focus = nextSelection.focus; + let anchorNode; + + if (anchor.type === 'text') { + anchorNode = anchor.getNode(); + anchorNode.selectionTransform(prevSelection, nextSelection); + } + if (focus.type === 'text') { + const focusNode = focus.getNode(); + if (anchorNode !== focusNode) { + focusNode.selectionTransform(prevSelection, nextSelection); + } + } + } +} + +export function moveSelectionPointToSibling( + point: PointType, + node: LexicalNode, + parent: ElementNode, + prevSibling: LexicalNode | null, + nextSibling: LexicalNode | null, +): void { + let siblingKey = null; + let offset = 0; + let type: 'text' | 'element' | null = null; + if (prevSibling !== null) { + siblingKey = prevSibling.__key; + if ($isTextNode(prevSibling)) { + offset = prevSibling.getTextContentSize(); + type = 'text'; + } else if ($isElementNode(prevSibling)) { + offset = prevSibling.getChildrenSize(); + type = 'element'; + } + } else { + if (nextSibling !== null) { + siblingKey = nextSibling.__key; + if ($isTextNode(nextSibling)) { + type = 'text'; + } else if ($isElementNode(nextSibling)) { + type = 'element'; + } + } + } + if (siblingKey !== null && type !== null) { + point.set(siblingKey, offset, type); + } else { + offset = node.getIndexWithinParent(); + if (offset === -1) { + // Move selection to end of parent + offset = parent.getChildrenSize(); + } + point.set(parent.__key, offset, 'element'); + } +} + +export function adjustPointOffsetForMergedSibling( + point: PointType, + isBefore: boolean, + key: NodeKey, + target: TextNode, + textLength: number, +): void { + if (point.type === 'text') { + point.key = key; + if (!isBefore) { + point.offset += textLength; + } + } else if (point.offset > target.getIndexWithinParent()) { + point.offset -= 1; + } +} + +export function updateDOMSelection( + prevSelection: BaseSelection | null, + nextSelection: BaseSelection | null, + editor: LexicalEditor, + domSelection: Selection, + tags: Set, + rootElement: HTMLElement, + nodeCount: number, +): void { + const anchorDOMNode = domSelection.anchorNode; + const focusDOMNode = domSelection.focusNode; + const anchorOffset = domSelection.anchorOffset; + const focusOffset = domSelection.focusOffset; + const activeElement = document.activeElement; + + // TODO: make this not hard-coded, and add another config option + // that makes this configurable. + if ( + (tags.has('collaboration') && activeElement !== rootElement) || + (activeElement !== null && + isSelectionCapturedInDecoratorInput(activeElement)) + ) { + return; + } + + if (!$isRangeSelection(nextSelection)) { + // We don't remove selection if the prevSelection is null because + // of editor.setRootElement(). If this occurs on init when the + // editor is already focused, then this can cause the editor to + // lose focus. + if ( + prevSelection !== null && + isSelectionWithinEditor(editor, anchorDOMNode, focusDOMNode) + ) { + domSelection.removeAllRanges(); + } + + return; + } + + const anchor = nextSelection.anchor; + const focus = nextSelection.focus; + const anchorKey = anchor.key; + const focusKey = focus.key; + const anchorDOM = getElementByKeyOrThrow(editor, anchorKey); + const focusDOM = getElementByKeyOrThrow(editor, focusKey); + const nextAnchorOffset = anchor.offset; + const nextFocusOffset = focus.offset; + const nextFormat = nextSelection.format; + const nextStyle = nextSelection.style; + const isCollapsed = nextSelection.isCollapsed(); + let nextAnchorNode: HTMLElement | Text | null = anchorDOM; + let nextFocusNode: HTMLElement | Text | null = focusDOM; + let anchorFormatOrStyleChanged = false; + + if (anchor.type === 'text') { + nextAnchorNode = getDOMTextNode(anchorDOM); + const anchorNode = anchor.getNode(); + anchorFormatOrStyleChanged = + anchorNode.getFormat() !== nextFormat || + anchorNode.getStyle() !== nextStyle; + } else if ( + $isRangeSelection(prevSelection) && + prevSelection.anchor.type === 'text' + ) { + anchorFormatOrStyleChanged = true; + } + + if (focus.type === 'text') { + nextFocusNode = getDOMTextNode(focusDOM); + } + + // If we can't get an underlying text node for selection, then + // we should avoid setting selection to something incorrect. + if (nextAnchorNode === null || nextFocusNode === null) { + return; + } + + if ( + isCollapsed && + (prevSelection === null || + anchorFormatOrStyleChanged || + ($isRangeSelection(prevSelection) && + (prevSelection.format !== nextFormat || + prevSelection.style !== nextStyle))) + ) { + markCollapsedSelectionFormat( + nextFormat, + nextStyle, + nextAnchorOffset, + anchorKey, + performance.now(), + ); + } + + // Diff against the native DOM selection to ensure we don't do + // an unnecessary selection update. We also skip this check if + // we're moving selection to within an element, as this can + // sometimes be problematic around scrolling. + if ( + anchorOffset === nextAnchorOffset && + focusOffset === nextFocusOffset && + anchorDOMNode === nextAnchorNode && + focusDOMNode === nextFocusNode && // Badly interpreted range selection when collapsed - #1482 + !(domSelection.type === 'Range' && isCollapsed) + ) { + // If the root element does not have focus, ensure it has focus + if (activeElement === null || !rootElement.contains(activeElement)) { + rootElement.focus({ + preventScroll: true, + }); + } + if (anchor.type !== 'element') { + return; + } + } + + // Apply the updated selection to the DOM. Note: this will trigger + // a "selectionchange" event, although it will be asynchronous. + try { + domSelection.setBaseAndExtent( + nextAnchorNode, + nextAnchorOffset, + nextFocusNode, + nextFocusOffset, + ); + } catch (error) { + // If we encounter an error, continue. This can sometimes + // occur with FF and there's no good reason as to why it + // should happen. + if (__DEV__) { + console.warn(error); + } + } + if ( + !tags.has('skip-scroll-into-view') && + nextSelection.isCollapsed() && + rootElement !== null && + rootElement === document.activeElement + ) { + const selectionTarget: null | Range | HTMLElement | Text = + nextSelection instanceof RangeSelection && + nextSelection.anchor.type === 'element' + ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) || + null + : domSelection.rangeCount > 0 + ? domSelection.getRangeAt(0) + : null; + if (selectionTarget !== null) { + let selectionRect: DOMRect; + if (selectionTarget instanceof Text) { + const range = document.createRange(); + range.selectNode(selectionTarget); + selectionRect = range.getBoundingClientRect(); + } else { + selectionRect = selectionTarget.getBoundingClientRect(); + } + scrollIntoViewIfNeeded(editor, selectionRect, rootElement); + } + } + + markSelectionChangeFromDOMUpdate(); +} + +export function $insertNodes(nodes: Array) { + let selection = $getSelection() || $getPreviousSelection(); + + if (selection === null) { + selection = $getRoot().selectEnd(); + } + selection.insertNodes(nodes); +} + +export function $getTextContent(): string { + const selection = $getSelection(); + if (selection === null) { + return ''; + } + return selection.getTextContent(); +} + +function $removeTextAndSplitBlock(selection: RangeSelection): number { + let selection_ = selection; + if (!selection.isCollapsed()) { + selection_.removeText(); + } + // A new selection can originate as a result of node replacement, in which case is registered via + // $setSelection + const newSelection = $getSelection(); + if ($isRangeSelection(newSelection)) { + selection_ = newSelection; + } + + invariant( + $isRangeSelection(selection_), + 'Unexpected dirty selection to be null', + ); + + const anchor = selection_.anchor; + let node = anchor.getNode(); + let offset = anchor.offset; + + while (!INTERNAL_$isBlock(node)) { + [node, offset] = $splitNodeAtPoint(node, offset); + } + + return offset; +} + +function $splitNodeAtPoint( + node: LexicalNode, + offset: number, +): [parent: ElementNode, offset: number] { + const parent = node.getParent(); + if (!parent) { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.select(); + return [$getRoot(), 0]; + } + + if ($isTextNode(node)) { + const split = node.splitText(offset); + if (split.length === 0) { + return [parent, node.getIndexWithinParent()]; + } + const x = offset === 0 ? 0 : 1; + const index = split[0].getIndexWithinParent() + x; + + return [parent, index]; + } + + if (!$isElementNode(node) || offset === 0) { + return [parent, node.getIndexWithinParent()]; + } + + const firstToAppend = node.getChildAtIndex(offset); + if (firstToAppend) { + const insertPoint = new RangeSelection( + $createPoint(node.__key, offset, 'element'), + $createPoint(node.__key, offset, 'element'), + 0, + '', + ); + const newElement = node.insertNewAfter(insertPoint) as ElementNode | null; + if (newElement) { + newElement.append(firstToAppend, ...firstToAppend.getNextSiblings()); + } + } + return [parent, node.getIndexWithinParent() + 1]; +} + +function $wrapInlineNodes(nodes: LexicalNode[]) { + // We temporarily insert the topLevelNodes into an arbitrary ElementNode, + // since insertAfter does not work on nodes that have no parent (TO-DO: fix that). + const virtualRoot = $createParagraphNode(); + + let currentBlock = null; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + const isLineBreakNode = $isLineBreakNode(node); + + if ( + isLineBreakNode || + ($isDecoratorNode(node) && node.isInline()) || + ($isElementNode(node) && node.isInline()) || + $isTextNode(node) || + node.isParentRequired() + ) { + if (currentBlock === null) { + currentBlock = node.createParentElementNode(); + virtualRoot.append(currentBlock); + // In the case of LineBreakNode, we just need to + // add an empty ParagraphNode to the topLevelBlocks. + if (isLineBreakNode) { + continue; + } + } + + if (currentBlock !== null) { + currentBlock.append(node); + } + } else { + virtualRoot.append(node); + currentBlock = null; + } + } + + return virtualRoot; +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalUpdates.ts b/resources/js/wysiwyg/lexical/core/LexicalUpdates.ts new file mode 100644 index 000000000..86ed2740f --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalUpdates.ts @@ -0,0 +1,1035 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {SerializedEditorState} from './LexicalEditorState'; +import type {LexicalNode, SerializedLexicalNode} from './LexicalNode'; + +import invariant from 'lexical/shared/invariant'; + +import {$isElementNode, $isTextNode, SELECTION_CHANGE_COMMAND} from '.'; +import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; +import { + CommandPayloadType, + EditorUpdateOptions, + LexicalCommand, + LexicalEditor, + Listener, + MutatedNodes, + RegisteredNodes, + resetEditor, + Transform, +} from './LexicalEditor'; +import { + cloneEditorState, + createEmptyEditorState, + EditorState, + editorStateHasDirtySelection, +} from './LexicalEditorState'; +import { + $garbageCollectDetachedDecorators, + $garbageCollectDetachedNodes, +} from './LexicalGC'; +import {initMutationObserver} from './LexicalMutations'; +import {$normalizeTextNode} from './LexicalNormalization'; +import {$reconcileRoot} from './LexicalReconciler'; +import { + $internalCreateSelection, + $isNodeSelection, + $isRangeSelection, + applySelectionTransforms, + updateDOMSelection, +} from './LexicalSelection'; +import { + $getCompositionKey, + getDOMSelection, + getEditorPropertyFromDOMNode, + getEditorStateTextContent, + getEditorsToPropagate, + getRegisteredNodeOrThrow, + isLexicalEditor, + removeDOMBlockCursorElement, + scheduleMicroTask, + updateDOMBlockCursorElement, +} from './LexicalUtils'; + +let activeEditorState: null | EditorState = null; +let activeEditor: null | LexicalEditor = null; +let isReadOnlyMode = false; +let isAttemptingToRecoverFromReconcilerError = false; +let infiniteTransformCount = 0; + +const observerOptions = { + characterData: true, + childList: true, + subtree: true, +}; + +export function isCurrentlyReadOnlyMode(): boolean { + return ( + isReadOnlyMode || + (activeEditorState !== null && activeEditorState._readOnly) + ); +} + +export function errorOnReadOnly(): void { + if (isReadOnlyMode) { + invariant(false, 'Cannot use method in read-only mode.'); + } +} + +export function errorOnInfiniteTransforms(): void { + if (infiniteTransformCount > 99) { + invariant( + false, + 'One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.', + ); + } +} + +export function getActiveEditorState(): EditorState { + if (activeEditorState === null) { + invariant( + false, + 'Unable to find an active editor state. ' + + 'State helpers or node methods can only be used ' + + 'synchronously during the callback of ' + + 'editor.update(), editor.read(), or editorState.read().%s', + collectBuildInformation(), + ); + } + + return activeEditorState; +} + +export function getActiveEditor(): LexicalEditor { + if (activeEditor === null) { + invariant( + false, + 'Unable to find an active editor. ' + + 'This method can only be used ' + + 'synchronously during the callback of ' + + 'editor.update() or editor.read().%s', + collectBuildInformation(), + ); + } + return activeEditor; +} + +function collectBuildInformation(): string { + let compatibleEditors = 0; + const incompatibleEditors = new Set(); + const thisVersion = LexicalEditor.version; + if (typeof window !== 'undefined') { + for (const node of document.querySelectorAll('[contenteditable]')) { + const editor = getEditorPropertyFromDOMNode(node); + if (isLexicalEditor(editor)) { + compatibleEditors++; + } else if (editor) { + let version = String( + ( + editor.constructor as typeof editor['constructor'] & + Record + ).version || '<0.17.1', + ); + if (version === thisVersion) { + version += + ' (separately built, likely a bundler configuration issue)'; + } + incompatibleEditors.add(version); + } + } + } + let output = ` Detected on the page: ${compatibleEditors} compatible editor(s) with version ${thisVersion}`; + if (incompatibleEditors.size) { + output += ` and incompatible editors with versions ${Array.from( + incompatibleEditors, + ).join(', ')}`; + } + return output; +} + +export function internalGetActiveEditor(): LexicalEditor | null { + return activeEditor; +} + +export function internalGetActiveEditorState(): EditorState | null { + return activeEditorState; +} + +export function $applyTransforms( + editor: LexicalEditor, + node: LexicalNode, + transformsCache: Map>>, +) { + const type = node.__type; + const registeredNode = getRegisteredNodeOrThrow(editor, type); + let transformsArr = transformsCache.get(type); + + if (transformsArr === undefined) { + transformsArr = Array.from(registeredNode.transforms); + transformsCache.set(type, transformsArr); + } + + const transformsArrLength = transformsArr.length; + + for (let i = 0; i < transformsArrLength; i++) { + transformsArr[i](node); + + if (!node.isAttached()) { + break; + } + } +} + +function $isNodeValidForTransform( + node: LexicalNode, + compositionKey: null | string, +): boolean { + return ( + node !== undefined && + // We don't want to transform nodes being composed + node.__key !== compositionKey && + node.isAttached() + ); +} + +function $normalizeAllDirtyTextNodes( + editorState: EditorState, + editor: LexicalEditor, +): void { + const dirtyLeaves = editor._dirtyLeaves; + const nodeMap = editorState._nodeMap; + + for (const nodeKey of dirtyLeaves) { + const node = nodeMap.get(nodeKey); + + if ( + $isTextNode(node) && + node.isAttached() && + node.isSimpleText() && + !node.isUnmergeable() + ) { + $normalizeTextNode(node); + } + } +} + +/** + * Transform heuristic: + * 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1. + * The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too. + * 2. We transform elements. If element transforms generate additional dirty nodes we repeat step 1. + * If element transforms only generate additional dirty elements we only repeat step 2. + * + * Note that to keep track of newly dirty nodes and subtrees we leverage the editor._dirtyNodes and + * editor._subtrees which we reset in every loop. + */ +function $applyAllTransforms( + editorState: EditorState, + editor: LexicalEditor, +): void { + const dirtyLeaves = editor._dirtyLeaves; + const dirtyElements = editor._dirtyElements; + const nodeMap = editorState._nodeMap; + const compositionKey = $getCompositionKey(); + const transformsCache = new Map(); + + let untransformedDirtyLeaves = dirtyLeaves; + let untransformedDirtyLeavesLength = untransformedDirtyLeaves.size; + let untransformedDirtyElements = dirtyElements; + let untransformedDirtyElementsLength = untransformedDirtyElements.size; + + while ( + untransformedDirtyLeavesLength > 0 || + untransformedDirtyElementsLength > 0 + ) { + if (untransformedDirtyLeavesLength > 0) { + // We leverage editor._dirtyLeaves to track the new dirty leaves after the transforms + editor._dirtyLeaves = new Set(); + + for (const nodeKey of untransformedDirtyLeaves) { + const node = nodeMap.get(nodeKey); + + if ( + $isTextNode(node) && + node.isAttached() && + node.isSimpleText() && + !node.isUnmergeable() + ) { + $normalizeTextNode(node); + } + + if ( + node !== undefined && + $isNodeValidForTransform(node, compositionKey) + ) { + $applyTransforms(editor, node, transformsCache); + } + + dirtyLeaves.add(nodeKey); + } + + untransformedDirtyLeaves = editor._dirtyLeaves; + untransformedDirtyLeavesLength = untransformedDirtyLeaves.size; + + // We want to prioritize node transforms over element transforms + if (untransformedDirtyLeavesLength > 0) { + infiniteTransformCount++; + continue; + } + } + + // All dirty leaves have been processed. Let's do elements! + // We have previously processed dirty leaves, so let's restart the editor leaves Set to track + // new ones caused by element transforms + editor._dirtyLeaves = new Set(); + editor._dirtyElements = new Map(); + + for (const currentUntransformedDirtyElement of untransformedDirtyElements) { + const nodeKey = currentUntransformedDirtyElement[0]; + const intentionallyMarkedAsDirty = currentUntransformedDirtyElement[1]; + if (nodeKey !== 'root' && !intentionallyMarkedAsDirty) { + continue; + } + + const node = nodeMap.get(nodeKey); + + if ( + node !== undefined && + $isNodeValidForTransform(node, compositionKey) + ) { + $applyTransforms(editor, node, transformsCache); + } + + dirtyElements.set(nodeKey, intentionallyMarkedAsDirty); + } + + untransformedDirtyLeaves = editor._dirtyLeaves; + untransformedDirtyLeavesLength = untransformedDirtyLeaves.size; + untransformedDirtyElements = editor._dirtyElements; + untransformedDirtyElementsLength = untransformedDirtyElements.size; + infiniteTransformCount++; + } + + editor._dirtyLeaves = dirtyLeaves; + editor._dirtyElements = dirtyElements; +} + +type InternalSerializedNode = { + children?: Array; + type: string; + version: number; +}; + +export function $parseSerializedNode( + serializedNode: SerializedLexicalNode, +): LexicalNode { + const internalSerializedNode: InternalSerializedNode = serializedNode; + return $parseSerializedNodeImpl( + internalSerializedNode, + getActiveEditor()._nodes, + ); +} + +function $parseSerializedNodeImpl< + SerializedNode extends InternalSerializedNode, +>( + serializedNode: SerializedNode, + registeredNodes: RegisteredNodes, +): LexicalNode { + const type = serializedNode.type; + const registeredNode = registeredNodes.get(type); + + if (registeredNode === undefined) { + invariant(false, 'parseEditorState: type "%s" + not found', type); + } + + const nodeClass = registeredNode.klass; + + if (serializedNode.type !== nodeClass.getType()) { + invariant( + false, + 'LexicalNode: Node %s does not implement .importJSON().', + nodeClass.name, + ); + } + + const node = nodeClass.importJSON(serializedNode); + const children = serializedNode.children; + + if ($isElementNode(node) && Array.isArray(children)) { + for (let i = 0; i < children.length; i++) { + const serializedJSONChildNode = children[i]; + const childNode = $parseSerializedNodeImpl( + serializedJSONChildNode, + registeredNodes, + ); + node.append(childNode); + } + } + + return node; +} + +export function parseEditorState( + serializedEditorState: SerializedEditorState, + editor: LexicalEditor, + updateFn: void | (() => void), +): EditorState { + const editorState = createEmptyEditorState(); + const previousActiveEditorState = activeEditorState; + const previousReadOnlyMode = isReadOnlyMode; + const previousActiveEditor = activeEditor; + const previousDirtyElements = editor._dirtyElements; + const previousDirtyLeaves = editor._dirtyLeaves; + const previousCloneNotNeeded = editor._cloneNotNeeded; + const previousDirtyType = editor._dirtyType; + editor._dirtyElements = new Map(); + editor._dirtyLeaves = new Set(); + editor._cloneNotNeeded = new Set(); + editor._dirtyType = 0; + activeEditorState = editorState; + isReadOnlyMode = false; + activeEditor = editor; + + try { + const registeredNodes = editor._nodes; + const serializedNode = serializedEditorState.root; + $parseSerializedNodeImpl(serializedNode, registeredNodes); + if (updateFn) { + updateFn(); + } + + // Make the editorState immutable + editorState._readOnly = true; + + if (__DEV__) { + handleDEVOnlyPendingUpdateGuarantees(editorState); + } + } catch (error) { + if (error instanceof Error) { + editor._onError(error); + } + } finally { + editor._dirtyElements = previousDirtyElements; + editor._dirtyLeaves = previousDirtyLeaves; + editor._cloneNotNeeded = previousCloneNotNeeded; + editor._dirtyType = previousDirtyType; + activeEditorState = previousActiveEditorState; + isReadOnlyMode = previousReadOnlyMode; + activeEditor = previousActiveEditor; + } + + return editorState; +} + +// This technically isn't an update but given we need +// exposure to the module's active bindings, we have this +// function here + +export function readEditorState( + editor: LexicalEditor | null, + editorState: EditorState, + callbackFn: () => V, +): V { + const previousActiveEditorState = activeEditorState; + const previousReadOnlyMode = isReadOnlyMode; + const previousActiveEditor = activeEditor; + + activeEditorState = editorState; + isReadOnlyMode = true; + activeEditor = editor; + + try { + return callbackFn(); + } finally { + activeEditorState = previousActiveEditorState; + isReadOnlyMode = previousReadOnlyMode; + activeEditor = previousActiveEditor; + } +} + +function handleDEVOnlyPendingUpdateGuarantees( + pendingEditorState: EditorState, +): void { + // Given we can't Object.freeze the nodeMap as it's a Map, + // we instead replace its set, clear and delete methods. + const nodeMap = pendingEditorState._nodeMap; + + nodeMap.set = () => { + throw new Error('Cannot call set() on a frozen Lexical node map'); + }; + + nodeMap.clear = () => { + throw new Error('Cannot call clear() on a frozen Lexical node map'); + }; + + nodeMap.delete = () => { + throw new Error('Cannot call delete() on a frozen Lexical node map'); + }; +} + +export function $commitPendingUpdates( + editor: LexicalEditor, + recoveryEditorState?: EditorState, +): void { + const pendingEditorState = editor._pendingEditorState; + const rootElement = editor._rootElement; + const shouldSkipDOM = editor._headless || rootElement === null; + + if (pendingEditorState === null) { + return; + } + + // ====== + // Reconciliation has started. + // ====== + + const currentEditorState = editor._editorState; + const currentSelection = currentEditorState._selection; + const pendingSelection = pendingEditorState._selection; + const needsUpdate = editor._dirtyType !== NO_DIRTY_NODES; + const previousActiveEditorState = activeEditorState; + const previousReadOnlyMode = isReadOnlyMode; + const previousActiveEditor = activeEditor; + const previouslyUpdating = editor._updating; + const observer = editor._observer; + let mutatedNodes = null; + editor._pendingEditorState = null; + editor._editorState = pendingEditorState; + + if (!shouldSkipDOM && needsUpdate && observer !== null) { + activeEditor = editor; + activeEditorState = pendingEditorState; + isReadOnlyMode = false; + // We don't want updates to sync block the reconciliation. + editor._updating = true; + try { + const dirtyType = editor._dirtyType; + const dirtyElements = editor._dirtyElements; + const dirtyLeaves = editor._dirtyLeaves; + observer.disconnect(); + + mutatedNodes = $reconcileRoot( + currentEditorState, + pendingEditorState, + editor, + dirtyType, + dirtyElements, + dirtyLeaves, + ); + } catch (error) { + // Report errors + if (error instanceof Error) { + editor._onError(error); + } + + // Reset editor and restore incoming editor state to the DOM + if (!isAttemptingToRecoverFromReconcilerError) { + resetEditor(editor, null, rootElement, pendingEditorState); + initMutationObserver(editor); + editor._dirtyType = FULL_RECONCILE; + isAttemptingToRecoverFromReconcilerError = true; + $commitPendingUpdates(editor, currentEditorState); + isAttemptingToRecoverFromReconcilerError = false; + } else { + // To avoid a possible situation of infinite loops, lets throw + throw error; + } + + return; + } finally { + observer.observe(rootElement as Node, observerOptions); + editor._updating = previouslyUpdating; + activeEditorState = previousActiveEditorState; + isReadOnlyMode = previousReadOnlyMode; + activeEditor = previousActiveEditor; + } + } + + if (!pendingEditorState._readOnly) { + pendingEditorState._readOnly = true; + if (__DEV__) { + handleDEVOnlyPendingUpdateGuarantees(pendingEditorState); + if ($isRangeSelection(pendingSelection)) { + Object.freeze(pendingSelection.anchor); + Object.freeze(pendingSelection.focus); + } + Object.freeze(pendingSelection); + } + } + + const dirtyLeaves = editor._dirtyLeaves; + const dirtyElements = editor._dirtyElements; + const normalizedNodes = editor._normalizedNodes; + const tags = editor._updateTags; + const deferred = editor._deferred; + const nodeCount = pendingEditorState._nodeMap.size; + + if (needsUpdate) { + editor._dirtyType = NO_DIRTY_NODES; + editor._cloneNotNeeded.clear(); + editor._dirtyLeaves = new Set(); + editor._dirtyElements = new Map(); + editor._normalizedNodes = new Set(); + editor._updateTags = new Set(); + } + $garbageCollectDetachedDecorators(editor, pendingEditorState); + + // ====== + // Reconciliation has finished. Now update selection and trigger listeners. + // ====== + + const domSelection = shouldSkipDOM ? null : getDOMSelection(editor._window); + + // Attempt to update the DOM selection, including focusing of the root element, + // and scroll into view if needed. + if ( + editor._editable && + // domSelection will be null in headless + domSelection !== null && + (needsUpdate || pendingSelection === null || pendingSelection.dirty) + ) { + activeEditor = editor; + activeEditorState = pendingEditorState; + try { + if (observer !== null) { + observer.disconnect(); + } + if (needsUpdate || pendingSelection === null || pendingSelection.dirty) { + const blockCursorElement = editor._blockCursorElement; + if (blockCursorElement !== null) { + removeDOMBlockCursorElement( + blockCursorElement, + editor, + rootElement as HTMLElement, + ); + } + updateDOMSelection( + currentSelection, + pendingSelection, + editor, + domSelection, + tags, + rootElement as HTMLElement, + nodeCount, + ); + } + updateDOMBlockCursorElement( + editor, + rootElement as HTMLElement, + pendingSelection, + ); + if (observer !== null) { + observer.observe(rootElement as Node, observerOptions); + } + } finally { + activeEditor = previousActiveEditor; + activeEditorState = previousActiveEditorState; + } + } + + if (mutatedNodes !== null) { + triggerMutationListeners( + editor, + mutatedNodes, + tags, + dirtyLeaves, + currentEditorState, + ); + } + if ( + !$isRangeSelection(pendingSelection) && + pendingSelection !== null && + (currentSelection === null || !currentSelection.is(pendingSelection)) + ) { + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + } + /** + * Capture pendingDecorators after garbage collecting detached decorators + */ + const pendingDecorators = editor._pendingDecorators; + if (pendingDecorators !== null) { + editor._decorators = pendingDecorators; + editor._pendingDecorators = null; + triggerListeners('decorator', editor, true, pendingDecorators); + } + + // If reconciler fails, we reset whole editor (so current editor state becomes empty) + // and attempt to re-render pendingEditorState. If that goes through we trigger + // listeners, but instead use recoverEditorState which is current editor state before reset + // This specifically important for collab that relies on prevEditorState from update + // listener to calculate delta of changed nodes/properties + triggerTextContentListeners( + editor, + recoveryEditorState || currentEditorState, + pendingEditorState, + ); + triggerListeners('update', editor, true, { + dirtyElements, + dirtyLeaves, + editorState: pendingEditorState, + normalizedNodes, + prevEditorState: recoveryEditorState || currentEditorState, + tags, + }); + triggerDeferredUpdateCallbacks(editor, deferred); + $triggerEnqueuedUpdates(editor); +} + +function triggerTextContentListeners( + editor: LexicalEditor, + currentEditorState: EditorState, + pendingEditorState: EditorState, +): void { + const currentTextContent = getEditorStateTextContent(currentEditorState); + const latestTextContent = getEditorStateTextContent(pendingEditorState); + + if (currentTextContent !== latestTextContent) { + triggerListeners('textcontent', editor, true, latestTextContent); + } +} + +function triggerMutationListeners( + editor: LexicalEditor, + mutatedNodes: MutatedNodes, + updateTags: Set, + dirtyLeaves: Set, + prevEditorState: EditorState, +): void { + const listeners = Array.from(editor._listeners.mutation); + const listenersLength = listeners.length; + + for (let i = 0; i < listenersLength; i++) { + const [listener, klass] = listeners[i]; + const mutatedNodesByType = mutatedNodes.get(klass); + if (mutatedNodesByType !== undefined) { + listener(mutatedNodesByType, { + dirtyLeaves, + prevEditorState, + updateTags, + }); + } + } +} + +export function triggerListeners( + type: 'update' | 'root' | 'decorator' | 'textcontent' | 'editable', + editor: LexicalEditor, + isCurrentlyEnqueuingUpdates: boolean, + ...payload: unknown[] +): void { + const previouslyUpdating = editor._updating; + editor._updating = isCurrentlyEnqueuingUpdates; + + try { + const listeners = Array.from(editor._listeners[type]); + for (let i = 0; i < listeners.length; i++) { + // @ts-ignore + listeners[i].apply(null, payload); + } + } finally { + editor._updating = previouslyUpdating; + } +} + +export function triggerCommandListeners< + TCommand extends LexicalCommand, +>( + editor: LexicalEditor, + type: TCommand, + payload: CommandPayloadType, +): boolean { + if (editor._updating === false || activeEditor !== editor) { + let returnVal = false; + editor.update(() => { + returnVal = triggerCommandListeners(editor, type, payload); + }); + return returnVal; + } + + const editors = getEditorsToPropagate(editor); + + for (let i = 4; i >= 0; i--) { + for (let e = 0; e < editors.length; e++) { + const currentEditor = editors[e]; + const commandListeners = currentEditor._commands; + const listenerInPriorityOrder = commandListeners.get(type); + + if (listenerInPriorityOrder !== undefined) { + const listenersSet = listenerInPriorityOrder[i]; + + if (listenersSet !== undefined) { + const listeners = Array.from(listenersSet); + const listenersLength = listeners.length; + + for (let j = 0; j < listenersLength; j++) { + if (listeners[j](payload, editor) === true) { + return true; + } + } + } + } + } + } + + return false; +} + +function $triggerEnqueuedUpdates(editor: LexicalEditor): void { + const queuedUpdates = editor._updates; + + if (queuedUpdates.length !== 0) { + const queuedUpdate = queuedUpdates.shift(); + if (queuedUpdate) { + const [updateFn, options] = queuedUpdate; + $beginUpdate(editor, updateFn, options); + } + } +} + +function triggerDeferredUpdateCallbacks( + editor: LexicalEditor, + deferred: Array<() => void>, +): void { + editor._deferred = []; + + if (deferred.length !== 0) { + const previouslyUpdating = editor._updating; + editor._updating = true; + + try { + for (let i = 0; i < deferred.length; i++) { + deferred[i](); + } + } finally { + editor._updating = previouslyUpdating; + } + } +} + +function processNestedUpdates( + editor: LexicalEditor, + initialSkipTransforms?: boolean, +): boolean { + const queuedUpdates = editor._updates; + let skipTransforms = initialSkipTransforms || false; + + // Updates might grow as we process them, we so we'll need + // to handle each update as we go until the updates array is + // empty. + while (queuedUpdates.length !== 0) { + const queuedUpdate = queuedUpdates.shift(); + if (queuedUpdate) { + const [nextUpdateFn, options] = queuedUpdate; + + let onUpdate; + let tag; + + if (options !== undefined) { + onUpdate = options.onUpdate; + tag = options.tag; + + if (options.skipTransforms) { + skipTransforms = true; + } + if (options.discrete) { + const pendingEditorState = editor._pendingEditorState; + invariant( + pendingEditorState !== null, + 'Unexpected empty pending editor state on discrete nested update', + ); + pendingEditorState._flushSync = true; + } + + if (onUpdate) { + editor._deferred.push(onUpdate); + } + + if (tag) { + editor._updateTags.add(tag); + } + } + + nextUpdateFn(); + } + } + + return skipTransforms; +} + +function $beginUpdate( + editor: LexicalEditor, + updateFn: () => void, + options?: EditorUpdateOptions, +): void { + const updateTags = editor._updateTags; + let onUpdate; + let tag; + let skipTransforms = false; + let discrete = false; + + if (options !== undefined) { + onUpdate = options.onUpdate; + tag = options.tag; + + if (tag != null) { + updateTags.add(tag); + } + + skipTransforms = options.skipTransforms || false; + discrete = options.discrete || false; + } + + if (onUpdate) { + editor._deferred.push(onUpdate); + } + + const currentEditorState = editor._editorState; + let pendingEditorState = editor._pendingEditorState; + let editorStateWasCloned = false; + + if (pendingEditorState === null || pendingEditorState._readOnly) { + pendingEditorState = editor._pendingEditorState = cloneEditorState( + pendingEditorState || currentEditorState, + ); + editorStateWasCloned = true; + } + pendingEditorState._flushSync = discrete; + + const previousActiveEditorState = activeEditorState; + const previousReadOnlyMode = isReadOnlyMode; + const previousActiveEditor = activeEditor; + const previouslyUpdating = editor._updating; + activeEditorState = pendingEditorState; + isReadOnlyMode = false; + editor._updating = true; + activeEditor = editor; + + try { + if (editorStateWasCloned) { + if (editor._headless) { + if (currentEditorState._selection !== null) { + pendingEditorState._selection = currentEditorState._selection.clone(); + } + } else { + pendingEditorState._selection = $internalCreateSelection(editor); + } + } + + const startingCompositionKey = editor._compositionKey; + updateFn(); + skipTransforms = processNestedUpdates(editor, skipTransforms); + applySelectionTransforms(pendingEditorState, editor); + + if (editor._dirtyType !== NO_DIRTY_NODES) { + if (skipTransforms) { + $normalizeAllDirtyTextNodes(pendingEditorState, editor); + } else { + $applyAllTransforms(pendingEditorState, editor); + } + + processNestedUpdates(editor); + $garbageCollectDetachedNodes( + currentEditorState, + pendingEditorState, + editor._dirtyLeaves, + editor._dirtyElements, + ); + } + + const endingCompositionKey = editor._compositionKey; + + if (startingCompositionKey !== endingCompositionKey) { + pendingEditorState._flushSync = true; + } + + const pendingSelection = pendingEditorState._selection; + + if ($isRangeSelection(pendingSelection)) { + const pendingNodeMap = pendingEditorState._nodeMap; + const anchorKey = pendingSelection.anchor.key; + const focusKey = pendingSelection.focus.key; + + if ( + pendingNodeMap.get(anchorKey) === undefined || + pendingNodeMap.get(focusKey) === undefined + ) { + invariant( + false, + 'updateEditor: selection has been lost because the previously selected nodes have been removed and ' + + "selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.", + ); + } + } else if ($isNodeSelection(pendingSelection)) { + // TODO: we should also validate node selection? + if (pendingSelection._nodes.size === 0) { + pendingEditorState._selection = null; + } + } + } catch (error) { + // Report errors + if (error instanceof Error) { + editor._onError(error); + } + + // Restore existing editor state to the DOM + editor._pendingEditorState = currentEditorState; + editor._dirtyType = FULL_RECONCILE; + + editor._cloneNotNeeded.clear(); + + editor._dirtyLeaves = new Set(); + + editor._dirtyElements.clear(); + + $commitPendingUpdates(editor); + return; + } finally { + activeEditorState = previousActiveEditorState; + isReadOnlyMode = previousReadOnlyMode; + activeEditor = previousActiveEditor; + editor._updating = previouslyUpdating; + infiniteTransformCount = 0; + } + + const shouldUpdate = + editor._dirtyType !== NO_DIRTY_NODES || + editorStateHasDirtySelection(pendingEditorState, editor); + + if (shouldUpdate) { + if (pendingEditorState._flushSync) { + pendingEditorState._flushSync = false; + $commitPendingUpdates(editor); + } else if (editorStateWasCloned) { + scheduleMicroTask(() => { + $commitPendingUpdates(editor); + }); + } + } else { + pendingEditorState._flushSync = false; + + if (editorStateWasCloned) { + updateTags.clear(); + editor._deferred = []; + editor._pendingEditorState = null; + } + } +} + +export function updateEditor( + editor: LexicalEditor, + updateFn: () => void, + options?: EditorUpdateOptions, +): void { + if (editor._updating) { + editor._updates.push([updateFn, options]); + } else { + $beginUpdate(editor, updateFn, options); + } +} diff --git a/resources/js/wysiwyg/lexical/core/LexicalUtils.ts b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts new file mode 100644 index 000000000..71096b19d --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/LexicalUtils.ts @@ -0,0 +1,1788 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + CommandPayloadType, + EditorConfig, + EditorThemeClasses, + Klass, + LexicalCommand, + MutatedNodes, + MutationListeners, + NodeMutation, + RegisteredNode, + RegisteredNodes, + Spread, +} from './LexicalEditor'; +import type {EditorState} from './LexicalEditorState'; +import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode'; +import type { + BaseSelection, + PointType, + RangeSelection, +} from './LexicalSelection'; +import type {RootNode} from './nodes/LexicalRootNode'; +import type {TextFormatType, TextNode} from './nodes/LexicalTextNode'; + +import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; +import {IS_APPLE, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'lexical/shared/environment'; +import invariant from 'lexical/shared/invariant'; +import normalizeClassNames from 'lexical/shared/normalizeClassNames'; + +import { + $createTextNode, + $getPreviousSelection, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isRangeSelection, + $isRootNode, + $isTextNode, + DecoratorNode, + ElementNode, + LineBreakNode, +} from '.'; +import { + COMPOSITION_SUFFIX, + DOM_TEXT_TYPE, + HAS_DIRTY_NODES, + LTR_REGEX, + RTL_REGEX, + TEXT_TYPE_TO_FORMAT, +} from './LexicalConstants'; +import {LexicalEditor} from './LexicalEditor'; +import {$flushRootMutations} from './LexicalMutations'; +import {$normalizeSelection} from './LexicalNormalization'; +import { + errorOnInfiniteTransforms, + errorOnReadOnly, + getActiveEditor, + getActiveEditorState, + internalGetActiveEditorState, + isCurrentlyReadOnlyMode, + triggerCommandListeners, + updateEditor, +} from './LexicalUpdates'; + +export const emptyFunction = () => { + return; +}; + +let keyCounter = 1; + +export function resetRandomKey(): void { + keyCounter = 1; +} + +export function generateRandomKey(): string { + return '' + keyCounter++; +} + +export function getRegisteredNodeOrThrow( + editor: LexicalEditor, + nodeType: string, +): RegisteredNode { + const registeredNode = editor._nodes.get(nodeType); + if (registeredNode === undefined) { + invariant(false, 'registeredNode: Type %s not found', nodeType); + } + return registeredNode; +} + +export const isArray = Array.isArray; + +export const scheduleMicroTask: (fn: () => void) => void = + typeof queueMicrotask === 'function' + ? queueMicrotask + : (fn) => { + // No window prefix intended (#1400) + Promise.resolve().then(fn); + }; + +export function $isSelectionCapturedInDecorator(node: Node): boolean { + return $isDecoratorNode($getNearestNodeFromDOMNode(node)); +} + +export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean { + const activeElement = document.activeElement as HTMLElement; + + if (activeElement === null) { + return false; + } + const nodeName = activeElement.nodeName; + + return ( + $isDecoratorNode($getNearestNodeFromDOMNode(anchorDOM)) && + (nodeName === 'INPUT' || + nodeName === 'TEXTAREA' || + (activeElement.contentEditable === 'true' && + getEditorPropertyFromDOMNode(activeElement) == null)) + ); +} + +export function isSelectionWithinEditor( + editor: LexicalEditor, + anchorDOM: null | Node, + focusDOM: null | Node, +): boolean { + const rootElement = editor.getRootElement(); + try { + return ( + rootElement !== null && + rootElement.contains(anchorDOM) && + rootElement.contains(focusDOM) && + // Ignore if selection is within nested editor + anchorDOM !== null && + !isSelectionCapturedInDecoratorInput(anchorDOM as Node) && + getNearestEditorFromDOMNode(anchorDOM) === editor + ); + } catch (error) { + return false; + } +} + +/** + * @returns true if the given argument is a LexicalEditor instance from this build of Lexical + */ +export function isLexicalEditor(editor: unknown): editor is LexicalEditor { + // Check instanceof to prevent issues with multiple embedded Lexical installations + return editor instanceof LexicalEditor; +} + +export function getNearestEditorFromDOMNode( + node: Node | null, +): LexicalEditor | null { + let currentNode = node; + while (currentNode != null) { + const editor = getEditorPropertyFromDOMNode(currentNode); + if (isLexicalEditor(editor)) { + return editor; + } + currentNode = getParentElement(currentNode); + } + return null; +} + +/** @internal */ +export function getEditorPropertyFromDOMNode(node: Node | null): unknown { + // @ts-expect-error: internal field + return node ? node.__lexicalEditor : null; +} + +export function getTextDirection(text: string): 'ltr' | 'rtl' | null { + if (RTL_REGEX.test(text)) { + return 'rtl'; + } + if (LTR_REGEX.test(text)) { + return 'ltr'; + } + return null; +} + +export function $isTokenOrSegmented(node: TextNode): boolean { + return node.isToken() || node.isSegmented(); +} + +function isDOMNodeLexicalTextNode(node: Node): node is Text { + return node.nodeType === DOM_TEXT_TYPE; +} + +export function getDOMTextNode(element: Node | null): Text | null { + let node = element; + while (node != null) { + if (isDOMNodeLexicalTextNode(node)) { + return node; + } + node = node.firstChild; + } + return null; +} + +export function toggleTextFormatType( + format: number, + type: TextFormatType, + alignWithFormat: null | number, +): number { + const activeFormat = TEXT_TYPE_TO_FORMAT[type]; + if ( + alignWithFormat !== null && + (format & activeFormat) === (alignWithFormat & activeFormat) + ) { + return format; + } + let newFormat = format ^ activeFormat; + if (type === 'subscript') { + newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript; + } else if (type === 'superscript') { + newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript; + } + return newFormat; +} + +export function $isLeafNode( + node: LexicalNode | null | undefined, +): node is TextNode | LineBreakNode | DecoratorNode { + return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node); +} + +export function $setNodeKey( + node: LexicalNode, + existingKey: NodeKey | null | undefined, +): void { + if (existingKey != null) { + if (__DEV__) { + errorOnNodeKeyConstructorMismatch(node, existingKey); + } + node.__key = existingKey; + return; + } + errorOnReadOnly(); + errorOnInfiniteTransforms(); + const editor = getActiveEditor(); + const editorState = getActiveEditorState(); + const key = generateRandomKey(); + editorState._nodeMap.set(key, node); + // TODO Split this function into leaf/element + if ($isElementNode(node)) { + editor._dirtyElements.set(key, true); + } else { + editor._dirtyLeaves.add(key); + } + editor._cloneNotNeeded.add(key); + editor._dirtyType = HAS_DIRTY_NODES; + node.__key = key; +} + +function errorOnNodeKeyConstructorMismatch( + node: LexicalNode, + existingKey: NodeKey, +) { + const editorState = internalGetActiveEditorState(); + if (!editorState) { + // tests expect to be able to do this kind of clone without an active editor state + return; + } + const existingNode = editorState._nodeMap.get(existingKey); + if (existingNode && existingNode.constructor !== node.constructor) { + // Lifted condition to if statement because the inverted logic is a bit confusing + if (node.constructor.name !== existingNode.constructor.name) { + invariant( + false, + 'Lexical node with constructor %s attempted to re-use key from node in active editor state with constructor %s. Keys must not be re-used when the type is changed.', + node.constructor.name, + existingNode.constructor.name, + ); + } else { + invariant( + false, + 'Lexical node with constructor %s attempted to re-use key from node in active editor state with different constructor with the same name (possibly due to invalid Hot Module Replacement). Keys must not be re-used when the type is changed.', + node.constructor.name, + ); + } + } +} + +type IntentionallyMarkedAsDirtyElement = boolean; + +function internalMarkParentElementsAsDirty( + parentKey: NodeKey, + nodeMap: NodeMap, + dirtyElements: Map, +): void { + let nextParentKey: string | null = parentKey; + while (nextParentKey !== null) { + if (dirtyElements.has(nextParentKey)) { + return; + } + const node = nodeMap.get(nextParentKey); + if (node === undefined) { + break; + } + dirtyElements.set(nextParentKey, false); + nextParentKey = node.__parent; + } +} + +// TODO #6031 this function or their callers have to adjust selection (i.e. insertBefore) +export function removeFromParent(node: LexicalNode): void { + const oldParent = node.getParent(); + if (oldParent !== null) { + const writableNode = node.getWritable(); + const writableParent = oldParent.getWritable(); + const prevSibling = node.getPreviousSibling(); + const nextSibling = node.getNextSibling(); + // TODO: this function duplicates a bunch of operations, can be simplified. + if (prevSibling === null) { + if (nextSibling !== null) { + const writableNextSibling = nextSibling.getWritable(); + writableParent.__first = nextSibling.__key; + writableNextSibling.__prev = null; + } else { + writableParent.__first = null; + } + } else { + const writablePrevSibling = prevSibling.getWritable(); + if (nextSibling !== null) { + const writableNextSibling = nextSibling.getWritable(); + writableNextSibling.__prev = writablePrevSibling.__key; + writablePrevSibling.__next = writableNextSibling.__key; + } else { + writablePrevSibling.__next = null; + } + writableNode.__prev = null; + } + if (nextSibling === null) { + if (prevSibling !== null) { + const writablePrevSibling = prevSibling.getWritable(); + writableParent.__last = prevSibling.__key; + writablePrevSibling.__next = null; + } else { + writableParent.__last = null; + } + } else { + const writableNextSibling = nextSibling.getWritable(); + if (prevSibling !== null) { + const writablePrevSibling = prevSibling.getWritable(); + writablePrevSibling.__next = writableNextSibling.__key; + writableNextSibling.__prev = writablePrevSibling.__key; + } else { + writableNextSibling.__prev = null; + } + writableNode.__next = null; + } + writableParent.__size--; + writableNode.__parent = null; + } +} + +// Never use this function directly! It will break +// the cloning heuristic. Instead use node.getWritable(). +export function internalMarkNodeAsDirty(node: LexicalNode): void { + errorOnInfiniteTransforms(); + const latest = node.getLatest(); + const parent = latest.__parent; + const editorState = getActiveEditorState(); + const editor = getActiveEditor(); + const nodeMap = editorState._nodeMap; + const dirtyElements = editor._dirtyElements; + if (parent !== null) { + internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements); + } + const key = latest.__key; + editor._dirtyType = HAS_DIRTY_NODES; + if ($isElementNode(node)) { + dirtyElements.set(key, true); + } else { + // TODO split internally MarkNodeAsDirty into two dedicated Element/leave functions + editor._dirtyLeaves.add(key); + } +} + +export function internalMarkSiblingsAsDirty(node: LexicalNode) { + const previousNode = node.getPreviousSibling(); + const nextNode = node.getNextSibling(); + if (previousNode !== null) { + internalMarkNodeAsDirty(previousNode); + } + if (nextNode !== null) { + internalMarkNodeAsDirty(nextNode); + } +} + +export function $setCompositionKey(compositionKey: null | NodeKey): void { + errorOnReadOnly(); + const editor = getActiveEditor(); + const previousCompositionKey = editor._compositionKey; + if (compositionKey !== previousCompositionKey) { + editor._compositionKey = compositionKey; + if (previousCompositionKey !== null) { + const node = $getNodeByKey(previousCompositionKey); + if (node !== null) { + node.getWritable(); + } + } + if (compositionKey !== null) { + const node = $getNodeByKey(compositionKey); + if (node !== null) { + node.getWritable(); + } + } + } +} + +export function $getCompositionKey(): null | NodeKey { + if (isCurrentlyReadOnlyMode()) { + return null; + } + const editor = getActiveEditor(); + return editor._compositionKey; +} + +export function $getNodeByKey( + key: NodeKey, + _editorState?: EditorState, +): T | null { + const editorState = _editorState || getActiveEditorState(); + const node = editorState._nodeMap.get(key) as T; + if (node === undefined) { + return null; + } + return node; +} + +export function $getNodeFromDOMNode( + dom: Node, + editorState?: EditorState, +): LexicalNode | null { + const editor = getActiveEditor(); + // @ts-ignore We intentionally add this to the Node. + const key = dom[`__lexicalKey_${editor._key}`]; + if (key !== undefined) { + return $getNodeByKey(key, editorState); + } + return null; +} + +export function $getNearestNodeFromDOMNode( + startingDOM: Node, + editorState?: EditorState, +): LexicalNode | null { + let dom: Node | null = startingDOM; + while (dom != null) { + const node = $getNodeFromDOMNode(dom, editorState); + if (node !== null) { + return node; + } + dom = getParentElement(dom); + } + return null; +} + +export function cloneDecorators( + editor: LexicalEditor, +): Record { + const currentDecorators = editor._decorators; + const pendingDecorators = Object.assign({}, currentDecorators); + editor._pendingDecorators = pendingDecorators; + return pendingDecorators; +} + +export function getEditorStateTextContent(editorState: EditorState): string { + return editorState.read(() => $getRoot().getTextContent()); +} + +export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void { + // Mark all existing text nodes as dirty + updateEditor( + editor, + () => { + const editorState = getActiveEditorState(); + if (editorState.isEmpty()) { + return; + } + if (type === 'root') { + $getRoot().markDirty(); + return; + } + const nodeMap = editorState._nodeMap; + for (const [, node] of nodeMap) { + node.markDirty(); + } + }, + editor._pendingEditorState === null + ? { + tag: 'history-merge', + } + : undefined, + ); +} + +export function $getRoot(): RootNode { + return internalGetRoot(getActiveEditorState()); +} + +export function internalGetRoot(editorState: EditorState): RootNode { + return editorState._nodeMap.get('root') as RootNode; +} + +export function $setSelection(selection: null | BaseSelection): void { + errorOnReadOnly(); + const editorState = getActiveEditorState(); + if (selection !== null) { + if (__DEV__) { + if (Object.isFrozen(selection)) { + invariant( + false, + '$setSelection called on frozen selection object. Ensure selection is cloned before passing in.', + ); + } + } + selection.dirty = true; + selection.setCachedNodes(null); + } + editorState._selection = selection; +} + +export function $flushMutations(): void { + errorOnReadOnly(); + const editor = getActiveEditor(); + $flushRootMutations(editor); +} + +export function $getNodeFromDOM(dom: Node): null | LexicalNode { + const editor = getActiveEditor(); + const nodeKey = getNodeKeyFromDOM(dom, editor); + if (nodeKey === null) { + const rootElement = editor.getRootElement(); + if (dom === rootElement) { + return $getNodeByKey('root'); + } + return null; + } + return $getNodeByKey(nodeKey); +} + +export function getTextNodeOffset( + node: TextNode, + moveSelectionToEnd: boolean, +): number { + return moveSelectionToEnd ? node.getTextContentSize() : 0; +} + +function getNodeKeyFromDOM( + // Note that node here refers to a DOM Node, not an Lexical Node + dom: Node, + editor: LexicalEditor, +): NodeKey | null { + let node: Node | null = dom; + while (node != null) { + // @ts-ignore We intentionally add this to the Node. + const key: NodeKey = node[`__lexicalKey_${editor._key}`]; + if (key !== undefined) { + return key; + } + node = getParentElement(node); + } + return null; +} + +export function doesContainGrapheme(str: string): boolean { + return /[\uD800-\uDBFF][\uDC00-\uDFFF]/g.test(str); +} + +export function getEditorsToPropagate( + editor: LexicalEditor, +): Array { + const editorsToPropagate = []; + let currentEditor: LexicalEditor | null = editor; + while (currentEditor !== null) { + editorsToPropagate.push(currentEditor); + currentEditor = currentEditor._parentEditor; + } + return editorsToPropagate; +} + +export function createUID(): string { + return Math.random() + .toString(36) + .replace(/[^a-z]+/g, '') + .substr(0, 5); +} + +export function getAnchorTextFromDOM(anchorNode: Node): null | string { + if (anchorNode.nodeType === DOM_TEXT_TYPE) { + return anchorNode.nodeValue; + } + return null; +} + +export function $updateSelectedTextFromDOM( + isCompositionEnd: boolean, + editor: LexicalEditor, + data?: string, +): void { + // Update the text content with the latest composition text + const domSelection = getDOMSelection(editor._window); + if (domSelection === null) { + return; + } + const anchorNode = domSelection.anchorNode; + let {anchorOffset, focusOffset} = domSelection; + if (anchorNode !== null) { + let textContent = getAnchorTextFromDOM(anchorNode); + const node = $getNearestNodeFromDOMNode(anchorNode); + if (textContent !== null && $isTextNode(node)) { + // Data is intentionally truthy, as we check for boolean, null and empty string. + if (textContent === COMPOSITION_SUFFIX && data) { + const offset = data.length; + textContent = data; + anchorOffset = offset; + focusOffset = offset; + } + + if (textContent !== null) { + $updateTextNodeFromDOMContent( + node, + textContent, + anchorOffset, + focusOffset, + isCompositionEnd, + ); + } + } + } +} + +export function $updateTextNodeFromDOMContent( + textNode: TextNode, + textContent: string, + anchorOffset: null | number, + focusOffset: null | number, + compositionEnd: boolean, +): void { + let node = textNode; + + if (node.isAttached() && (compositionEnd || !node.isDirty())) { + const isComposing = node.isComposing(); + let normalizedTextContent = textContent; + + if ( + (isComposing || compositionEnd) && + textContent[textContent.length - 1] === COMPOSITION_SUFFIX + ) { + normalizedTextContent = textContent.slice(0, -1); + } + const prevTextContent = node.getTextContent(); + + if (compositionEnd || normalizedTextContent !== prevTextContent) { + if (normalizedTextContent === '') { + $setCompositionKey(null); + if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT) { + // For composition (mainly Android), we have to remove the node on a later update + const editor = getActiveEditor(); + setTimeout(() => { + editor.update(() => { + if (node.isAttached()) { + node.remove(); + } + }); + }, 20); + } else { + node.remove(); + } + return; + } + const parent = node.getParent(); + const prevSelection = $getPreviousSelection(); + const prevTextContentSize = node.getTextContentSize(); + const compositionKey = $getCompositionKey(); + const nodeKey = node.getKey(); + + if ( + node.isToken() || + (compositionKey !== null && + nodeKey === compositionKey && + !isComposing) || + // Check if character was added at the start or boundaries when not insertable, and we need + // to clear this input from occurring as that action wasn't permitted. + ($isRangeSelection(prevSelection) && + ((parent !== null && + !parent.canInsertTextBefore() && + prevSelection.anchor.offset === 0) || + (prevSelection.anchor.key === textNode.__key && + prevSelection.anchor.offset === 0 && + !node.canInsertTextBefore() && + !isComposing) || + (prevSelection.focus.key === textNode.__key && + prevSelection.focus.offset === prevTextContentSize && + !node.canInsertTextAfter() && + !isComposing))) + ) { + node.markDirty(); + return; + } + const selection = $getSelection(); + + if ( + !$isRangeSelection(selection) || + anchorOffset === null || + focusOffset === null + ) { + node.setTextContent(normalizedTextContent); + return; + } + selection.setTextNodeRange(node, anchorOffset, node, focusOffset); + + if (node.isSegmented()) { + const originalTextContent = node.getTextContent(); + const replacement = $createTextNode(originalTextContent); + node.replace(replacement); + node = replacement; + } + node.setTextContent(normalizedTextContent); + } + } +} + +function $previousSiblingDoesNotAcceptText(node: TextNode): boolean { + const previousSibling = node.getPreviousSibling(); + + return ( + ($isTextNode(previousSibling) || + ($isElementNode(previousSibling) && previousSibling.isInline())) && + !previousSibling.canInsertTextAfter() + ); +} + +// This function is connected to $shouldPreventDefaultAndInsertText and determines whether the +// TextNode boundaries are writable or we should use the previous/next sibling instead. For example, +// in the case of a LinkNode, boundaries are not writable. +export function $shouldInsertTextAfterOrBeforeTextNode( + selection: RangeSelection, + node: TextNode, +): boolean { + if (node.isSegmented()) { + return true; + } + if (!selection.isCollapsed()) { + return false; + } + const offset = selection.anchor.offset; + const parent = node.getParentOrThrow(); + const isToken = node.isToken(); + if (offset === 0) { + return ( + !node.canInsertTextBefore() || + (!parent.canInsertTextBefore() && !node.isComposing()) || + isToken || + $previousSiblingDoesNotAcceptText(node) + ); + } else if (offset === node.getTextContentSize()) { + return ( + !node.canInsertTextAfter() || + (!parent.canInsertTextAfter() && !node.isComposing()) || + isToken + ); + } else { + return false; + } +} + +export function isTab( + key: string, + altKey: boolean, + ctrlKey: boolean, + metaKey: boolean, +): boolean { + return key === 'Tab' && !altKey && !ctrlKey && !metaKey; +} + +export function isBold( + key: string, + altKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + return ( + key.toLowerCase() === 'b' && !altKey && controlOrMeta(metaKey, ctrlKey) + ); +} + +export function isItalic( + key: string, + altKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + return ( + key.toLowerCase() === 'i' && !altKey && controlOrMeta(metaKey, ctrlKey) + ); +} + +export function isUnderline( + key: string, + altKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + return ( + key.toLowerCase() === 'u' && !altKey && controlOrMeta(metaKey, ctrlKey) + ); +} + +export function isParagraph(key: string, shiftKey: boolean): boolean { + return isReturn(key) && !shiftKey; +} + +export function isLineBreak(key: string, shiftKey: boolean): boolean { + return isReturn(key) && shiftKey; +} + +// Inserts a new line after the selection + +export function isOpenLineBreak(key: string, ctrlKey: boolean): boolean { + // 79 = KeyO + return IS_APPLE && ctrlKey && key.toLowerCase() === 'o'; +} + +export function isDeleteWordBackward( + key: string, + altKey: boolean, + ctrlKey: boolean, +): boolean { + return isBackspace(key) && (IS_APPLE ? altKey : ctrlKey); +} + +export function isDeleteWordForward( + key: string, + altKey: boolean, + ctrlKey: boolean, +): boolean { + return isDelete(key) && (IS_APPLE ? altKey : ctrlKey); +} + +export function isDeleteLineBackward(key: string, metaKey: boolean): boolean { + return IS_APPLE && metaKey && isBackspace(key); +} + +export function isDeleteLineForward(key: string, metaKey: boolean): boolean { + return IS_APPLE && metaKey && isDelete(key); +} + +export function isDeleteBackward( + key: string, + altKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + if (IS_APPLE) { + if (altKey || metaKey) { + return false; + } + return isBackspace(key) || (key.toLowerCase() === 'h' && ctrlKey); + } + if (ctrlKey || altKey || metaKey) { + return false; + } + return isBackspace(key); +} + +export function isDeleteForward( + key: string, + ctrlKey: boolean, + shiftKey: boolean, + altKey: boolean, + metaKey: boolean, +): boolean { + if (IS_APPLE) { + if (shiftKey || altKey || metaKey) { + return false; + } + return isDelete(key) || (key.toLowerCase() === 'd' && ctrlKey); + } + if (ctrlKey || altKey || metaKey) { + return false; + } + return isDelete(key); +} + +export function isUndo( + key: string, + shiftKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + return ( + key.toLowerCase() === 'z' && !shiftKey && controlOrMeta(metaKey, ctrlKey) + ); +} + +export function isRedo( + key: string, + shiftKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + if (IS_APPLE) { + return key.toLowerCase() === 'z' && metaKey && shiftKey; + } + return ( + (key.toLowerCase() === 'y' && ctrlKey) || + (key.toLowerCase() === 'z' && ctrlKey && shiftKey) + ); +} + +export function isCopy( + key: string, + shiftKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + if (shiftKey) { + return false; + } + if (key.toLowerCase() === 'c') { + return IS_APPLE ? metaKey : ctrlKey; + } + + return false; +} + +export function isCut( + key: string, + shiftKey: boolean, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + if (shiftKey) { + return false; + } + if (key.toLowerCase() === 'x') { + return IS_APPLE ? metaKey : ctrlKey; + } + + return false; +} + +function isArrowLeft(key: string): boolean { + return key === 'ArrowLeft'; +} + +function isArrowRight(key: string): boolean { + return key === 'ArrowRight'; +} + +function isArrowUp(key: string): boolean { + return key === 'ArrowUp'; +} + +function isArrowDown(key: string): boolean { + return key === 'ArrowDown'; +} + +export function isMoveBackward( + key: string, + ctrlKey: boolean, + altKey: boolean, + metaKey: boolean, +): boolean { + return isArrowLeft(key) && !ctrlKey && !metaKey && !altKey; +} + +export function isMoveToStart( + key: string, + ctrlKey: boolean, + shiftKey: boolean, + altKey: boolean, + metaKey: boolean, +): boolean { + return isArrowLeft(key) && !altKey && !shiftKey && (ctrlKey || metaKey); +} + +export function isMoveForward( + key: string, + ctrlKey: boolean, + altKey: boolean, + metaKey: boolean, +): boolean { + return isArrowRight(key) && !ctrlKey && !metaKey && !altKey; +} + +export function isMoveToEnd( + key: string, + ctrlKey: boolean, + shiftKey: boolean, + altKey: boolean, + metaKey: boolean, +): boolean { + return isArrowRight(key) && !altKey && !shiftKey && (ctrlKey || metaKey); +} + +export function isMoveUp( + key: string, + ctrlKey: boolean, + metaKey: boolean, +): boolean { + return isArrowUp(key) && !ctrlKey && !metaKey; +} + +export function isMoveDown( + key: string, + ctrlKey: boolean, + metaKey: boolean, +): boolean { + return isArrowDown(key) && !ctrlKey && !metaKey; +} + +export function isModifier( + ctrlKey: boolean, + shiftKey: boolean, + altKey: boolean, + metaKey: boolean, +): boolean { + return ctrlKey || shiftKey || altKey || metaKey; +} + +export function isSpace(key: string): boolean { + return key === ' '; +} + +export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean { + if (IS_APPLE) { + return metaKey; + } + return ctrlKey; +} + +export function isReturn(key: string): boolean { + return key === 'Enter'; +} + +export function isBackspace(key: string): boolean { + return key === 'Backspace'; +} + +export function isEscape(key: string): boolean { + return key === 'Escape'; +} + +export function isDelete(key: string): boolean { + return key === 'Delete'; +} + +export function isSelectAll( + key: string, + metaKey: boolean, + ctrlKey: boolean, +): boolean { + return key.toLowerCase() === 'a' && controlOrMeta(metaKey, ctrlKey); +} + +export function $selectAll(): void { + const root = $getRoot(); + const selection = root.select(0, root.getChildrenSize()); + $setSelection($normalizeSelection(selection)); +} + +export function getCachedClassNameArray( + classNamesTheme: EditorThemeClasses, + classNameThemeType: string, +): Array { + if (classNamesTheme.__lexicalClassNameCache === undefined) { + classNamesTheme.__lexicalClassNameCache = {}; + } + const classNamesCache = classNamesTheme.__lexicalClassNameCache; + const cachedClassNames = classNamesCache[classNameThemeType]; + if (cachedClassNames !== undefined) { + return cachedClassNames; + } + const classNames = classNamesTheme[classNameThemeType]; + // As we're using classList, we need + // to handle className tokens that have spaces. + // The easiest way to do this to convert the + // className tokens to an array that can be + // applied to classList.add()/remove(). + if (typeof classNames === 'string') { + const classNamesArr = normalizeClassNames(classNames); + classNamesCache[classNameThemeType] = classNamesArr; + return classNamesArr; + } + return classNames; +} + +export function setMutatedNode( + mutatedNodes: MutatedNodes, + registeredNodes: RegisteredNodes, + mutationListeners: MutationListeners, + node: LexicalNode, + mutation: NodeMutation, +) { + if (mutationListeners.size === 0) { + return; + } + const nodeType = node.__type; + const nodeKey = node.__key; + const registeredNode = registeredNodes.get(nodeType); + if (registeredNode === undefined) { + invariant(false, 'Type %s not in registeredNodes', nodeType); + } + const klass = registeredNode.klass; + let mutatedNodesByType = mutatedNodes.get(klass); + if (mutatedNodesByType === undefined) { + mutatedNodesByType = new Map(); + mutatedNodes.set(klass, mutatedNodesByType); + } + const prevMutation = mutatedNodesByType.get(nodeKey); + // If the node has already been "destroyed", yet we are + // re-making it, then this means a move likely happened. + // We should change the mutation to be that of "updated" + // instead. + const isMove = prevMutation === 'destroyed' && mutation === 'created'; + if (prevMutation === undefined || isMove) { + mutatedNodesByType.set(nodeKey, isMove ? 'updated' : mutation); + } +} + +export function $nodesOfType(klass: Klass): Array { + const klassType = klass.getType(); + const editorState = getActiveEditorState(); + if (editorState._readOnly) { + const nodes = getCachedTypeToNodeMap(editorState).get(klassType) as + | undefined + | Map; + return nodes ? Array.from(nodes.values()) : []; + } + const nodes = editorState._nodeMap; + const nodesOfType: Array = []; + for (const [, node] of nodes) { + if ( + node instanceof klass && + node.__type === klassType && + node.isAttached() + ) { + nodesOfType.push(node as T); + } + } + return nodesOfType; +} + +function resolveElement( + element: ElementNode, + isBackward: boolean, + focusOffset: number, +): LexicalNode | null { + const parent = element.getParent(); + let offset = focusOffset; + let block = element; + if (parent !== null) { + if (isBackward && focusOffset === 0) { + offset = block.getIndexWithinParent(); + block = parent; + } else if (!isBackward && focusOffset === block.getChildrenSize()) { + offset = block.getIndexWithinParent() + 1; + block = parent; + } + } + return block.getChildAtIndex(isBackward ? offset - 1 : offset); +} + +export function $getAdjacentNode( + focus: PointType, + isBackward: boolean, +): null | LexicalNode { + const focusOffset = focus.offset; + if (focus.type === 'element') { + const block = focus.getNode(); + return resolveElement(block, isBackward, focusOffset); + } else { + const focusNode = focus.getNode(); + if ( + (isBackward && focusOffset === 0) || + (!isBackward && focusOffset === focusNode.getTextContentSize()) + ) { + const possibleNode = isBackward + ? focusNode.getPreviousSibling() + : focusNode.getNextSibling(); + if (possibleNode === null) { + return resolveElement( + focusNode.getParentOrThrow(), + isBackward, + focusNode.getIndexWithinParent() + (isBackward ? 0 : 1), + ); + } + return possibleNode; + } + } + return null; +} + +export function isFirefoxClipboardEvents(editor: LexicalEditor): boolean { + const event = getWindow(editor).event; + const inputType = event && (event as InputEvent).inputType; + return ( + inputType === 'insertFromPaste' || + inputType === 'insertFromPasteAsQuotation' + ); +} + +export function dispatchCommand>( + editor: LexicalEditor, + command: TCommand, + payload: CommandPayloadType, +): boolean { + return triggerCommandListeners(editor, command, payload); +} + +export function $textContentRequiresDoubleLinebreakAtEnd( + node: ElementNode, +): boolean { + return !$isRootNode(node) && !node.isLastChild() && !node.isInline(); +} + +export function getElementByKeyOrThrow( + editor: LexicalEditor, + key: NodeKey, +): HTMLElement { + const element = editor._keyToDOMMap.get(key); + + if (element === undefined) { + invariant( + false, + 'Reconciliation: could not find DOM element for node key %s', + key, + ); + } + + return element; +} + +export function getParentElement(node: Node): HTMLElement | null { + const parentElement = + (node as HTMLSlotElement).assignedSlot || node.parentElement; + return parentElement !== null && parentElement.nodeType === 11 + ? ((parentElement as unknown as ShadowRoot).host as HTMLElement) + : parentElement; +} + +export function scrollIntoViewIfNeeded( + editor: LexicalEditor, + selectionRect: DOMRect, + rootElement: HTMLElement, +): void { + const doc = rootElement.ownerDocument; + const defaultView = doc.defaultView; + + if (defaultView === null) { + return; + } + let {top: currentTop, bottom: currentBottom} = selectionRect; + let targetTop = 0; + let targetBottom = 0; + let element: HTMLElement | null = rootElement; + + while (element !== null) { + const isBodyElement = element === doc.body; + if (isBodyElement) { + targetTop = 0; + targetBottom = getWindow(editor).innerHeight; + } else { + const targetRect = element.getBoundingClientRect(); + targetTop = targetRect.top; + targetBottom = targetRect.bottom; + } + let diff = 0; + + if (currentTop < targetTop) { + diff = -(targetTop - currentTop); + } else if (currentBottom > targetBottom) { + diff = currentBottom - targetBottom; + } + + if (diff !== 0) { + if (isBodyElement) { + // Only handles scrolling of Y axis + defaultView.scrollBy(0, diff); + } else { + const scrollTop = element.scrollTop; + element.scrollTop += diff; + const yOffset = element.scrollTop - scrollTop; + currentTop -= yOffset; + currentBottom -= yOffset; + } + } + if (isBodyElement) { + break; + } + element = getParentElement(element); + } +} + +export function $hasUpdateTag(tag: string): boolean { + const editor = getActiveEditor(); + return editor._updateTags.has(tag); +} + +export function $addUpdateTag(tag: string): void { + errorOnReadOnly(); + const editor = getActiveEditor(); + editor._updateTags.add(tag); +} + +export function $maybeMoveChildrenSelectionToParent( + parentNode: LexicalNode, +): BaseSelection | null { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !$isElementNode(parentNode)) { + return selection; + } + const {anchor, focus} = selection; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + if ($hasAncestor(anchorNode, parentNode)) { + anchor.set(parentNode.__key, 0, 'element'); + } + if ($hasAncestor(focusNode, parentNode)) { + focus.set(parentNode.__key, 0, 'element'); + } + return selection; +} + +export function $hasAncestor( + child: LexicalNode, + targetNode: LexicalNode, +): boolean { + let parent = child.getParent(); + while (parent !== null) { + if (parent.is(targetNode)) { + return true; + } + parent = parent.getParent(); + } + return false; +} + +export function getDefaultView(domElem: HTMLElement): Window | null { + const ownerDoc = domElem.ownerDocument; + return (ownerDoc && ownerDoc.defaultView) || null; +} + +export function getWindow(editor: LexicalEditor): Window { + const windowObj = editor._window; + if (windowObj === null) { + invariant(false, 'window object not found'); + } + return windowObj; +} + +export function $isInlineElementOrDecoratorNode(node: LexicalNode): boolean { + return ( + ($isElementNode(node) && node.isInline()) || + ($isDecoratorNode(node) && node.isInline()) + ); +} + +export function $getNearestRootOrShadowRoot( + node: LexicalNode, +): RootNode | ElementNode { + let parent = node.getParentOrThrow(); + while (parent !== null) { + if ($isRootOrShadowRoot(parent)) { + return parent; + } + parent = parent.getParentOrThrow(); + } + return parent; +} + +const ShadowRootNodeBrand: unique symbol = Symbol.for( + '@lexical/ShadowRootNodeBrand', +); +type ShadowRootNode = Spread< + {isShadowRoot(): true; [ShadowRootNodeBrand]: never}, + ElementNode +>; +export function $isRootOrShadowRoot( + node: null | LexicalNode, +): node is RootNode | ShadowRootNode { + return $isRootNode(node) || ($isElementNode(node) && node.isShadowRoot()); +} + +/** + * Returns a shallow clone of node with a new key + * + * @param node - The node to be copied. + * @returns The copy of the node. + */ +export function $copyNode(node: T): T { + const copy = node.constructor.clone(node) as T; + $setNodeKey(copy, null); + return copy; +} + +export function $applyNodeReplacement( + node: LexicalNode, +): N { + const editor = getActiveEditor(); + const nodeType = node.constructor.getType(); + const registeredNode = editor._nodes.get(nodeType); + if (registeredNode === undefined) { + invariant( + false, + '$initializeNode failed. Ensure node has been registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.', + ); + } + const replaceFunc = registeredNode.replace; + if (replaceFunc !== null) { + const replacementNode = replaceFunc(node) as N; + if (!(replacementNode instanceof node.constructor)) { + invariant( + false, + '$initializeNode failed. Ensure replacement node is a subclass of the original node.', + ); + } + return replacementNode; + } + return node as N; +} + +export function errorOnInsertTextNodeOnRoot( + node: LexicalNode, + insertNode: LexicalNode, +): void { + const parentNode = node.getParent(); + if ( + $isRootNode(parentNode) && + !$isElementNode(insertNode) && + !$isDecoratorNode(insertNode) + ) { + invariant( + false, + 'Only element or decorator nodes can be inserted in to the root node', + ); + } +} + +export function $getNodeByKeyOrThrow(key: NodeKey): N { + const node = $getNodeByKey(key); + if (node === null) { + invariant( + false, + "Expected node with key %s to exist but it's not in the nodeMap.", + key, + ); + } + return node; +} + +function createBlockCursorElement(editorConfig: EditorConfig): HTMLDivElement { + const theme = editorConfig.theme; + const element = document.createElement('div'); + element.contentEditable = 'false'; + element.setAttribute('data-lexical-cursor', 'true'); + let blockCursorTheme = theme.blockCursor; + if (blockCursorTheme !== undefined) { + if (typeof blockCursorTheme === 'string') { + const classNamesArr = normalizeClassNames(blockCursorTheme); + // @ts-expect-error: intentional + blockCursorTheme = theme.blockCursor = classNamesArr; + } + if (blockCursorTheme !== undefined) { + element.classList.add(...blockCursorTheme); + } + } + return element; +} + +function needsBlockCursor(node: null | LexicalNode): boolean { + return ( + ($isDecoratorNode(node) || ($isElementNode(node) && !node.canBeEmpty())) && + !node.isInline() + ); +} + +export function removeDOMBlockCursorElement( + blockCursorElement: HTMLElement, + editor: LexicalEditor, + rootElement: HTMLElement, +) { + rootElement.style.removeProperty('caret-color'); + editor._blockCursorElement = null; + const parentElement = blockCursorElement.parentElement; + if (parentElement !== null) { + parentElement.removeChild(blockCursorElement); + } +} + +export function updateDOMBlockCursorElement( + editor: LexicalEditor, + rootElement: HTMLElement, + nextSelection: null | BaseSelection, +): void { + let blockCursorElement = editor._blockCursorElement; + + if ( + $isRangeSelection(nextSelection) && + nextSelection.isCollapsed() && + nextSelection.anchor.type === 'element' && + rootElement.contains(document.activeElement) + ) { + const anchor = nextSelection.anchor; + const elementNode = anchor.getNode(); + const offset = anchor.offset; + const elementNodeSize = elementNode.getChildrenSize(); + let isBlockCursor = false; + let insertBeforeElement: null | HTMLElement = null; + + if (offset === elementNodeSize) { + const child = elementNode.getChildAtIndex(offset - 1); + if (needsBlockCursor(child)) { + isBlockCursor = true; + } + } else { + const child = elementNode.getChildAtIndex(offset); + if (needsBlockCursor(child)) { + const sibling = (child as LexicalNode).getPreviousSibling(); + if (sibling === null || needsBlockCursor(sibling)) { + isBlockCursor = true; + insertBeforeElement = editor.getElementByKey( + (child as LexicalNode).__key, + ); + } + } + } + if (isBlockCursor) { + const elementDOM = editor.getElementByKey( + elementNode.__key, + ) as HTMLElement; + if (blockCursorElement === null) { + editor._blockCursorElement = blockCursorElement = + createBlockCursorElement(editor._config); + } + rootElement.style.caretColor = 'transparent'; + if (insertBeforeElement === null) { + elementDOM.appendChild(blockCursorElement); + } else { + elementDOM.insertBefore(blockCursorElement, insertBeforeElement); + } + return; + } + } + // Remove cursor + if (blockCursorElement !== null) { + removeDOMBlockCursorElement(blockCursorElement, editor, rootElement); + } +} + +export function getDOMSelection(targetWindow: null | Window): null | Selection { + return !CAN_USE_DOM ? null : (targetWindow || window).getSelection(); +} + +export function $splitNode( + node: ElementNode, + offset: number, +): [ElementNode | null, ElementNode] { + let startNode = node.getChildAtIndex(offset); + if (startNode == null) { + startNode = node; + } + + invariant( + !$isRootOrShadowRoot(node), + 'Can not call $splitNode() on root element', + ); + + const recurse = ( + currentNode: T, + ): [ElementNode, ElementNode, T] => { + const parent = currentNode.getParentOrThrow(); + const isParentRoot = $isRootOrShadowRoot(parent); + // The node we start split from (leaf) is moved, but its recursive + // parents are copied to create separate tree + const nodeToMove = + currentNode === startNode && !isParentRoot + ? currentNode + : $copyNode(currentNode); + + if (isParentRoot) { + invariant( + $isElementNode(currentNode) && $isElementNode(nodeToMove), + 'Children of a root must be ElementNode', + ); + + currentNode.insertAfter(nodeToMove); + return [currentNode, nodeToMove, nodeToMove]; + } else { + const [leftTree, rightTree, newParent] = recurse(parent); + const nextSiblings = currentNode.getNextSiblings(); + + newParent.append(nodeToMove, ...nextSiblings); + return [leftTree, rightTree, nodeToMove]; + } + }; + + const [leftTree, rightTree] = recurse(startNode); + + return [leftTree, rightTree]; +} + +export function $findMatchingParent( + startingNode: LexicalNode, + findFn: (node: LexicalNode) => boolean, +): LexicalNode | null { + let curr: ElementNode | LexicalNode | null = startingNode; + + while (curr !== $getRoot() && curr != null) { + if (findFn(curr)) { + return curr; + } + + curr = curr.getParent(); + } + + return null; +} + +/** + * @param x - The element being tested + * @returns Returns true if x is an HTML anchor tag, false otherwise + */ +export function isHTMLAnchorElement(x: Node): x is HTMLAnchorElement { + return isHTMLElement(x) && x.tagName === 'A'; +} + +/** + * @param x - The element being testing + * @returns Returns true if x is an HTML element, false otherwise. + */ +export function isHTMLElement(x: Node | EventTarget): x is HTMLElement { + // @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors + return x.nodeType === 1; +} + +/** + * + * @param node - the Dom Node to check + * @returns if the Dom Node is an inline node + */ +export function isInlineDomNode(node: Node) { + const inlineNodes = new RegExp( + /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/, + 'i', + ); + return node.nodeName.match(inlineNodes) !== null; +} + +/** + * + * @param node - the Dom Node to check + * @returns if the Dom Node is a block node + */ +export function isBlockDomNode(node: Node) { + const blockNodes = new RegExp( + /^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/, + 'i', + ); + return node.nodeName.match(blockNodes) !== null; +} + +/** + * This function is for internal use of the library. + * Please do not use it as it may change in the future. + */ +export function INTERNAL_$isBlock( + node: LexicalNode, +): node is ElementNode | DecoratorNode { + if ($isRootNode(node) || ($isDecoratorNode(node) && !node.isInline())) { + return true; + } + if (!$isElementNode(node) || $isRootOrShadowRoot(node)) { + return false; + } + + const firstChild = node.getFirstChild(); + const isLeafElement = + firstChild === null || + $isLineBreakNode(firstChild) || + $isTextNode(firstChild) || + firstChild.isInline(); + + return !node.isInline() && node.canBeEmpty() !== false && isLeafElement; +} + +export function $getAncestor( + node: LexicalNode, + predicate: (ancestor: LexicalNode) => ancestor is NodeType, +) { + let parent = node; + while (parent !== null && parent.getParent() !== null && !predicate(parent)) { + parent = parent.getParentOrThrow(); + } + return predicate(parent) ? parent : null; +} + +/** + * Utility function for accessing current active editor instance. + * @returns Current active editor + */ +export function $getEditor(): LexicalEditor { + return getActiveEditor(); +} + +/** @internal */ +export type TypeToNodeMap = Map; +/** + * @internal + * Compute a cached Map of node type to nodes for a frozen EditorState + */ +const cachedNodeMaps = new WeakMap(); +const EMPTY_TYPE_TO_NODE_MAP: TypeToNodeMap = new Map(); +export function getCachedTypeToNodeMap( + editorState: EditorState, +): TypeToNodeMap { + // If this is a new Editor it may have a writable this._editorState + // with only a 'root' entry. + if (!editorState._readOnly && editorState.isEmpty()) { + return EMPTY_TYPE_TO_NODE_MAP; + } + invariant( + editorState._readOnly, + 'getCachedTypeToNodeMap called with a writable EditorState', + ); + let typeToNodeMap = cachedNodeMaps.get(editorState); + if (!typeToNodeMap) { + typeToNodeMap = new Map(); + cachedNodeMaps.set(editorState, typeToNodeMap); + for (const [nodeKey, node] of editorState._nodeMap) { + const nodeType = node.__type; + let nodeMap = typeToNodeMap.get(nodeType); + if (!nodeMap) { + nodeMap = new Map(); + typeToNodeMap.set(nodeType, nodeMap); + } + nodeMap.set(nodeKey, node); + } + } + return typeToNodeMap; +} + +/** + * Returns a clone of a node using `node.constructor.clone()` followed by + * `clone.afterCloneFrom(node)`. The resulting clone must have the same key, + * parent/next/prev pointers, and other properties that are not set by + * `node.constructor.clone` (format, style, etc.). This is primarily used by + * {@link LexicalNode.getWritable} to create a writable version of an + * existing node. The clone is the same logical node as the original node, + * do not try and use this function to duplicate or copy an existing node. + * + * Does not mutate the EditorState. + * @param node - The node to be cloned. + * @returns The clone of the node. + */ +export function $cloneWithProperties(latestNode: T): T { + const constructor = latestNode.constructor; + const mutableNode = constructor.clone(latestNode) as T; + mutableNode.afterCloneFrom(latestNode); + if (__DEV__) { + invariant( + mutableNode.__key === latestNode.__key, + "$cloneWithProperties: %s.clone(node) (with type '%s') did not return a node with the same key, make sure to specify node.__key as the last argument to the constructor", + constructor.name, + constructor.getType(), + ); + invariant( + mutableNode.__parent === latestNode.__parent && + mutableNode.__next === latestNode.__next && + mutableNode.__prev === latestNode.__prev, + "$cloneWithProperties: %s.clone(node) (with type '%s') overrided afterCloneFrom but did not call super.afterCloneFrom(prevNode)", + constructor.name, + constructor.getType(), + ); + } + return mutableNode; +} diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts new file mode 100644 index 000000000..5d6a9311b --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts @@ -0,0 +1,144 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import { + $createParagraphNode, + $getRoot, + $getSelection, + $isRangeSelection, +} from 'lexical'; +import { + DataTransferMock, + initializeUnitTest, + invariant, +} from 'lexical/__tests__/utils'; + +describe('CodeBlock tests', () => { + initializeUnitTest( + (testEnv) => { + beforeEach(async () => { + const {editor} = testEnv; + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.select(); + }); + }); + + /** + * Code example for tests: + * + * function run() { + * return [null, undefined, 2, ""]; + * } + * + */ + const EXPECTED_HTML = `function run() {
    return [null, undefined, 2, ""];
    }
    `; + + const CODE_PASTING_TESTS = [ + { + expectedHTML: EXPECTED_HTML, + name: 'VS Code', + pastedHTML: `

    function run() {
    return [null, undefined, 2, ""];
    }
    `, + }, + { + expectedHTML: EXPECTED_HTML, + name: 'Quip', + pastedHTML: `
    function run() {
    return [null, undefined, 2, ""];
    }
    `, + }, + { + expectedHTML: EXPECTED_HTML, + name: 'WebStorm / Idea', + pastedHTML: `
    function run() {
    return [null, undefined, 2, ""];
    }
    `, + }, + { + expectedHTML: `function run() {
    return [null, undefined, 2, ""];
    }
    `, + name: 'Postman IDE', + pastedHTML: `
    function run() {
    return [null, undefined, 2, ""];
    }
    `, + }, + { + expectedHTML: EXPECTED_HTML, + name: 'Slack message', + pastedHTML: `
    function run() {\n  return [null, undefined, 2, ""];\n}
    `, + }, + { + expectedHTML: `const Lexical = requireCond('gk', 'runtime_is_dev', {
    true: 'Lexical.dev',
    false: 'Lexical.prod',
    });
    `, + name: 'CodeHub', + pastedHTML: `
    const Lexical = requireCond('gk', 'runtime_is_dev', {
    true: 'Lexical.dev',
    false: 'Lexical.prod',
    });
    `, + }, + { + expectedHTML: EXPECTED_HTML, + name: 'GitHub / Gist', + pastedHTML: `
    function run() {
    return [null, undefined, 2, ""];
    }
    `, + }, + { + expectedHTML: `

    12

    `, + name: 'Single line ', + pastedHTML: `12`, + }, + { + expectedHTML: `1
    2
    `, + name: 'Multiline ', + // TODO This is not correct. This resembles how Lexical exports code right now but + // semantically it should be wrapped in a pre + pastedHTML: `1
    2
    `, + }, + { + expectedHTML: `

    Hello World Lexical

    `, + name: 'Multiple text formats', + pastedHTML: `Hello World Lexical`, + }, + { + expectedHTML: `

    My document

    `, + name: 'Title from Google Docs', + pastedHTML: `My document`, + }, + { + expectedHTML: `

    My document

    `, + name: 'Title from Google Docs Wrapped in Paragraph', + pastedHTML: `

    My document

    `, + }, + { + expectedHTML: `

    subscript and superscript

    `, + name: 'Subscript and Superscript', + pastedHTML: `subscript and superscript`, + }, + ]; + + CODE_PASTING_TESTS.forEach((testCase, i) => { + test(`Code block html paste: ${testCase.name}`, async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData('text/html', testCase.pastedHTML); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe(testCase.expectedHTML); + }); + }); + }, + { + namespace: 'test', + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }, + ); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts new file mode 100644 index 000000000..b14654838 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import { + $createParagraphNode, + $getRoot, + $getSelection, + $isRangeSelection, +} from 'lexical'; +import { + DataTransferMock, + initializeUnitTest, + invariant, +} from 'lexical/src/__tests__/utils'; + +describe('HTMLCopyAndPaste tests', () => { + initializeUnitTest( + (testEnv) => { + beforeEach(async () => { + const {editor} = testEnv; + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.select(); + }); + }); + + const HTML_COPY_PASTING_TESTS = [ + { + expectedHTML: `

    Hello!

    `, + name: 'plain DOM text node', + pastedHTML: `Hello!`, + }, + { + expectedHTML: `

    Hello!


    `, + name: 'a paragraph element', + pastedHTML: `

    Hello!

    `, + }, + { + expectedHTML: `

    123

    456

    `, + name: 'a single div', + pastedHTML: `123 +
    + 456 +
    `, + }, + { + expectedHTML: `

    a b c d e

    f g h

    `, + name: 'multiple nested spans and divs', + pastedHTML: `
    + a b + + c d + e + +
    + f + g h +
    +
    `, + }, + { + expectedHTML: `

    123

    456

    `, + name: 'nested span in a div', + pastedHTML: `
    + + 123 +
    456
    +
    +
    `, + }, + { + expectedHTML: `

    123

    456

    `, + name: 'nested div in a span', + pastedHTML: ` 123
    456
    `, + }, + { + expectedHTML: `
    • done
    • todo
      • done
      • todo
    • todo
    `, + name: 'google doc checklist', + pastedHTML: `
    • checked

      done

    • unchecked

      todo

      • checked

        done

      • unchecked

        todo

    • unchecked

      todo

    `, + }, + { + expectedHTML: `

    checklist

    • done
    • todo
    `, + name: 'github checklist', + pastedHTML: `

    checklist

    • done
    • todo
    `, + }, + ]; + + HTML_COPY_PASTING_TESTS.forEach((testCase, i) => { + test(`HTML copy paste: ${testCase.name}`, async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData('text/html', testCase.pastedHTML); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe(testCase.expectedHTML); + }); + }); + }, + { + namespace: 'test', + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }, + ); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts new file mode 100644 index 000000000..4ca6b77c8 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -0,0 +1,2856 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, + TableCellNode, + TableRowNode, +} from '@lexical/table'; +import { + $createLineBreakNode, + $createNodeSelection, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getEditor, + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + $isParagraphNode, + $isTextNode, + $parseSerializedNode, + $setCompositionKey, + $setSelection, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_LOW, + createCommand, + createEditor, + EditorState, + ElementNode, + type Klass, + type LexicalEditor, + type LexicalNode, + type LexicalNodeReplacement, + ParagraphNode, + RootNode, + TextNode, +} from 'lexical'; + +import invariant from 'lexical/shared/invariant'; + +import { + $createTestDecoratorNode, + $createTestElementNode, + $createTestInlineElementNode, + createTestEditor, + createTestHeadlessEditor, + TestTextNode, +} from '../utils'; + +describe('LexicalEditor tests', () => { + let container: HTMLElement; + let reactRoot: Root; + + beforeEach(() => { + container = document.createElement('div'); + reactRoot = createRoot(container); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + // @ts-ignore + container = null; + + jest.restoreAllMocks(); + }); + + function useLexicalEditor( + rootElementRef: React.RefObject, + onError?: (error: Error) => void, + nodes?: ReadonlyArray | LexicalNodeReplacement>, + ) { + const editor = useMemo( + () => + createTestEditor({ + nodes: nodes ?? [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }), + [onError, nodes], + ); + + useEffect(() => { + const rootElement = rootElementRef.current; + + editor.setRootElement(rootElement); + }, [rootElementRef, editor]); + + return editor; + } + + let editor: LexicalEditor; + + function init(onError?: (error: Error) => void) { + const ref = createRef(); + + function TestBase() { + editor = useLexicalEditor(ref, onError); + + return
    ; + } + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + } + + async function update(fn: () => void) { + editor.update(fn); + + return Promise.resolve().then(); + } + + describe('read()', () => { + it('Can read the editor state', async () => { + init(function onError(err) { + throw err; + }); + expect(editor.read(() => $getRoot().getTextContent())).toEqual(''); + expect(editor.read(() => $getEditor())).toBe(editor); + const onUpdate = jest.fn(); + editor.update( + () => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + }, + {onUpdate}, + ); + expect(onUpdate).toHaveBeenCalledTimes(0); + // This read will flush pending updates + expect(editor.read(() => $getRoot().getTextContent())).toEqual( + 'This works!', + ); + expect(onUpdate).toHaveBeenCalledTimes(1); + // Check to make sure there is not an unexpected reconciliation + await Promise.resolve().then(); + expect(onUpdate).toHaveBeenCalledTimes(1); + editor.read(() => { + const rootElement = editor.getRootElement(); + expect(rootElement).toBeDefined(); + // The root never works for this call + expect($getNearestNodeFromDOMNode(rootElement!)).toBe(null); + const paragraphDom = rootElement!.querySelector('p'); + expect(paragraphDom).toBeDefined(); + expect( + $isParagraphNode($getNearestNodeFromDOMNode(paragraphDom!)), + ).toBe(true); + expect( + $getNearestNodeFromDOMNode(paragraphDom!)!.getTextContent(), + ).toBe('This works!'); + const textDom = paragraphDom!.querySelector('span'); + expect(textDom).toBeDefined(); + expect($isTextNode($getNearestNodeFromDOMNode(textDom!))).toBe(true); + expect($getNearestNodeFromDOMNode(textDom!)!.getTextContent()).toBe( + 'This works!', + ); + expect( + $getNearestNodeFromDOMNode(textDom!.firstChild!)!.getTextContent(), + ).toBe('This works!'); + }); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + it('runs transforms the editor state', async () => { + init(function onError(err) { + throw err; + }); + expect(editor.read(() => $getRoot().getTextContent())).toEqual(''); + expect(editor.read(() => $getEditor())).toBe(editor); + editor.registerNodeTransform(TextNode, (node) => { + if (node.getTextContent() === 'This works!') { + node.replace($createTextNode('Transforms work!')); + } + }); + const onUpdate = jest.fn(); + editor.update( + () => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + }, + {onUpdate}, + ); + expect(onUpdate).toHaveBeenCalledTimes(0); + // This read will flush pending updates + expect(editor.read(() => $getRoot().getTextContent())).toEqual( + 'Transforms work!', + ); + expect(editor.getRootElement()!.textContent).toEqual('Transforms work!'); + expect(onUpdate).toHaveBeenCalledTimes(1); + // Check to make sure there is not an unexpected reconciliation + await Promise.resolve().then(); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(editor.read(() => $getRoot().getTextContent())).toEqual( + 'Transforms work!', + ); + }); + it('can be nested in an update or read', async () => { + init(function onError(err) { + throw err; + }); + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('This works!'); + }); + editor.read(() => { + // Nesting update in read works, although it is discouraged in the documentation. + editor.update(() => { + expect($getRoot().getTextContent()).toBe('This works!'); + }); + }); + // Updating after a nested read will fail as it has already been committed + expect(() => { + root.append( + $createParagraphNode().append( + $createTextNode('update-read-update'), + ), + ); + }).toThrow(); + }); + editor.read(() => { + editor.read(() => { + expect($getRoot().getTextContent()).toBe('This works!'); + }); + }); + }); + }); + + it('Should create an editor with an initial editor state', async () => { + const rootElement = document.createElement('div'); + + container.appendChild(rootElement); + + const initialEditor = createTestEditor({ + onError: jest.fn(), + }); + + initialEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + }); + + initialEditor.setRootElement(rootElement); + + // Wait for update to complete + await Promise.resolve().then(); + + expect(container.innerHTML).toBe( + '

    This works!

    ', + ); + + const initialEditorState = initialEditor.getEditorState(); + initialEditor.setRootElement(null); + + expect(container.innerHTML).toBe( + '
    ', + ); + + editor = createTestEditor({ + editorState: initialEditorState, + onError: jest.fn(), + }); + editor.setRootElement(rootElement); + + expect(editor.getEditorState()).toEqual(initialEditorState); + expect(container.innerHTML).toBe( + '

    This works!

    ', + ); + }); + + it('Should handle nested updates in the correct sequence', async () => { + init(); + const onUpdate = jest.fn(); + + let log: Array = []; + + editor.registerUpdateListener(onUpdate); + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + }); + + editor.update( + () => { + log.push('A1'); + // To enforce the update + $getRoot().markDirty(); + editor.update( + () => { + log.push('B1'); + editor.update( + () => { + log.push('C1'); + }, + { + onUpdate: () => { + log.push('F1'); + }, + }, + ); + }, + { + onUpdate: () => { + log.push('E1'); + }, + }, + ); + }, + { + onUpdate: () => { + log.push('D1'); + }, + }, + ); + + // Wait for update to complete + await Promise.resolve().then(); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(log).toEqual(['A1', 'B1', 'C1', 'D1', 'E1', 'F1']); + + log = []; + editor.update( + () => { + log.push('A2'); + // To enforce the update + $getRoot().markDirty(); + }, + { + onUpdate: () => { + log.push('B2'); + editor.update( + () => { + // force flush sync + $setCompositionKey('root'); + log.push('D2'); + }, + { + onUpdate: () => { + log.push('F2'); + }, + }, + ); + log.push('C2'); + editor.update( + () => { + log.push('E2'); + }, + { + onUpdate: () => { + log.push('G2'); + }, + }, + ); + }, + }, + ); + + // Wait for update to complete + await Promise.resolve().then(); + + expect(log).toEqual(['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2']); + + log = []; + editor.registerNodeTransform(TextNode, () => { + log.push('TextTransform A3'); + editor.update( + () => { + log.push('TextTransform B3'); + }, + { + onUpdate: () => { + log.push('TextTransform C3'); + }, + }, + ); + }); + + // Wait for update to complete + await Promise.resolve().then(); + + expect(log).toEqual([ + 'TextTransform A3', + 'TextTransform B3', + 'TextTransform C3', + ]); + + log = []; + editor.update( + () => { + log.push('A3'); + $getRoot().getLastDescendant()!.markDirty(); + }, + { + onUpdate: () => { + log.push('B3'); + }, + }, + ); + + // Wait for update to complete + await Promise.resolve().then(); + + expect(log).toEqual([ + 'A3', + 'TextTransform A3', + 'TextTransform B3', + 'B3', + 'TextTransform C3', + ]); + }); + + it('nested update after selection update triggers exactly 1 update', async () => { + init(); + const onUpdate = jest.fn(); + editor.registerUpdateListener(onUpdate); + editor.update(() => { + $setSelection($createRangeSelection()); + editor.update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Sync update')), + ); + }); + }); + + await Promise.resolve().then(); + + const textContent = editor + .getEditorState() + .read(() => $getRoot().getTextContent()); + expect(textContent).toBe('Sync update'); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('update does not call onUpdate callback when no dirty nodes', () => { + init(); + + const fn = jest.fn(); + editor.update( + () => { + // + }, + { + onUpdate: fn, + }, + ); + expect(fn).toHaveBeenCalledTimes(0); + }); + + it('editor.focus() callback is called', async () => { + init(); + + await editor.update(() => { + const root = $getRoot(); + root.append($createParagraphNode()); + }); + + const fn = jest.fn(); + + await editor.focus(fn); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('Synchronously runs three transforms, two of them depend on the other', async () => { + init(); + + // 2. Add italics + const italicsListener = editor.registerNodeTransform(TextNode, (node) => { + if ( + node.getTextContent() === 'foo' && + node.hasFormat('bold') && + !node.hasFormat('italic') + ) { + node.toggleFormat('italic'); + } + }); + + // 1. Add bold + const boldListener = editor.registerNodeTransform(TextNode, (node) => { + if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) { + node.toggleFormat('bold'); + } + }); + + // 2. Add underline + const underlineListener = editor.registerNodeTransform(TextNode, (node) => { + if ( + node.getTextContent() === 'foo' && + node.hasFormat('bold') && + !node.hasFormat('underline') + ) { + node.toggleFormat('underline'); + } + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.append($createTextNode('foo')); + }); + italicsListener(); + boldListener(); + underlineListener(); + + expect(container.innerHTML).toBe( + '

    foo

    ', + ); + }); + + it('Synchronously runs three transforms, two of them depend on the other (2)', async () => { + await init(); + + // Add transform makes everything dirty the first time (let's not leverage this here) + const skipFirst = [true, true, true]; + + // 2. (Block transform) Add text + const testParagraphListener = editor.registerNodeTransform( + ParagraphNode, + (paragraph) => { + if (skipFirst[0]) { + skipFirst[0] = false; + + return; + } + + if (paragraph.isEmpty()) { + paragraph.append($createTextNode('foo')); + } + }, + ); + + // 2. (Text transform) Add bold to text + const boldListener = editor.registerNodeTransform(TextNode, (node) => { + if (node.getTextContent() === 'foo' && !node.hasFormat('bold')) { + node.toggleFormat('bold'); + } + }); + + // 3. (Block transform) Add italics to bold text + const italicsListener = editor.registerNodeTransform( + ParagraphNode, + (paragraph) => { + const child = paragraph.getLastDescendant(); + + if ( + $isTextNode(child) && + child.hasFormat('bold') && + !child.hasFormat('italic') + ) { + child.toggleFormat('italic'); + } + }, + ); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + paragraph!.markDirty(); + }); + + testParagraphListener(); + boldListener(); + italicsListener(); + + expect(container.innerHTML).toBe( + '

    foo

    ', + ); + }); + + it('Synchronously runs three transforms, two of them depend on previously merged text content', async () => { + const hasRun = [false, false, false]; + init(); + + // 1. [Foo] into [,Fo,o,,!,] + const fooListener = editor.registerNodeTransform(TextNode, (node) => { + if (node.getTextContent() === 'Foo' && !hasRun[0]) { + const [before, after] = node.splitText(2); + + before.insertBefore($createTextNode('')); + after.insertAfter($createTextNode('')); + after.insertAfter($createTextNode('!')); + after.insertAfter($createTextNode('')); + + hasRun[0] = true; + } + }); + + // 2. [Foo!] into [,Fo,o!,,!,] + const megaFooListener = editor.registerNodeTransform( + ParagraphNode, + (paragraph) => { + const child = paragraph.getFirstChild(); + + if ( + $isTextNode(child) && + child.getTextContent() === 'Foo!' && + !hasRun[1] + ) { + const [before, after] = child.splitText(2); + + before.insertBefore($createTextNode('')); + after.insertAfter($createTextNode('')); + after.insertAfter($createTextNode('!')); + after.insertAfter($createTextNode('')); + + hasRun[1] = true; + } + }, + ); + + // 3. [Foo!!] into formatted bold [,Fo,o!!,] + const boldFooListener = editor.registerNodeTransform(TextNode, (node) => { + if (node.getTextContent() === 'Foo!!' && !hasRun[2]) { + node.toggleFormat('bold'); + + const [before, after] = node.splitText(2); + before.insertBefore($createTextNode('')); + after.insertAfter($createTextNode('')); + + hasRun[2] = true; + } + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + + root.append(paragraph); + paragraph.append($createTextNode('Foo')); + }); + + fooListener(); + megaFooListener(); + boldFooListener(); + + expect(container.innerHTML).toBe( + '

    Foo!!

    ', + ); + }); + + it('text transform runs when node is removed', async () => { + init(); + + const executeTransform = jest.fn(); + let hasBeenRemoved = false; + const removeListener = editor.registerNodeTransform(TextNode, (node) => { + if (hasBeenRemoved) { + executeTransform(); + } + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.append( + $createTextNode('Foo').toggleUnmergeable(), + $createTextNode('Bar').toggleUnmergeable(), + ); + }); + + await editor.update(() => { + $getRoot().getLastDescendant()!.remove(); + hasBeenRemoved = true; + }); + + expect(executeTransform).toHaveBeenCalledTimes(1); + + removeListener(); + }); + + it('transforms only run on nodes that were explicitly marked as dirty', async () => { + init(); + + let executeParagraphNodeTransform = () => { + return; + }; + + let executeTextNodeTransform = () => { + return; + }; + + const removeParagraphTransform = editor.registerNodeTransform( + ParagraphNode, + (node) => { + executeParagraphNodeTransform(); + }, + ); + const removeTextNodeTransform = editor.registerNodeTransform( + TextNode, + (node) => { + executeTextNodeTransform(); + }, + ); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.append($createTextNode('Foo')); + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild() as ParagraphNode; + const textNode = paragraph.getFirstChild() as TextNode; + + textNode.getWritable(); + + executeParagraphNodeTransform = jest.fn(); + executeTextNodeTransform = jest.fn(); + }); + + expect(executeParagraphNodeTransform).toHaveBeenCalledTimes(0); + expect(executeTextNodeTransform).toHaveBeenCalledTimes(1); + + removeParagraphTransform(); + removeTextNodeTransform(); + }); + + describe('transforms on siblings', () => { + let textNodeKeys: string[]; + let textTransformCount: number[]; + let removeTransform: () => void; + + beforeEach(async () => { + init(); + + textNodeKeys = []; + textTransformCount = []; + + await editor.update(() => { + const root = $getRoot(); + const paragraph0 = $createParagraphNode(); + const paragraph1 = $createParagraphNode(); + const textNodes: Array = []; + + for (let i = 0; i < 6; i++) { + const node = $createTextNode(String(i)).toggleUnmergeable(); + textNodes.push(node); + textNodeKeys.push(node.getKey()); + textTransformCount[i] = 0; + } + + root.append(paragraph0, paragraph1); + paragraph0.append(...textNodes.slice(0, 3)); + paragraph1.append(...textNodes.slice(3)); + }); + + removeTransform = editor.registerNodeTransform(TextNode, (node) => { + textTransformCount[Number(node.__text)]++; + }); + }); + + afterEach(() => { + removeTransform(); + }); + + it('on remove', async () => { + await editor.update(() => { + const textNode1 = $getNodeByKey(textNodeKeys[1])!; + textNode1.remove(); + }); + expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1]); + }); + + it('on replace', async () => { + await editor.update(() => { + const textNode1 = $getNodeByKey(textNodeKeys[1])!; + const textNode4 = $getNodeByKey(textNodeKeys[4])!; + textNode4.replace(textNode1); + }); + expect(textTransformCount).toEqual([2, 2, 2, 2, 1, 2]); + }); + + it('on insertBefore', async () => { + await editor.update(() => { + const textNode1 = $getNodeByKey(textNodeKeys[1])!; + const textNode4 = $getNodeByKey(textNodeKeys[4])!; + textNode4.insertBefore(textNode1); + }); + expect(textTransformCount).toEqual([2, 2, 2, 2, 2, 1]); + }); + + it('on insertAfter', async () => { + await editor.update(() => { + const textNode1 = $getNodeByKey(textNodeKeys[1])!; + const textNode4 = $getNodeByKey(textNodeKeys[4])!; + textNode4.insertAfter(textNode1); + }); + expect(textTransformCount).toEqual([2, 2, 2, 1, 2, 2]); + }); + + it('on splitText', async () => { + await editor.update(() => { + const textNode1 = $getNodeByKey(textNodeKeys[1]) as TextNode; + textNode1.setTextContent('67'); + textNode1.splitText(1); + textTransformCount.push(0, 0); + }); + expect(textTransformCount).toEqual([2, 1, 2, 1, 1, 1, 1, 1]); + }); + + it('on append', async () => { + await editor.update(() => { + const paragraph1 = $getRoot().getFirstChild() as ParagraphNode; + paragraph1.append($createTextNode('6').toggleUnmergeable()); + textTransformCount.push(0); + }); + expect(textTransformCount).toEqual([1, 1, 2, 1, 1, 1, 1]); + }); + }); + + it('Detects infinite recursivity on transforms', async () => { + const errorListener = jest.fn(); + init(errorListener); + + const boldListener = editor.registerNodeTransform(TextNode, (node) => { + node.toggleFormat('bold'); + }); + + expect(errorListener).toHaveBeenCalledTimes(0); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.append($createTextNode('foo')); + }); + + expect(errorListener).toHaveBeenCalledTimes(1); + boldListener(); + }); + + it('Should be able to update an editor state without a root element', () => { + const ref = createRef(); + + function TestBase({element}: {element: HTMLElement | null}) { + editor = useMemo(() => createTestEditor(), []); + + useEffect(() => { + editor.setRootElement(element); + }, [element]); + + return
    ; + } + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + }); + + expect(container.innerHTML).toBe('
    '); + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + + expect(container.innerHTML).toBe( + '

    This works!

    ', + ); + }); + + it('Should be able to recover from an update error', async () => { + const errorListener = jest.fn(); + init(errorListener); + editor.update(() => { + const root = $getRoot(); + + if (root.getFirstChild() === null) { + const paragraph = $createParagraphNode(); + const text = $createTextNode('This works!'); + root.append(paragraph); + paragraph.append(text); + } + }); + + // Wait for update to complete + await Promise.resolve().then(); + + expect(container.innerHTML).toBe( + '

    This works!

    ', + ); + expect(errorListener).toHaveBeenCalledTimes(0); + + editor.update(() => { + const root = $getRoot(); + root + .getFirstChild()! + .getFirstChild()! + .getFirstChild()! + .setTextContent('Foo'); + }); + + expect(errorListener).toHaveBeenCalledTimes(1); + expect(container.innerHTML).toBe( + '

    This works!

    ', + ); + }); + + it('Should be able to handle a change in root element', async () => { + const rootListener = jest.fn(); + const updateListener = jest.fn(); + + function TestBase({changeElement}: {changeElement: boolean}) { + editor = useMemo(() => createTestEditor(), []); + + useEffect(() => { + editor.update(() => { + const root = $getRoot(); + const firstChild = root.getFirstChild() as ParagraphNode | null; + const text = changeElement ? 'Change successful' : 'Not changed'; + + if (firstChild === null) { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraph.append(textNode); + root.append(paragraph); + } else { + const textNode = firstChild.getFirstChild() as TextNode; + textNode.setTextContent(text); + } + }); + }, [changeElement]); + + useEffect(() => { + return editor.registerRootListener(rootListener); + }, []); + + useEffect(() => { + return editor.registerUpdateListener(updateListener); + }, []); + + const ref = useCallback((node: HTMLElement | null) => { + editor.setRootElement(node); + }, []); + + return changeElement ? ( + + ) : ( +
    + ); + } + + await ReactTestUtils.act(() => { + reactRoot.render(); + }); + + expect(container.innerHTML).toBe( + '

    Not changed

    ', + ); + + await ReactTestUtils.act(() => { + reactRoot.render(); + }); + + expect(rootListener).toHaveBeenCalledTimes(3); + expect(updateListener).toHaveBeenCalledTimes(3); + expect(container.innerHTML).toBe( + '

    Change successful

    ', + ); + }); + + for (const editable of [true, false]) { + it(`Retains pendingEditor while rootNode is not set (${ + editable ? 'editable' : 'non-editable' + })`, async () => { + const JSON_EDITOR_STATE = + '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; + init(); + const contentEditable = editor.getRootElement(); + editor.setEditable(editable); + editor.setRootElement(null); + const editorState = editor.parseEditorState(JSON_EDITOR_STATE); + editor.setEditorState(editorState); + editor.update(() => { + // + }); + editor.setRootElement(contentEditable); + expect(JSON.stringify(editor.getEditorState().toJSON())).toBe( + JSON_EDITOR_STATE, + ); + }); + } + + describe('With node decorators', () => { + function useDecorators() { + const [decorators, setDecorators] = useState(() => + editor.getDecorators(), + ); + + // Subscribe to changes + useEffect(() => { + return editor.registerDecoratorListener((nextDecorators) => { + setDecorators(nextDecorators); + }); + }, []); + + const decoratedPortals = useMemo( + () => + Object.keys(decorators).map((nodeKey) => { + const reactDecorator = decorators[nodeKey]; + const element = editor.getElementByKey(nodeKey)!; + + return createPortal(reactDecorator, element); + }), + [decorators], + ); + + return decoratedPortals; + } + + afterEach(async () => { + // Clean up so we are not calling setState outside of act + await ReactTestUtils.act(async () => { + reactRoot.render(null); + await Promise.resolve().then(); + }); + }); + + it('Should correctly render React component into Lexical node #1', async () => { + const listener = jest.fn(); + + function Test() { + editor = useMemo(() => createTestEditor(), []); + + useEffect(() => { + editor.registerRootListener(listener); + }, []); + + const ref = useCallback((node: HTMLDivElement | null) => { + editor.setRootElement(node); + }, []); + + const decorators = useDecorators(); + + return ( + <> +
    + {decorators} + + ); + } + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + // Update the editor with the decorator + await ReactTestUtils.act(async () => { + await editor.update(() => { + const paragraph = $createParagraphNode(); + const test = $createTestDecoratorNode(); + paragraph.append(test); + $getRoot().append(paragraph); + }); + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(container.innerHTML).toBe( + '

    ' + + 'Hello world

    ', + ); + }); + + it('Should correctly render React component into Lexical node #2', async () => { + const listener = jest.fn(); + + function Test({divKey}: {divKey: number}): JSX.Element { + function TestPlugin() { + [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerRootListener(listener); + }, []); + + return null; + } + + return ( + + + } + placeholder={null} + ErrorBoundary={LexicalErrorBoundary} + /> + + + ); + } + + await ReactTestUtils.act(async () => { + reactRoot.render(); + // Wait for update to complete + await Promise.resolve().then(); + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(container.innerHTML).toBe( + '


    ', + ); + + await ReactTestUtils.act(async () => { + reactRoot.render(); + // Wait for update to complete + await Promise.resolve().then(); + }); + + expect(listener).toHaveBeenCalledTimes(5); + expect(container.innerHTML).toBe( + '


    ', + ); + + // Wait for update to complete + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild()!; + expect(root).toEqual({ + __cachedText: '', + __dir: null, + __first: paragraph.getKey(), + __format: 0, + __indent: 0, + __key: 'root', + __last: paragraph.getKey(), + __next: null, + __parent: null, + __prev: null, + __size: 1, + __style: '', + __type: 'root', + }); + expect(paragraph).toEqual({ + __dir: null, + __first: null, + __format: 0, + __indent: 0, + __key: paragraph.getKey(), + __last: null, + __next: null, + __parent: 'root', + __prev: null, + __size: 0, + __style: '', + __textFormat: 0, + __textStyle: '', + __type: 'paragraph', + }); + }); + }); + }); + + describe('parseEditorState()', () => { + let originalText: TextNode; + let parsedParagraph: ParagraphNode; + let parsedRoot: RootNode; + let parsedText: TextNode; + let paragraphKey: string; + let textKey: string; + let parsedEditorState: EditorState; + + it('exportJSON API - parses parsed JSON', async () => { + await update(() => { + const paragraph = $createParagraphNode(); + originalText = $createTextNode('Hello world'); + originalText.select(6, 11); + paragraph.append(originalText); + $getRoot().append(paragraph); + }); + const stringifiedEditorState = JSON.stringify(editor.getEditorState()); + const parsedEditorStateFromObject = editor.parseEditorState( + JSON.parse(stringifiedEditorState), + ); + parsedEditorStateFromObject.read(() => { + const root = $getRoot(); + expect(root.getTextContent()).toMatch(/Hello world/); + }); + }); + + describe('range selection', () => { + beforeEach(async () => { + await init(); + + await update(() => { + const paragraph = $createParagraphNode(); + originalText = $createTextNode('Hello world'); + originalText.select(6, 11); + paragraph.append(originalText); + $getRoot().append(paragraph); + }); + const stringifiedEditorState = JSON.stringify( + editor.getEditorState().toJSON(), + ); + parsedEditorState = editor.parseEditorState(stringifiedEditorState); + parsedEditorState.read(() => { + parsedRoot = $getRoot(); + parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode; + paragraphKey = parsedParagraph.getKey(); + parsedText = parsedParagraph.getFirstChild() as TextNode; + textKey = parsedText.getKey(); + }); + }); + + it('Parses the nodes of a stringified editor state', async () => { + expect(parsedRoot).toEqual({ + __cachedText: null, + __dir: 'ltr', + __first: paragraphKey, + __format: 0, + __indent: 0, + __key: 'root', + __last: paragraphKey, + __next: null, + __parent: null, + __prev: null, + __size: 1, + __style: '', + __type: 'root', + }); + expect(parsedParagraph).toEqual({ + __dir: 'ltr', + __first: textKey, + __format: 0, + __indent: 0, + __key: paragraphKey, + __last: textKey, + __next: null, + __parent: 'root', + __prev: null, + __size: 1, + __style: '', + __textFormat: 0, + __textStyle: '', + __type: 'paragraph', + }); + expect(parsedText).toEqual({ + __detail: 0, + __format: 0, + __key: textKey, + __mode: 0, + __next: null, + __parent: paragraphKey, + __prev: null, + __style: '', + __text: 'Hello world', + __type: 'text', + }); + }); + + it('Parses the text content of the editor state', async () => { + expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe( + null, + ); + expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe( + 'Hello world', + ); + }); + }); + + describe('node selection', () => { + beforeEach(async () => { + init(); + + await update(() => { + const paragraph = $createParagraphNode(); + originalText = $createTextNode('Hello world'); + const selection = $createNodeSelection(); + selection.add(originalText.getKey()); + $setSelection(selection); + paragraph.append(originalText); + $getRoot().append(paragraph); + }); + const stringifiedEditorState = JSON.stringify( + editor.getEditorState().toJSON(), + ); + parsedEditorState = editor.parseEditorState(stringifiedEditorState); + parsedEditorState.read(() => { + parsedRoot = $getRoot(); + parsedParagraph = parsedRoot.getFirstChild() as ParagraphNode; + paragraphKey = parsedParagraph.getKey(); + parsedText = parsedParagraph.getFirstChild() as TextNode; + textKey = parsedText.getKey(); + }); + }); + + it('Parses the nodes of a stringified editor state', async () => { + expect(parsedRoot).toEqual({ + __cachedText: null, + __dir: 'ltr', + __first: paragraphKey, + __format: 0, + __indent: 0, + __key: 'root', + __last: paragraphKey, + __next: null, + __parent: null, + __prev: null, + __size: 1, + __style: '', + __type: 'root', + }); + expect(parsedParagraph).toEqual({ + __dir: 'ltr', + __first: textKey, + __format: 0, + __indent: 0, + __key: paragraphKey, + __last: textKey, + __next: null, + __parent: 'root', + __prev: null, + __size: 1, + __style: '', + __textFormat: 0, + __textStyle: '', + __type: 'paragraph', + }); + expect(parsedText).toEqual({ + __detail: 0, + __format: 0, + __key: textKey, + __mode: 0, + __next: null, + __parent: paragraphKey, + __prev: null, + __style: '', + __text: 'Hello world', + __type: 'text', + }); + }); + + it('Parses the text content of the editor state', async () => { + expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe( + null, + ); + expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe( + 'Hello world', + ); + }); + }); + }); + + describe('$parseSerializedNode()', () => { + it('parses serialized nodes', async () => { + const expectedTextContent = 'Hello world\n\nHello world'; + let actualTextContent: string; + let root: RootNode; + await update(() => { + root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('Hello world')); + root.append(paragraph); + }); + const stringifiedEditorState = JSON.stringify(editor.getEditorState()); + const parsedEditorStateJson = JSON.parse(stringifiedEditorState); + const rootJson = parsedEditorStateJson.root; + await update(() => { + const children = rootJson.children.map($parseSerializedNode); + root = $getRoot(); + root.append(...children); + actualTextContent = root.getTextContent(); + }); + expect(actualTextContent!).toEqual(expectedTextContent); + }); + }); + + describe('Node children', () => { + beforeEach(async () => { + init(); + + await reset(); + }); + + async function reset() { + init(); + + await update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + }); + } + + it('moves node to different tree branches', async () => { + function $createElementNodeWithText(text: string) { + const elementNode = $createTestElementNode(); + const textNode = $createTextNode(text); + elementNode.append(textNode); + + return [elementNode, textNode]; + } + + let paragraphNodeKey: string; + let elementNode1Key: string; + let textNode1Key: string; + let elementNode2Key: string; + let textNode2Key: string; + + await update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + paragraphNodeKey = paragraph.getKey(); + + const [elementNode1, textNode1] = $createElementNodeWithText('A'); + elementNode1Key = elementNode1.getKey(); + textNode1Key = textNode1.getKey(); + + const [elementNode2, textNode2] = $createElementNodeWithText('B'); + elementNode2Key = elementNode2.getKey(); + textNode2Key = textNode2.getKey(); + + paragraph.append(elementNode1, elementNode2); + }); + + await update(() => { + const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode; + const elementNode2 = $getNodeByKey(elementNode2Key) as TextNode; + elementNode1.append(elementNode2); + }); + const keys = [ + paragraphNodeKey!, + elementNode1Key!, + textNode1Key!, + elementNode2Key!, + textNode2Key!, + ]; + + for (let i = 0; i < keys.length; i++) { + expect(editor._editorState._nodeMap.has(keys[i])).toBe(true); + expect(editor._keyToDOMMap.has(keys[i])).toBe(true); + } + + expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root + expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root + expect(container.innerHTML).toBe( + '

    A
    B

    ', + ); + }); + + it('moves node to different tree branches (inverse)', async () => { + function $createElementNodeWithText(text: string) { + const elementNode = $createTestElementNode(); + const textNode = $createTextNode(text); + elementNode.append(textNode); + + return elementNode; + } + + let elementNode1Key: string; + let elementNode2Key: string; + + await update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + + const elementNode1 = $createElementNodeWithText('A'); + elementNode1Key = elementNode1.getKey(); + + const elementNode2 = $createElementNodeWithText('B'); + elementNode2Key = elementNode2.getKey(); + + paragraph.append(elementNode1, elementNode2); + }); + + await update(() => { + const elementNode1 = $getNodeByKey(elementNode1Key) as TextNode; + const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode; + elementNode2.append(elementNode1); + }); + + expect(container.innerHTML).toBe( + '

    B
    A

    ', + ); + }); + + it('moves node to different tree branches (node appended twice in two different branches)', async () => { + function $createElementNodeWithText(text: string) { + const elementNode = $createTestElementNode(); + const textNode = $createTextNode(text); + elementNode.append(textNode); + + return elementNode; + } + + let elementNode1Key: string; + let elementNode2Key: string; + let elementNode3Key: string; + + await update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + + const elementNode1 = $createElementNodeWithText('A'); + elementNode1Key = elementNode1.getKey(); + + const elementNode2 = $createElementNodeWithText('B'); + elementNode2Key = elementNode2.getKey(); + + const elementNode3 = $createElementNodeWithText('C'); + elementNode3Key = elementNode3.getKey(); + + paragraph.append(elementNode1, elementNode2, elementNode3); + }); + + await update(() => { + const elementNode1 = $getNodeByKey(elementNode1Key) as ElementNode; + const elementNode2 = $getNodeByKey(elementNode2Key) as ElementNode; + const elementNode3 = $getNodeByKey(elementNode3Key) as TextNode; + elementNode2.append(elementNode3); + elementNode1.append(elementNode3); + }); + + expect(container.innerHTML).toBe( + '

    A
    C
    B

    ', + ); + }); + }); + + it('can subscribe and unsubscribe from commands and the callback is fired', () => { + init(); + + const commandListener = jest.fn(); + const command = createCommand('TEST_COMMAND'); + const payload = 'testPayload'; + const removeCommandListener = editor.registerCommand( + command, + commandListener, + COMMAND_PRIORITY_EDITOR, + ); + editor.dispatchCommand(command, payload); + editor.dispatchCommand(command, payload); + editor.dispatchCommand(command, payload); + + expect(commandListener).toHaveBeenCalledTimes(3); + expect(commandListener).toHaveBeenCalledWith(payload, editor); + + removeCommandListener(); + + editor.dispatchCommand(command, payload); + editor.dispatchCommand(command, payload); + editor.dispatchCommand(command, payload); + + expect(commandListener).toHaveBeenCalledTimes(3); + expect(commandListener).toHaveBeenCalledWith(payload, editor); + }); + + it('removes the command from the command map when no listener are attached', () => { + init(); + + const commandListener = jest.fn(); + const commandListenerTwo = jest.fn(); + const command = createCommand('TEST_COMMAND'); + const removeCommandListener = editor.registerCommand( + command, + commandListener, + COMMAND_PRIORITY_EDITOR, + ); + const removeCommandListenerTwo = editor.registerCommand( + command, + commandListenerTwo, + COMMAND_PRIORITY_EDITOR, + ); + + expect(editor._commands).toEqual( + new Map([ + [ + command, + [ + new Set([commandListener, commandListenerTwo]), + new Set(), + new Set(), + new Set(), + new Set(), + ], + ], + ]), + ); + + removeCommandListener(); + + expect(editor._commands).toEqual( + new Map([ + [ + command, + [ + new Set([commandListenerTwo]), + new Set(), + new Set(), + new Set(), + new Set(), + ], + ], + ]), + ); + + removeCommandListenerTwo(); + + expect(editor._commands).toEqual(new Map()); + }); + + it('can register transforms before updates', async () => { + init(); + + const emptyTransform = () => { + return; + }; + + const removeTextTransform = editor.registerNodeTransform( + TextNode, + emptyTransform, + ); + const removeParagraphTransform = editor.registerNodeTransform( + ParagraphNode, + emptyTransform, + ); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + }); + + removeTextTransform(); + removeParagraphTransform(); + }); + + it('textcontent listener', async () => { + init(); + + const fn = jest.fn(); + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(textNode); + }); + editor.registerTextContentListener((text) => { + fn(text); + }); + + await editor.update(() => { + const root = $getRoot(); + const child = root.getLastDescendant()!; + child.insertAfter($createTextNode('bar')); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('foobar'); + + await editor.update(() => { + const root = $getRoot(); + const child = root.getLastDescendant()!; + child.insertAfter($createLineBreakNode()); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenCalledWith('foobar\n'); + + await editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + root.append(paragraph); + paragraph.append($createTextNode('bar')); + paragraph2.append($createTextNode('yar')); + paragraph.insertAfter(paragraph2); + }); + + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith('bar\n\nyar'); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + root.getLastChild()!.insertAfter(paragraph); + paragraph.append($createTextNode('bar2')); + paragraph2.append($createTextNode('yar2')); + paragraph.insertAfter(paragraph2); + }); + + expect(fn).toHaveBeenCalledTimes(4); + expect(fn).toHaveBeenCalledWith('bar\n\nyar\n\nbar2\n\nyar2'); + }); + + it('mutation listener', async () => { + init(); + + const paragraphNodeMutations = jest.fn(); + const textNodeMutations = jest.fn(); + editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, { + skipInitialization: false, + }); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + const paragraphKeys: string[] = []; + const textNodeKeys: string[] = []; + + // No await intentional (batch with next) + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(textNode); + paragraphKeys.push(paragraph.getKey()); + textNodeKeys.push(textNode.getKey()); + }); + + await editor.update(() => { + const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode; + const textNode2 = $createTextNode('bar').toggleFormat('bold'); + const textNode3 = $createTextNode('xyz').toggleFormat('italic'); + textNode.insertAfter(textNode2); + textNode2.insertAfter(textNode3); + textNodeKeys.push(textNode2.getKey()); + textNodeKeys.push(textNode3.getKey()); + }); + + await editor.update(() => { + $getRoot().clear(); + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + + paragraphKeys.push(paragraph.getKey()); + + // Created and deleted in the same update (not attached to node) + textNodeKeys.push($createTextNode('zzz').getKey()); + root.append(paragraph); + }); + + expect(paragraphNodeMutations.mock.calls.length).toBe(3); + expect(textNodeMutations.mock.calls.length).toBe(2); + + const [paragraphMutation1, paragraphMutation2, paragraphMutation3] = + paragraphNodeMutations.mock.calls; + const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls; + + expect(paragraphMutation1[0].size).toBe(1); + expect(paragraphMutation1[0].get(paragraphKeys[0])).toBe('created'); + expect(paragraphMutation1[0].size).toBe(1); + expect(paragraphMutation2[0].get(paragraphKeys[0])).toBe('destroyed'); + expect(paragraphMutation3[0].size).toBe(1); + expect(paragraphMutation3[0].get(paragraphKeys[1])).toBe('created'); + expect(textNodeMutation1[0].size).toBe(3); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created'); + expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created'); + expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created'); + expect(textNodeMutation2[0].size).toBe(3); + expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed'); + expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed'); + expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed'); + }); + it('mutation listener on newly initialized editor', async () => { + editor = createEditor(); + const textNodeMutations = jest.fn(); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + expect(textNodeMutations.mock.calls.length).toBe(0); + }); + it('mutation listener with setEditorState', async () => { + init(); + + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + + const initialEditorState = editor.getEditorState(); + const textNodeMutations = jest.fn(); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + const textNodeKeys: string[] = []; + + await editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const textNode1 = $createTextNode('foo'); + paragraph.append(textNode1); + textNodeKeys.push(textNode1.getKey()); + }); + + const fooEditorState = editor.getEditorState(); + + await editor.setEditorState(initialEditorState); + // This line should have no effect on the mutation listeners + const parsedFooEditorState = editor.parseEditorState( + JSON.stringify(fooEditorState), + ); + + await editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const textNode2 = $createTextNode('bar').toggleFormat('bold'); + const textNode3 = $createTextNode('xyz').toggleFormat('italic'); + paragraph.append(textNode2, textNode3); + textNodeKeys.push(textNode2.getKey(), textNode3.getKey()); + }); + + await editor.setEditorState(parsedFooEditorState); + + expect(textNodeMutations.mock.calls.length).toBe(4); + + const [ + textNodeMutation1, + textNodeMutation2, + textNodeMutation3, + textNodeMutation4, + ] = textNodeMutations.mock.calls; + + expect(textNodeMutation1[0].size).toBe(1); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created'); + expect(textNodeMutation2[0].size).toBe(1); + expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed'); + expect(textNodeMutation3[0].size).toBe(2); + expect(textNodeMutation3[0].get(textNodeKeys[1])).toBe('created'); + expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('created'); + expect(textNodeMutation4[0].size).toBe(3); // +1 newly generated key by parseEditorState + expect(textNodeMutation4[0].get(textNodeKeys[1])).toBe('destroyed'); + expect(textNodeMutation4[0].get(textNodeKeys[2])).toBe('destroyed'); + }); + + it('mutation listener set for original node should work with the replaced node', async () => { + const ref = createRef(); + + function TestBase() { + editor = useLexicalEditor(ref, undefined, [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + withKlass: TestTextNode, + }, + ]); + + return
    ; + } + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + + const textNodeMutations = jest.fn(); + const textNodeMutationsB = jest.fn(); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + const textNodeKeys: string[] = []; + + // No await intentional (batch with next) + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(textNode); + textNodeKeys.push(textNode.getKey()); + }); + + await editor.update(() => { + const textNode = $getNodeByKey(textNodeKeys[0]) as TextNode; + const textNode2 = $createTextNode('bar').toggleFormat('bold'); + const textNode3 = $createTextNode('xyz').toggleFormat('italic'); + textNode.insertAfter(textNode2); + textNode2.insertAfter(textNode3); + textNodeKeys.push(textNode2.getKey()); + textNodeKeys.push(textNode3.getKey()); + }); + + editor.registerMutationListener(TextNode, textNodeMutationsB, { + skipInitialization: false, + }); + + await editor.update(() => { + $getRoot().clear(); + }); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + + // Created and deleted in the same update (not attached to node) + textNodeKeys.push($createTextNode('zzz').getKey()); + root.append(paragraph); + }); + + expect(textNodeMutations.mock.calls.length).toBe(2); + expect(textNodeMutationsB.mock.calls.length).toBe(2); + + const [textNodeMutation1, textNodeMutation2] = textNodeMutations.mock.calls; + + expect(textNodeMutation1[0].size).toBe(3); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created'); + expect(textNodeMutation1[0].get(textNodeKeys[1])).toBe('created'); + expect(textNodeMutation1[0].get(textNodeKeys[2])).toBe('created'); + expect([...textNodeMutation1[1].updateTags]).toEqual([]); + expect(textNodeMutation2[0].size).toBe(3); + expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('destroyed'); + expect(textNodeMutation2[0].get(textNodeKeys[1])).toBe('destroyed'); + expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('destroyed'); + expect([...textNodeMutation2[1].updateTags]).toEqual([]); + + const [textNodeMutationB1, textNodeMutationB2] = + textNodeMutationsB.mock.calls; + + expect(textNodeMutationB1[0].size).toBe(3); + expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created'); + expect(textNodeMutationB1[0].get(textNodeKeys[1])).toBe('created'); + expect(textNodeMutationB1[0].get(textNodeKeys[2])).toBe('created'); + expect([...textNodeMutationB1[1].updateTags]).toEqual([ + 'registerMutationListener', + ]); + expect(textNodeMutationB2[0].size).toBe(3); + expect(textNodeMutationB2[0].get(textNodeKeys[0])).toBe('destroyed'); + expect(textNodeMutationB2[0].get(textNodeKeys[1])).toBe('destroyed'); + expect(textNodeMutationB2[0].get(textNodeKeys[2])).toBe('destroyed'); + expect([...textNodeMutationB2[1].updateTags]).toEqual([]); + }); + + it('mutation listener should work with the replaced node', async () => { + const ref = createRef(); + + function TestBase() { + editor = useLexicalEditor(ref, undefined, [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + withKlass: TestTextNode, + }, + ]); + + return
    ; + } + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + + const textNodeMutations = jest.fn(); + const textNodeMutationsB = jest.fn(); + editor.registerMutationListener(TestTextNode, textNodeMutations, { + skipInitialization: false, + }); + const textNodeKeys: string[] = []; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(textNode); + textNodeKeys.push(textNode.getKey()); + }); + + editor.registerMutationListener(TestTextNode, textNodeMutationsB, { + skipInitialization: false, + }); + + expect(textNodeMutations.mock.calls.length).toBe(1); + + const [textNodeMutation1] = textNodeMutations.mock.calls; + + expect(textNodeMutation1[0].size).toBe(1); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created'); + expect([...textNodeMutation1[1].updateTags]).toEqual([]); + + const [textNodeMutationB1] = textNodeMutationsB.mock.calls; + + expect(textNodeMutationB1[0].size).toBe(1); + expect(textNodeMutationB1[0].get(textNodeKeys[0])).toBe('created'); + expect([...textNodeMutationB1[1].updateTags]).toEqual([ + 'registerMutationListener', + ]); + }); + + it('mutation listeners does not trigger when other node types are mutated', async () => { + init(); + + const paragraphNodeMutations = jest.fn(); + const textNodeMutations = jest.fn(); + editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, { + skipInitialization: false, + }); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + + expect(paragraphNodeMutations.mock.calls.length).toBe(1); + expect(textNodeMutations.mock.calls.length).toBe(0); + }); + + it('mutation listeners with normalization', async () => { + init(); + + const textNodeMutations = jest.fn(); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + const textNodeKeys: string[] = []; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode1 = $createTextNode('foo'); + const textNode2 = $createTextNode('bar'); + + textNodeKeys.push(textNode1.getKey(), textNode2.getKey()); + root.append(paragraph); + paragraph.append(textNode1, textNode2); + }); + + await editor.update(() => { + const paragraph = $getRoot().getFirstChild() as ParagraphNode; + const textNode3 = $createTextNode('xyz').toggleFormat('bold'); + paragraph.append(textNode3); + textNodeKeys.push(textNode3.getKey()); + }); + + await editor.update(() => { + const textNode3 = $getNodeByKey(textNodeKeys[2]) as TextNode; + textNode3.toggleFormat('bold'); // Normalize with foobar + }); + + expect(textNodeMutations.mock.calls.length).toBe(3); + + const [textNodeMutation1, textNodeMutation2, textNodeMutation3] = + textNodeMutations.mock.calls; + + expect(textNodeMutation1[0].size).toBe(1); + expect(textNodeMutation1[0].get(textNodeKeys[0])).toBe('created'); + expect(textNodeMutation2[0].size).toBe(2); + expect(textNodeMutation2[0].get(textNodeKeys[2])).toBe('created'); + expect(textNodeMutation3[0].size).toBe(2); + expect(textNodeMutation3[0].get(textNodeKeys[0])).toBe('updated'); + expect(textNodeMutation3[0].get(textNodeKeys[2])).toBe('destroyed'); + }); + + it('mutation "update" listener', async () => { + init(); + + const paragraphNodeMutations = jest.fn(); + const textNodeMutations = jest.fn(); + + editor.registerMutationListener(ParagraphNode, paragraphNodeMutations, { + skipInitialization: false, + }); + editor.registerMutationListener(TextNode, textNodeMutations, { + skipInitialization: false, + }); + + const paragraphNodeKeys: string[] = []; + const textNodeKeys: string[] = []; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode1 = $createTextNode('foo'); + textNodeKeys.push(textNode1.getKey()); + paragraphNodeKeys.push(paragraph.getKey()); + root.append(paragraph); + paragraph.append(textNode1); + }); + + expect(paragraphNodeMutations.mock.calls.length).toBe(1); + + const [paragraphNodeMutation1] = paragraphNodeMutations.mock.calls; + expect(textNodeMutations.mock.calls.length).toBe(1); + + const [textNodeMutation1] = textNodeMutations.mock.calls; + + expect(textNodeMutation1[0].size).toBe(1); + expect(paragraphNodeMutation1[0].size).toBe(1); + + // Change first text node's content. + await editor.update(() => { + const textNode1 = $getNodeByKey(textNodeKeys[0]) as TextNode; + textNode1.setTextContent('Test'); // Normalize with foobar + }); + + // Append text node to paragraph. + await editor.update(() => { + const paragraphNode1 = $getNodeByKey( + paragraphNodeKeys[0], + ) as ParagraphNode; + const textNode1 = $createTextNode('foo'); + paragraphNode1.append(textNode1); + }); + + expect(textNodeMutations.mock.calls.length).toBe(3); + + const textNodeMutation2 = textNodeMutations.mock.calls[1]; + + // Show TextNode was updated when text content changed. + expect(textNodeMutation2[0].get(textNodeKeys[0])).toBe('updated'); + expect(paragraphNodeMutations.mock.calls.length).toBe(2); + + const paragraphNodeMutation2 = paragraphNodeMutations.mock.calls[1]; + + // Show ParagraphNode was updated when new text node was appended. + expect(paragraphNodeMutation2[0].get(paragraphNodeKeys[0])).toBe('updated'); + + let tableCellKey: string; + let tableRowKey: string; + + const tableCellMutations = jest.fn(); + const tableRowMutations = jest.fn(); + + editor.registerMutationListener(TableCellNode, tableCellMutations, { + skipInitialization: false, + }); + editor.registerMutationListener(TableRowNode, tableRowMutations, { + skipInitialization: false, + }); + // Create Table + + await editor.update(() => { + const root = $getRoot(); + const tableCell = $createTableCellNode(0); + const tableRow = $createTableRowNode(); + const table = $createTableNode(); + + tableRow.append(tableCell); + table.append(tableRow); + root.append(table); + + tableRowKey = tableRow.getKey(); + tableCellKey = tableCell.getKey(); + }); + // Add New Table Cell To Row + + await editor.update(() => { + const tableRow = $getNodeByKey(tableRowKey) as TableRowNode; + const tableCell = $createTableCellNode(0); + tableRow.append(tableCell); + }); + + // Update Table Cell + await editor.update(() => { + const tableCell = $getNodeByKey(tableCellKey) as TableCellNode; + tableCell.toggleHeaderStyle(1); + }); + + expect(tableCellMutations.mock.calls.length).toBe(3); + const tableCellMutation3 = tableCellMutations.mock.calls[2]; + + // Show table cell is updated when header value changes. + expect(tableCellMutation3[0].get(tableCellKey!)).toBe('updated'); + expect(tableRowMutations.mock.calls.length).toBe(2); + + const tableRowMutation2 = tableRowMutations.mock.calls[1]; + + // Show row is updated when a new child is added. + expect(tableRowMutation2[0].get(tableRowKey!)).toBe('updated'); + }); + + it('editable listener', () => { + init(); + + const editableFn = jest.fn(); + editor.registerEditableListener(editableFn); + + expect(editor.isEditable()).toBe(true); + + editor.setEditable(false); + + expect(editor.isEditable()).toBe(false); + + editor.setEditable(true); + + expect(editableFn.mock.calls).toEqual([[false], [true]]); + }); + + it('does not add new listeners while triggering existing', async () => { + const updateListener = jest.fn(); + const mutationListener = jest.fn(); + const nodeTransformListener = jest.fn(); + const textContentListener = jest.fn(); + const editableListener = jest.fn(); + const commandListener = jest.fn(); + const TEST_COMMAND = createCommand('TEST_COMMAND'); + + init(); + + editor.registerUpdateListener(() => { + updateListener(); + + editor.registerUpdateListener(() => { + updateListener(); + }); + }); + + editor.registerMutationListener( + TextNode, + (map) => { + mutationListener(); + editor.registerMutationListener( + TextNode, + () => { + mutationListener(); + }, + {skipInitialization: true}, + ); + }, + {skipInitialization: false}, + ); + + editor.registerNodeTransform(ParagraphNode, () => { + nodeTransformListener(); + editor.registerNodeTransform(ParagraphNode, () => { + nodeTransformListener(); + }); + }); + + editor.registerEditableListener(() => { + editableListener(); + editor.registerEditableListener(() => { + editableListener(); + }); + }); + + editor.registerTextContentListener(() => { + textContentListener(); + editor.registerTextContentListener(() => { + textContentListener(); + }); + }); + + editor.registerCommand( + TEST_COMMAND, + (): boolean => { + commandListener(); + editor.registerCommand( + TEST_COMMAND, + commandListener, + COMMAND_PRIORITY_LOW, + ); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + + await update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Hello world')), + ); + }); + + editor.dispatchCommand(TEST_COMMAND, false); + + editor.setEditable(false); + + expect(updateListener).toHaveBeenCalledTimes(1); + expect(editableListener).toHaveBeenCalledTimes(1); + expect(commandListener).toHaveBeenCalledTimes(1); + expect(textContentListener).toHaveBeenCalledTimes(1); + expect(nodeTransformListener).toHaveBeenCalledTimes(1); + expect(mutationListener).toHaveBeenCalledTimes(1); + }); + + it('calls mutation listener with initial state', async () => { + // TODO add tests for node replacement + const mutationListenerA = jest.fn(); + const mutationListenerB = jest.fn(); + const mutationListenerC = jest.fn(); + init(); + + editor.registerMutationListener(TextNode, mutationListenerA, { + skipInitialization: false, + }); + expect(mutationListenerA).toHaveBeenCalledTimes(0); + + await update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Hello world')), + ); + }); + + function asymmetricMatcher(asymmetricMatch: (x: T) => boolean) { + return {asymmetricMatch}; + } + + expect(mutationListenerA).toHaveBeenCalledTimes(1); + expect(mutationListenerA).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + updateTags: asymmetricMatcher( + (s: Set) => !s.has('registerMutationListener'), + ), + }), + ); + editor.registerMutationListener(TextNode, mutationListenerB, { + skipInitialization: false, + }); + editor.registerMutationListener(TextNode, mutationListenerC, { + skipInitialization: true, + }); + expect(mutationListenerA).toHaveBeenCalledTimes(1); + expect(mutationListenerB).toHaveBeenCalledTimes(1); + expect(mutationListenerB).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + updateTags: asymmetricMatcher((s: Set) => + s.has('registerMutationListener'), + ), + }), + ); + expect(mutationListenerC).toHaveBeenCalledTimes(0); + await update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Another update!')), + ); + }); + expect(mutationListenerA).toHaveBeenCalledTimes(2); + expect(mutationListenerB).toHaveBeenCalledTimes(2); + expect(mutationListenerC).toHaveBeenCalledTimes(1); + [mutationListenerA, mutationListenerB, mutationListenerC].forEach((fn) => { + expect(fn).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + updateTags: asymmetricMatcher( + (s: Set) => !s.has('registerMutationListener'), + ), + }), + ); + }); + }); + + it('can use discrete for synchronous updates', () => { + init(); + const onUpdate = jest.fn(); + editor.registerUpdateListener(onUpdate); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Sync update')), + ); + }, + { + discrete: true, + }, + ); + + const textContent = editor + .getEditorState() + .read(() => $getRoot().getTextContent()); + expect(textContent).toBe('Sync update'); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('can use discrete after a non-discrete update to flush the entire queue', () => { + const headless = createTestHeadlessEditor(); + const onUpdate = jest.fn(); + headless.registerUpdateListener(onUpdate); + headless.update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Async update')), + ); + }); + headless.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Sync update')), + ); + }, + { + discrete: true, + }, + ); + + const textContent = headless + .getEditorState() + .read(() => $getRoot().getTextContent()); + expect(textContent).toBe('Async update\n\nSync update'); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('can use discrete after a non-discrete setEditorState to flush the entire queue', () => { + init(); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Async update')), + ); + }, + { + discrete: true, + }, + ); + + const headless = createTestHeadlessEditor(editor.getEditorState()); + headless.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Sync update')), + ); + }, + { + discrete: true, + }, + ); + const textContent = headless + .getEditorState() + .read(() => $getRoot().getTextContent()); + expect(textContent).toBe('Async update\n\nSync update'); + }); + + it('can use discrete in a nested update to flush the entire queue', () => { + init(); + const onUpdate = jest.fn(); + editor.registerUpdateListener(onUpdate); + editor.update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Async update')), + ); + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Sync update')), + ); + }, + { + discrete: true, + }, + ); + }); + + const textContent = editor + .getEditorState() + .read(() => $getRoot().getTextContent()); + expect(textContent).toBe('Async update\n\nSync update'); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('does not include linebreak into inline elements', async () => { + init(); + + await editor.update(() => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('Hello'), + $createTestInlineElementNode(), + ), + ); + }); + + expect(container.firstElementChild?.innerHTML).toBe( + '

    Hello

    ', + ); + }); + + it('reconciles state without root element', () => { + editor = createTestEditor({}); + const state = editor.parseEditorState( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + ); + editor.setEditorState(state); + expect(editor._editorState).toBe(state); + expect(editor._pendingEditorState).toBe(null); + }); + + describe('node replacement', () => { + it('should work correctly', async () => { + const onError = jest.fn(); + + const newEditor = createTestEditor({ + nodes: [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + }, + ], + onError: onError, + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }); + + newEditor.setRootElement(container); + + await newEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('123'); + root.append(paragraph); + paragraph.append(text); + expect(text instanceof TestTextNode).toBe(true); + expect(text.getTextContent()).toBe('123'); + }); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('should fail if node keys are re-used', async () => { + const onError = jest.fn(); + + const newEditor = createTestEditor({ + nodes: [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => + new TestTextNode(node.getTextContent(), node.getKey()), + }, + ], + onError: onError, + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }); + + newEditor.setRootElement(container); + + await newEditor.update(() => { + // this will throw + $createTextNode('123'); + expect(false).toBe('unreachable'); + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/), + }), + ); + }); + + it('node transform to the nodes specified by "replace" should not be applied to the nodes specified by "with" when "withKlass" is not specified', async () => { + const onError = jest.fn(); + + const newEditor = createTestEditor({ + nodes: [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + }, + ], + onError: onError, + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }); + + newEditor.setRootElement(container); + + const mockTransform = jest.fn(); + const removeTransform = newEditor.registerNodeTransform( + TextNode, + mockTransform, + ); + + await newEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('123'); + root.append(paragraph); + paragraph.append(text); + expect(text instanceof TestTextNode).toBe(true); + expect(text.getTextContent()).toBe('123'); + }); + + await newEditor.getEditorState().read(() => { + expect(mockTransform).toHaveBeenCalledTimes(0); + }); + + expect(onError).not.toHaveBeenCalled(); + removeTransform(); + }); + + it('node transform to the nodes specified by "replace" should be applied also to the nodes specified by "with" when "withKlass" is specified', async () => { + const onError = jest.fn(); + + const newEditor = createTestEditor({ + nodes: [ + TestTextNode, + { + replace: TextNode, + with: (node: TextNode) => new TestTextNode(node.getTextContent()), + withKlass: TestTextNode, + }, + ], + onError: onError, + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }); + + newEditor.setRootElement(container); + + const mockTransform = jest.fn(); + const removeTransform = newEditor.registerNodeTransform( + TextNode, + mockTransform, + ); + + await newEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('123'); + root.append(paragraph); + paragraph.append(text); + expect(text instanceof TestTextNode).toBe(true); + expect(text.getTextContent()).toBe('123'); + }); + + await newEditor.getEditorState().read(() => { + expect(mockTransform).toHaveBeenCalledTimes(1); + }); + + expect(onError).not.toHaveBeenCalled(); + removeTransform(); + }); + }); + + it('recovers from reconciler failure and trigger proper prev editor state', async () => { + const updateListener = jest.fn(); + const textListener = jest.fn(); + const onError = jest.fn(); + const updateError = new Error('Failed updateDOM'); + + init(onError); + + editor.registerUpdateListener(updateListener); + editor.registerTextContentListener(textListener); + + await update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('Hello')), + ); + }); + + // Cause reconciler error in update dom, so that it attempts to fallback by + // reseting editor and rerendering whole content + jest.spyOn(ParagraphNode.prototype, 'updateDOM').mockImplementation(() => { + throw updateError; + }); + + const editorState = editor.getEditorState(); + + editor.registerUpdateListener(updateListener); + + await update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('world')), + ); + }); + + expect(onError).toBeCalledWith(updateError); + expect(textListener).toBeCalledWith('Hello\n\nworld'); + expect(updateListener.mock.lastCall[0].prevEditorState).toBe(editorState); + }); + + it('should call importDOM methods only once', async () => { + jest.spyOn(ParagraphNode, 'importDOM'); + + class CustomParagraphNode extends ParagraphNode { + static getType() { + return 'custom-paragraph'; + } + + static clone(node: CustomParagraphNode) { + return new CustomParagraphNode(node.__key); + } + + static importJSON() { + return new CustomParagraphNode(); + } + + exportJSON() { + return {...super.exportJSON(), type: 'custom-paragraph'}; + } + } + + createTestEditor({nodes: [CustomParagraphNode]}); + + expect(ParagraphNode.importDOM).toHaveBeenCalledTimes(1); + }); + + it('root element count is always positive', () => { + const newEditor1 = createTestEditor(); + const newEditor2 = createTestEditor(); + + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + + newEditor1.setRootElement(container1); + newEditor1.setRootElement(null); + + newEditor1.setRootElement(container1); + newEditor2.setRootElement(container2); + newEditor1.setRootElement(null); + newEditor2.setRootElement(null); + }); + + describe('html config', () => { + it('should override export output function', async () => { + const onError = jest.fn(); + + const newEditor = createTestEditor({ + html: { + export: new Map([ + [ + TextNode, + (_, target) => { + invariant($isTextNode(target)); + + return { + element: target.hasFormat('bold') + ? document.createElement('bor') + : document.createElement('foo'), + }; + }, + ], + ]), + }, + onError: onError, + }); + + newEditor.setRootElement(container); + + newEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode(); + root.append(paragraph); + paragraph.append(text); + + const selection = $createNodeSelection(); + selection.add(text.getKey()); + + const htmlFoo = $generateHtmlFromNodes(newEditor, selection); + expect(htmlFoo).toBe(''); + + text.toggleFormat('bold'); + + const htmlBold = $generateHtmlFromNodes(newEditor, selection); + expect(htmlBold).toBe(''); + }); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('should override import conversion function', async () => { + const onError = jest.fn(); + + const newEditor = createTestEditor({ + html: { + import: { + figure: () => ({ + conversion: () => ({node: $createTextNode('yolo')}), + priority: 4, + }), + }, + }, + onError: onError, + }); + + newEditor.setRootElement(container); + + newEditor.update(() => { + const html = '
    '; + + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + const node = $generateNodesFromDOM(newEditor, dom)[0]; + + expect(node).toEqual({ + __detail: 0, + __format: 0, + __key: node.getKey(), + __mode: 0, + __next: null, + __parent: null, + __prev: null, + __style: '', + __text: 'yolo', + __type: 'text', + }); + }); + + expect(onError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts new file mode 100644 index 000000000..09b49b738 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts @@ -0,0 +1,159 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getEditor, + $getRoot, + ParagraphNode, + TextNode, +} from 'lexical'; + +import {EditorState} from '../../LexicalEditorState'; +import {$createRootNode, RootNode} from '../../nodes/LexicalRootNode'; +import {initializeUnitTest} from '../utils'; + +describe('LexicalEditorState tests', () => { + initializeUnitTest((testEnv) => { + test('constructor', async () => { + const root = $createRootNode(); + const nodeMap = new Map([['root', root]]); + + const editorState = new EditorState(nodeMap); + expect(editorState._nodeMap).toBe(nodeMap); + expect(editorState._selection).toBe(null); + }); + + test('read()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraph = $createParagraphNode(); + const text = $createTextNode('foo'); + paragraph.append(text); + $getRoot().append(paragraph); + }); + + let root!: RootNode; + let paragraph!: ParagraphNode; + let text!: TextNode; + + editor.getEditorState().read(() => { + root = $getRoot(); + paragraph = root.getFirstChild()!; + text = paragraph.getFirstChild()!; + }); + + expect(root).toEqual({ + __cachedText: 'foo', + __dir: 'ltr', + __first: '1', + __format: 0, + __indent: 0, + __key: 'root', + __last: '1', + __next: null, + __parent: null, + __prev: null, + __size: 1, + __style: '', + __type: 'root', + }); + expect(paragraph).toEqual({ + __dir: 'ltr', + __first: '2', + __format: 0, + __indent: 0, + __key: '1', + __last: '2', + __next: null, + __parent: 'root', + __prev: null, + __size: 1, + __style: '', + __textFormat: 0, + __textStyle: '', + __type: 'paragraph', + }); + expect(text).toEqual({ + __detail: 0, + __format: 0, + __key: '2', + __mode: 0, + __next: null, + __parent: '1', + __prev: null, + __style: '', + __text: 'foo', + __type: 'text', + }); + expect(() => editor.getEditorState().read(() => $getEditor())).toThrow( + /Unable to find an active editor/, + ); + expect( + editor.getEditorState().read(() => $getEditor(), {editor: editor}), + ).toBe(editor); + }); + + test('toJSON()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraph = $createParagraphNode(); + const text = $createTextNode('Hello world'); + text.select(6, 11); + paragraph.append(text); + $getRoot().append(paragraph); + }); + + expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + ); + }); + + test('ensure garbage collection works as expected', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraph = $createParagraphNode(); + const text = $createTextNode('foo'); + paragraph.append(text); + $getRoot().append(paragraph); + }); + // Remove the first node, which should cause a GC for everything + + await editor.update(() => { + $getRoot().getFirstChild()!.remove(); + }); + + expect(editor.getEditorState()._nodeMap).toEqual( + new Map([ + [ + 'root', + { + __cachedText: '', + __dir: null, + __first: null, + __format: 0, + __indent: 0, + __key: 'root', + __last: null, + __next: null, + __parent: null, + __prev: null, + __size: 0, + __style: '', + __type: 'root', + }, + ], + ]), + ); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx new file mode 100644 index 000000000..a2968c259 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx @@ -0,0 +1,212 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {ListItemNode, ListNode} from '@lexical/list'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {ListPlugin} from '@lexical/react/LexicalListPlugin'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import { + INDENT_CONTENT_COMMAND, + LexicalEditor, + OUTDENT_CONTENT_COMMAND, +} from 'lexical'; +import { + expectHtmlToBeEqual, + html, + TestComposer, +} from 'lexical/src/__tests__/utils'; +import {createRoot, Root} from 'react-dom/client'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +import { + INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, +} from '../../../../lexical-list/src/index'; + +describe('@lexical/list tests', () => { + let container: HTMLDivElement; + let reactRoot: Root; + + beforeEach(() => { + container = document.createElement('div'); + reactRoot = createRoot(container); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + // @ts-ignore + container = null; + + jest.restoreAllMocks(); + }); + + // Shared instance across tests + let editor: LexicalEditor; + + function Test(): JSX.Element { + function TestPlugin() { + // Plugin used just to get our hands on the Editor object + [editor] = useLexicalComposerContext(); + return null; + } + + return ( + + } + placeholder={ +
    Enter some text...
    + } + ErrorBoundary={LexicalErrorBoundary} + /> + + +
    + ); + } + + test('Toggle an empty list on/off', async () => { + ReactTestUtils.act(() => { + reactRoot.render(); + }); + + await ReactTestUtils.act(async () => { + await editor.update(() => { + editor.focus(); + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }); + }); + + expectHtmlToBeEqual( + container.innerHTML, + html` +
    +
      +
    • +
      +
    • +
    +
    + `, + ); + + await ReactTestUtils.act(async () => { + await editor.update(() => { + editor.focus(); + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + }); + }); + + expectHtmlToBeEqual( + container.innerHTML, + html` +
    +

    +
    +

    +
    +
    Enter some text...
    + `, + ); + }); + + test('Can create a list and indent/outdent it', async () => { + ReactTestUtils.act(() => { + reactRoot.render(); + }); + + await ReactTestUtils.act(async () => { + await editor.update(() => { + editor.focus(); + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }); + }); + + expectHtmlToBeEqual( + container.innerHTML, + html` +
    +
      +
    • +
      +
    • +
    +
    + `, + ); + + await ReactTestUtils.act(async () => { + await editor.update(() => { + editor.focus(); + editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); + }); + }); + + expectHtmlToBeEqual( + container.innerHTML, + html` +
    +
      +
    • +
        +

      • +
      +
    • +
    +
    + `, + ); + + await ReactTestUtils.act(async () => { + await editor.update(() => { + editor.focus(); + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + }); + }); + + expectHtmlToBeEqual( + container.innerHTML, + html` +
    +
      +
    • +
      +
    • +
    +
    + `, + ); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts new file mode 100644 index 000000000..7373f898d --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts @@ -0,0 +1,1517 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createRangeSelection, + $getRoot, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isRangeSelection, + $setSelection, + createEditor, + DecoratorNode, + ElementNode, + LexicalEditor, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedTextNode, + TextNode, +} from 'lexical'; + +import {LexicalNode} from '../../LexicalNode'; +import {$createParagraphNode} from '../../nodes/LexicalParagraphNode'; +import {$createTextNode} from '../../nodes/LexicalTextNode'; +import { + $createTestInlineElementNode, + initializeUnitTest, + TestElementNode, + TestInlineElementNode, +} from '../utils'; + +class TestNode extends LexicalNode { + static getType(): string { + return 'test'; + } + + static clone(node: TestNode) { + return new TestNode(node.__key); + } + + createDOM() { + return document.createElement('div'); + } + + static importJSON() { + return new TestNode(); + } + + exportJSON() { + return {type: 'test', version: 1}; + } +} + +class InlineDecoratorNode extends DecoratorNode { + static getType(): string { + return 'inline-decorator'; + } + + static clone(): InlineDecoratorNode { + return new InlineDecoratorNode(); + } + + static importJSON() { + return new InlineDecoratorNode(); + } + + exportJSON() { + return {type: 'inline-decorator', version: 1}; + } + + createDOM(): HTMLElement { + return document.createElement('span'); + } + + isInline(): true { + return true; + } + + isParentRequired(): true { + return true; + } + + decorate() { + return 'inline-decorator'; + } +} + +// This is a hack to bypass the node type validation on LexicalNode. We never want to create +// an LexicalNode directly but we're testing the base functionality in this module. +LexicalNode.getType = function () { + return 'node'; +}; + +describe('LexicalNode tests', () => { + initializeUnitTest( + (testEnv) => { + let paragraphNode: ParagraphNode; + let textNode: TextNode; + + beforeEach(async () => { + const {editor} = testEnv; + + await editor.update(() => { + const rootNode = $getRoot(); + paragraphNode = new ParagraphNode(); + textNode = new TextNode('foo'); + paragraphNode.append(textNode); + rootNode.append(paragraphNode); + }); + }); + + test('LexicalNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode('__custom_key__'); + expect(node.__type).toBe('node'); + expect(node.__key).toBe('__custom_key__'); + expect(node.__parent).toBe(null); + }); + + await editor.getEditorState().read(() => { + expect(() => new LexicalNode()).toThrow(); + expect(() => new LexicalNode('__custom_key__')).toThrow(); + }); + }); + + test('LexicalNode.constructor: type change detected', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const validNode = new TextNode(textNode.__text, textNode.__key); + expect(textNode.getLatest()).toBe(textNode); + expect(validNode.getLatest()).toBe(textNode); + expect(() => new TestNode(textNode.__key)).toThrowError( + /TestNode.*re-use key.*TextNode/, + ); + }); + }); + + test('LexicalNode.clone()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode('__custom_key__'); + + expect(() => LexicalNode.clone(node)).toThrow(); + }); + }); + test('LexicalNode.afterCloneFrom()', () => { + class VersionedTextNode extends TextNode { + // ['constructor']!: KlassConstructor; + __version = 0; + static getType(): 'vtext' { + return 'vtext'; + } + static clone(node: VersionedTextNode): VersionedTextNode { + return new VersionedTextNode(node.__text, node.__key); + } + static importJSON(node: SerializedTextNode): VersionedTextNode { + throw new Error('Not implemented'); + } + exportJSON(): SerializedTextNode { + throw new Error('Not implemented'); + } + afterCloneFrom(node: this): void { + super.afterCloneFrom(node); + this.__version = node.__version + 1; + } + } + const editor = createEditor({ + nodes: [VersionedTextNode], + onError(err) { + throw err; + }, + }); + let versionedTextNode: VersionedTextNode; + + editor.update( + () => { + versionedTextNode = new VersionedTextNode('test'); + $getRoot().append($createParagraphNode().append(versionedTextNode)); + expect(versionedTextNode.__version).toEqual(0); + }, + {discrete: true}, + ); + editor.update( + () => { + expect(versionedTextNode.getLatest().__version).toEqual(0); + expect( + versionedTextNode.setTextContent('update').setMode('token') + .__version, + ).toEqual(1); + }, + {discrete: true}, + ); + editor.update( + () => { + let latest = versionedTextNode.getLatest(); + expect(versionedTextNode.__version).toEqual(0); + expect(versionedTextNode.__mode).toEqual(0); + expect(versionedTextNode.getMode()).toEqual('token'); + expect(latest.__version).toEqual(1); + expect(latest.__mode).toEqual(1); + latest = latest.setTextContent('another update'); + expect(latest.__version).toEqual(2); + expect(latest.getWritable().__version).toEqual(2); + expect( + versionedTextNode.getLatest().getWritable().__version, + ).toEqual(2); + expect(versionedTextNode.getLatest().__version).toEqual(2); + expect(versionedTextNode.__mode).toEqual(0); + expect(versionedTextNode.getLatest().__mode).toEqual(1); + expect(versionedTextNode.getMode()).toEqual('token'); + }, + {discrete: true}, + ); + }); + + test('LexicalNode.getType()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode('__custom_key__'); + expect(node.getType()).toEqual(node.__type); + }); + }); + + test('LexicalNode.isAttached()', async () => { + const {editor} = testEnv; + let node: LexicalNode; + + await editor.update(() => { + node = new LexicalNode('__custom_key__'); + }); + + await editor.getEditorState().read(() => { + expect(node.isAttached()).toBe(false); + expect(textNode.isAttached()).toBe(true); + expect(paragraphNode.isAttached()).toBe(true); + }); + + expect(() => textNode.isAttached()).toThrow(); + }); + + test('LexicalNode.isSelected()', async () => { + const {editor} = testEnv; + let node: LexicalNode; + + await editor.update(() => { + node = new LexicalNode('__custom_key__'); + }); + + await editor.getEditorState().read(() => { + expect(node.isSelected()).toBe(false); + expect(textNode.isSelected()).toBe(false); + expect(paragraphNode.isSelected()).toBe(false); + }); + + await editor.update(() => { + textNode.select(0, 0); + }); + + await editor.getEditorState().read(() => { + expect(textNode.isSelected()).toBe(true); + }); + + expect(() => textNode.isSelected()).toThrow(); + }); + + test('LexicalNode.isSelected(): selected text node', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + expect(paragraphNode.isSelected()).toBe(false); + expect(textNode.isSelected()).toBe(false); + }); + + await editor.update(() => { + textNode.select(0, 0); + }); + + await editor.getEditorState().read(() => { + expect(textNode.isSelected()).toBe(true); + expect(paragraphNode.isSelected()).toBe(false); + }); + }); + + test('LexicalNode.isSelected(): selected block node range', async () => { + const {editor} = testEnv; + let newParagraphNode: ParagraphNode; + let newTextNode: TextNode; + + await editor.update(() => { + expect(paragraphNode.isSelected()).toBe(false); + expect(textNode.isSelected()).toBe(false); + newParagraphNode = new ParagraphNode(); + newTextNode = new TextNode('bar'); + newParagraphNode.append(newTextNode); + paragraphNode.insertAfter(newParagraphNode); + expect(newParagraphNode.isSelected()).toBe(false); + expect(newTextNode.isSelected()).toBe(false); + }); + + await editor.update(() => { + textNode.select(0, 0); + const selection = $getSelection(); + + expect(selection).not.toBe(null); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.anchor.type = 'text'; + selection.anchor.offset = 1; + selection.anchor.key = textNode.getKey(); + selection.focus.type = 'text'; + selection.focus.offset = 1; + selection.focus.key = newTextNode.getKey(); + }); + + await Promise.resolve().then(); + + await editor.update(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor.key).toBe(textNode.getKey()); + expect(selection.focus.key).toBe(newTextNode.getKey()); + expect(paragraphNode.isSelected()).toBe(true); + expect(textNode.isSelected()).toBe(true); + expect(newParagraphNode.isSelected()).toBe(true); + expect(newTextNode.isSelected()).toBe(true); + }); + }); + + test('LexicalNode.isSelected(): with custom range selection', async () => { + const {editor} = testEnv; + let newParagraphNode: ParagraphNode; + let newTextNode: TextNode; + + await editor.update(() => { + expect(paragraphNode.isSelected()).toBe(false); + expect(textNode.isSelected()).toBe(false); + newParagraphNode = new ParagraphNode(); + newTextNode = new TextNode('bar'); + newParagraphNode.append(newTextNode); + paragraphNode.insertAfter(newParagraphNode); + expect(newParagraphNode.isSelected()).toBe(false); + expect(newTextNode.isSelected()).toBe(false); + }); + + await editor.update(() => { + const rangeSelection = $createRangeSelection(); + + rangeSelection.anchor.type = 'text'; + rangeSelection.anchor.offset = 1; + rangeSelection.anchor.key = textNode.getKey(); + rangeSelection.focus.type = 'text'; + rangeSelection.focus.offset = 1; + rangeSelection.focus.key = newTextNode.getKey(); + + expect(paragraphNode.isSelected(rangeSelection)).toBe(true); + expect(textNode.isSelected(rangeSelection)).toBe(true); + expect(newParagraphNode.isSelected(rangeSelection)).toBe(true); + expect(newTextNode.isSelected(rangeSelection)).toBe(true); + }); + + await Promise.resolve().then(); + }); + + describe('LexicalNode.isSelected(): with inline decorator node', () => { + let editor: LexicalEditor; + let paragraphNode1: ParagraphNode; + let paragraphNode2: ParagraphNode; + let paragraphNode3: ParagraphNode; + let inlineDecoratorNode: InlineDecoratorNode; + let names: Record; + beforeEach(() => { + editor = testEnv.editor; + editor.update(() => { + inlineDecoratorNode = new InlineDecoratorNode(); + paragraphNode1 = $createParagraphNode(); + paragraphNode2 = $createParagraphNode().append(inlineDecoratorNode); + paragraphNode3 = $createParagraphNode(); + names = { + [inlineDecoratorNode.getKey()]: 'd', + [paragraphNode1.getKey()]: 'p1', + [paragraphNode2.getKey()]: 'p2', + [paragraphNode3.getKey()]: 'p3', + }; + $getRoot() + .clear() + .append(paragraphNode1, paragraphNode2, paragraphNode3); + }); + }); + const cases: { + label: string; + isSelected: boolean; + update: () => void; + }[] = [ + { + isSelected: true, + label: 'whole editor', + update() { + $getRoot().select(0); + }, + }, + { + isSelected: true, + label: 'containing paragraph', + update() { + paragraphNode2.select(0); + }, + }, + { + isSelected: true, + label: 'before and containing', + update() { + paragraphNode2 + .select(0) + .anchor.set(paragraphNode1.getKey(), 0, 'element'); + }, + }, + { + isSelected: true, + label: 'containing and after', + update() { + paragraphNode2 + .select(0) + .focus.set(paragraphNode3.getKey(), 0, 'element'); + }, + }, + { + isSelected: true, + label: 'before and after', + update() { + paragraphNode1 + .select(0) + .focus.set(paragraphNode3.getKey(), 0, 'element'); + }, + }, + { + isSelected: false, + label: 'collapsed before', + update() { + paragraphNode2.select(0, 0); + }, + }, + { + isSelected: false, + label: 'in another element', + update() { + paragraphNode1.select(0); + }, + }, + { + isSelected: false, + label: 'before', + update() { + paragraphNode1 + .select(0) + .focus.set(paragraphNode2.getKey(), 0, 'element'); + }, + }, + { + isSelected: false, + label: 'collapsed after', + update() { + paragraphNode2.selectEnd(); + }, + }, + { + isSelected: false, + label: 'after', + update() { + paragraphNode3 + .select(0) + .anchor.set( + paragraphNode2.getKey(), + paragraphNode2.getChildrenSize(), + 'element', + ); + }, + }, + ]; + for (const {label, isSelected, update} of cases) { + test(`${isSelected ? 'is' : "isn't"} selected ${label}`, () => { + editor.update(update); + const $verify = () => { + const selection = $getSelection() as RangeSelection; + expect($isRangeSelection(selection)).toBe(true); + const dbg = [selection.anchor, selection.focus] + .map( + (point) => + `(${names[point.key] || point.key}:${point.offset})`, + ) + .join(' '); + const nodes = `[${selection + .getNodes() + .map((k) => names[k.__key] || k.__key) + .join(',')}]`; + expect([dbg, nodes, inlineDecoratorNode.isSelected()]).toEqual([ + dbg, + nodes, + isSelected, + ]); + }; + editor.read($verify); + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const backwards = $createRangeSelection(); + backwards.anchor.set( + selection.focus.key, + selection.focus.offset, + selection.focus.type, + ); + backwards.focus.set( + selection.anchor.key, + selection.anchor.offset, + selection.anchor.type, + ); + $setSelection(backwards); + } + expect($isRangeSelection(selection)).toBe(true); + }); + editor.read($verify); + }); + } + }); + + test('LexicalNode.getKey()', async () => { + expect(textNode.getKey()).toEqual(textNode.__key); + }); + + test('LexicalNode.getParent()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + expect(node.getParent()).toBe(null); + }); + + await editor.getEditorState().read(() => { + const rootNode = $getRoot(); + expect(textNode.getParent()).toBe(paragraphNode); + expect(paragraphNode.getParent()).toBe(rootNode); + }); + expect(() => textNode.getParent()).toThrow(); + }); + + test('LexicalNode.getParentOrThrow()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + expect(() => node.getParentOrThrow()).toThrow(); + }); + + await editor.getEditorState().read(() => { + const rootNode = $getRoot(); + expect(textNode.getParent()).toBe(paragraphNode); + expect(paragraphNode.getParent()).toBe(rootNode); + }); + expect(() => textNode.getParentOrThrow()).toThrow(); + }); + + test('LexicalNode.getTopLevelElement()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + expect(node.getTopLevelElement()).toBe(null); + }); + + await editor.getEditorState().read(() => { + expect(textNode.getTopLevelElement()).toBe(paragraphNode); + expect(paragraphNode.getTopLevelElement()).toBe(paragraphNode); + }); + expect(() => textNode.getTopLevelElement()).toThrow(); + await editor.update(() => { + const node = new InlineDecoratorNode(); + expect(node.getTopLevelElement()).toBe(null); + $getRoot().append(node); + expect(node.getTopLevelElement()).toBe(node); + }); + editor.getEditorState().read(() => { + const elementNodes: ElementNode[] = []; + const decoratorNodes: DecoratorNode[] = []; + for (const child of $getRoot().getChildren()) { + expect(child.getTopLevelElement()).toBe(child); + if ($isElementNode(child)) { + elementNodes.push(child); + } else if ($isDecoratorNode(child)) { + decoratorNodes.push(child); + } else { + throw new Error( + 'Expecting all children to be ElementNode or DecoratorNode', + ); + } + } + expect(decoratorNodes).toHaveLength(1); + expect(elementNodes).toHaveLength(1); + }); + }); + + test('LexicalNode.getTopLevelElementOrThrow()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + expect(() => node.getTopLevelElementOrThrow()).toThrow(); + }); + + await editor.getEditorState().read(() => { + expect(textNode.getTopLevelElementOrThrow()).toBe(paragraphNode); + expect(paragraphNode.getTopLevelElementOrThrow()).toBe(paragraphNode); + }); + expect(() => textNode.getTopLevelElementOrThrow()).toThrow(); + await editor.update(() => { + const node = new InlineDecoratorNode(); + expect(() => node.getTopLevelElementOrThrow()).toThrow(); + $getRoot().append(node); + expect(node.getTopLevelElementOrThrow()).toBe(node); + }); + }); + + test('LexicalNode.getParents()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + expect(node.getParents()).toEqual([]); + }); + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.getEditorState().read(() => { + const rootNode = $getRoot(); + expect(textNode.getParents()).toEqual([paragraphNode, rootNode]); + expect(paragraphNode.getParents()).toEqual([rootNode]); + }); + expect(() => textNode.getParents()).toThrow(); + }); + + test('LexicalNode.getPreviousSibling()', async () => { + const {editor} = testEnv; + let barTextNode: TextNode; + + await editor.update(() => { + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + paragraphNode.append(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobar

    ', + ); + + await editor.getEditorState().read(() => { + expect(barTextNode.getPreviousSibling()).toEqual({ + ...textNode, + __next: '3', + }); + expect(textNode.getPreviousSibling()).toEqual(null); + }); + expect(() => textNode.getPreviousSibling()).toThrow(); + }); + + test('LexicalNode.getPreviousSiblings()', async () => { + const {editor} = testEnv; + let barTextNode: TextNode; + let bazTextNode: TextNode; + + await editor.update(() => { + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + bazTextNode = new TextNode('baz'); + bazTextNode.toggleUnmergeable(); + paragraphNode.append(barTextNode, bazTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobarbaz

    ', + ); + + await editor.getEditorState().read(() => { + expect(bazTextNode.getPreviousSiblings()).toEqual([ + { + ...textNode, + __next: '3', + }, + { + ...barTextNode, + __prev: '2', + }, + ]); + expect(barTextNode.getPreviousSiblings()).toEqual([ + { + ...textNode, + __next: '3', + }, + ]); + expect(textNode.getPreviousSiblings()).toEqual([]); + }); + expect(() => textNode.getPreviousSiblings()).toThrow(); + }); + + test('LexicalNode.getNextSibling()', async () => { + const {editor} = testEnv; + let barTextNode: TextNode; + + await editor.update(() => { + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + paragraphNode.append(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobar

    ', + ); + + await editor.getEditorState().read(() => { + expect(barTextNode.getNextSibling()).toEqual(null); + expect(textNode.getNextSibling()).toEqual(barTextNode); + }); + expect(() => textNode.getNextSibling()).toThrow(); + }); + + test('LexicalNode.getNextSiblings()', async () => { + const {editor} = testEnv; + let barTextNode: TextNode; + let bazTextNode: TextNode; + + await editor.update(() => { + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + bazTextNode = new TextNode('baz'); + bazTextNode.toggleUnmergeable(); + paragraphNode.append(barTextNode, bazTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobarbaz

    ', + ); + + await editor.getEditorState().read(() => { + expect(bazTextNode.getNextSiblings()).toEqual([]); + expect(barTextNode.getNextSiblings()).toEqual([bazTextNode]); + expect(textNode.getNextSiblings()).toEqual([ + barTextNode, + bazTextNode, + ]); + }); + expect(() => textNode.getNextSiblings()).toThrow(); + }); + + test('LexicalNode.getCommonAncestor()', async () => { + const {editor} = testEnv; + let quxTextNode: TextNode; + let barParagraphNode: ParagraphNode; + let barTextNode: TextNode; + let bazParagraphNode: ParagraphNode; + let bazTextNode: TextNode; + + await editor.update(() => { + const rootNode = $getRoot(); + barParagraphNode = new ParagraphNode(); + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + bazParagraphNode = new ParagraphNode(); + bazTextNode = new TextNode('baz'); + bazTextNode.toggleUnmergeable(); + quxTextNode = new TextNode('qux'); + quxTextNode.toggleUnmergeable(); + paragraphNode.append(quxTextNode); + expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(null); + barParagraphNode.append(barTextNode); + bazParagraphNode.append(bazTextNode); + expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(null); + rootNode.append(barParagraphNode, bazParagraphNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    fooqux

    bar

    baz

    ', + ); + + await editor.getEditorState().read(() => { + const rootNode = $getRoot(); + expect(textNode.getCommonAncestor(rootNode)).toBe(rootNode); + expect(quxTextNode.getCommonAncestor(rootNode)).toBe(rootNode); + expect(barTextNode.getCommonAncestor(rootNode)).toBe(rootNode); + expect(bazTextNode.getCommonAncestor(rootNode)).toBe(rootNode); + expect(textNode.getCommonAncestor(quxTextNode)).toBe( + paragraphNode.getLatest(), + ); + expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(rootNode); + expect(barTextNode.getCommonAncestor(bazTextNode)).toBe(rootNode); + }); + + expect(() => textNode.getCommonAncestor(barTextNode)).toThrow(); + }); + + test('LexicalNode.isBefore()', async () => { + const {editor} = testEnv; + let barTextNode: TextNode; + let bazTextNode: TextNode; + + await editor.update(() => { + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + bazTextNode = new TextNode('baz'); + bazTextNode.toggleUnmergeable(); + paragraphNode.append(barTextNode, bazTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobarbaz

    ', + ); + + await editor.getEditorState().read(() => { + expect(textNode.isBefore(textNode)).toBe(false); + expect(textNode.isBefore(barTextNode)).toBe(true); + expect(textNode.isBefore(bazTextNode)).toBe(true); + expect(barTextNode.isBefore(bazTextNode)).toBe(true); + expect(bazTextNode.isBefore(barTextNode)).toBe(false); + expect(bazTextNode.isBefore(textNode)).toBe(false); + }); + expect(() => textNode.isBefore(barTextNode)).toThrow(); + }); + + test('LexicalNode.isParentOf()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + const rootNode = $getRoot(); + expect(rootNode.isParentOf(textNode)).toBe(true); + expect(rootNode.isParentOf(paragraphNode)).toBe(true); + expect(paragraphNode.isParentOf(textNode)).toBe(true); + expect(paragraphNode.isParentOf(rootNode)).toBe(false); + expect(textNode.isParentOf(paragraphNode)).toBe(false); + expect(textNode.isParentOf(rootNode)).toBe(false); + }); + expect(() => paragraphNode.isParentOf(textNode)).toThrow(); + }); + + test('LexicalNode.getNodesBetween()', async () => { + const {editor} = testEnv; + let barTextNode: TextNode; + let bazTextNode: TextNode; + let newParagraphNode: ParagraphNode; + let quxTextNode: TextNode; + + await editor.update(() => { + const rootNode = $getRoot(); + barTextNode = new TextNode('bar'); + barTextNode.toggleUnmergeable(); + bazTextNode = new TextNode('baz'); + bazTextNode.toggleUnmergeable(); + newParagraphNode = new ParagraphNode(); + quxTextNode = new TextNode('qux'); + quxTextNode.toggleUnmergeable(); + rootNode.append(newParagraphNode); + paragraphNode.append(barTextNode, bazTextNode); + newParagraphNode.append(quxTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobarbaz

    qux

    ', + ); + + await editor.getEditorState().read(() => { + expect(textNode.getNodesBetween(textNode)).toEqual([textNode]); + expect(textNode.getNodesBetween(barTextNode)).toEqual([ + textNode, + barTextNode, + ]); + expect(textNode.getNodesBetween(bazTextNode)).toEqual([ + textNode, + barTextNode, + bazTextNode, + ]); + expect(textNode.getNodesBetween(quxTextNode)).toEqual([ + textNode, + barTextNode, + bazTextNode, + paragraphNode.getLatest(), + newParagraphNode, + quxTextNode, + ]); + }); + expect(() => textNode.getNodesBetween(bazTextNode)).toThrow(); + }); + + test('LexicalNode.isToken()', async () => { + const {editor} = testEnv; + let tokenTextNode: TextNode; + + await editor.update(() => { + tokenTextNode = new TextNode('token').setMode('token'); + paragraphNode.append(tokenTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    footoken

    ', + ); + + await editor.getEditorState().read(() => { + expect(textNode.isToken()).toBe(false); + expect(tokenTextNode.isToken()).toBe(true); + }); + expect(() => textNode.isToken()).toThrow(); + }); + + test('LexicalNode.isSegmented()', async () => { + const {editor} = testEnv; + let segmentedTextNode: TextNode; + + await editor.update(() => { + segmentedTextNode = new TextNode('segmented').setMode('segmented'); + paragraphNode.append(segmentedTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foosegmented

    ', + ); + + await editor.getEditorState().read(() => { + expect(textNode.isSegmented()).toBe(false); + expect(segmentedTextNode.isSegmented()).toBe(true); + }); + expect(() => textNode.isSegmented()).toThrow(); + }); + + test('LexicalNode.isDirectionless()', async () => { + const {editor} = testEnv; + let directionlessTextNode: TextNode; + + await editor.update(() => { + directionlessTextNode = new TextNode( + 'directionless', + ).toggleDirectionless(); + directionlessTextNode.toggleUnmergeable(); + paragraphNode.append(directionlessTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foodirectionless

    ', + ); + + await editor.getEditorState().read(() => { + expect(textNode.isDirectionless()).toBe(false); + expect(directionlessTextNode.isDirectionless()).toBe(true); + }); + expect(() => directionlessTextNode.isDirectionless()).toThrow(); + }); + + test('LexicalNode.getLatest()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + expect(textNode.getLatest()).toBe(textNode); + }); + expect(() => textNode.getLatest()).toThrow(); + }); + + test('LexicalNode.getLatest(): garbage collected node', async () => { + const {editor} = testEnv; + let node: LexicalNode; + let text: TextNode; + let block: TestElementNode; + + await editor.update(() => { + node = new LexicalNode(); + node.getLatest(); + text = new TextNode(''); + text.getLatest(); + block = new TestElementNode(); + block.getLatest(); + }); + + await editor.update(() => { + expect(() => node.getLatest()).toThrow(); + expect(() => text.getLatest()).toThrow(); + expect(() => block.getLatest()).toThrow(); + }); + }); + + test('LexicalNode.getTextContent()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + expect(node.getTextContent()).toBe(''); + }); + + await editor.getEditorState().read(() => { + expect(textNode.getTextContent()).toBe('foo'); + }); + expect(() => textNode.getTextContent()).toThrow(); + }); + + test('LexicalNode.getTextContentSize()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + expect(textNode.getTextContentSize()).toBe('foo'.length); + }); + expect(() => textNode.getTextContentSize()).toThrow(); + }); + + test('LexicalNode.createDOM()', async () => { + const {editor} = testEnv; + + editor.update(() => { + const node = new LexicalNode(); + expect(() => + node.createDOM( + { + namespace: '', + theme: {}, + }, + editor, + ), + ).toThrow(); + }); + }); + + test('LexicalNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = new LexicalNode(); + // @ts-expect-error + expect(() => node.updateDOM()).toThrow(); + }); + }); + + test('LexicalNode.remove()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + expect(() => textNode.remove()).toThrow(); + }); + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const node = new LexicalNode(); + node.remove(); + expect(node.getParent()).toBe(null); + textNode.remove(); + expect(textNode.getParent()).toBe(null); + expect(editor._dirtyLeaves.has(textNode.getKey())); + }); + + expect(testEnv.outerHTML).toBe( + '


    ', + ); + expect(() => textNode.remove()).toThrow(); + }); + + test('LexicalNode.replace()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + // @ts-expect-error + expect(() => textNode.replace()).toThrow(); + }); + expect(() => textNode.remove()).toThrow(); + }); + + test('LexicalNode.replace(): from another parent', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + let barTextNode: TextNode; + + await editor.update(() => { + const rootNode = $getRoot(); + const barParagraphNode = new ParagraphNode(); + barTextNode = new TextNode('bar'); + barParagraphNode.append(barTextNode); + rootNode.append(barParagraphNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foo

    bar

    ', + ); + + await editor.update(() => { + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    bar


    ', + ); + }); + + test('LexicalNode.replace(): text', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    bar

    ', + ); + }); + + test('LexicalNode.replace(): token', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    bar

    ', + ); + }); + + test('LexicalNode.replace(): segmented', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('segmented'); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    bar

    ', + ); + }); + + test('LexicalNode.replace(): directionless', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode(`bar`).toggleDirectionless(); + textNode.replace(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    bar

    ', + ); + // TODO: add text direction validations + }); + + test('LexicalNode.replace() within canBeEmpty: false', async () => { + const {editor} = testEnv; + + jest + .spyOn(TestInlineElementNode.prototype, 'canBeEmpty') + .mockReturnValue(false); + + await editor.update(() => { + textNode = $createTextNode('Hello'); + + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTestInlineElementNode().append(textNode), + ), + ); + + textNode.replace($createTextNode('world')); + }); + + expect(testEnv.outerHTML).toBe( + '', + ); + }); + + test('LexicalNode.insertAfter()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + // @ts-expect-error + expect(() => textNode.insertAfter()).toThrow(); + }); + // @ts-expect-error + expect(() => textNode.insertAfter()).toThrow(); + }); + + test('LexicalNode.insertAfter(): text', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobar

    ', + ); + }); + + test('LexicalNode.insertAfter(): token', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobar

    ', + ); + }); + + test('LexicalNode.insertAfter(): segmented', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobar

    ', + ); + }); + + test('LexicalNode.insertAfter(): directionless', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode(`bar`).toggleDirectionless(); + textNode.insertAfter(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foobar

    ', + ); + // TODO: add text direction validations + }); + + test('LexicalNode.insertAfter() move blocks around', async () => { + const {editor} = testEnv; + let block1: ParagraphNode, + block2: ParagraphNode, + block3: ParagraphNode, + text1: TextNode, + text2: TextNode, + text3: TextNode; + + await editor.update(() => { + const root = $getRoot(); + root.clear(); + block1 = new ParagraphNode(); + block2 = new ParagraphNode(); + block3 = new ParagraphNode(); + text1 = new TextNode('A'); + text2 = new TextNode('B'); + text3 = new TextNode('C'); + block1.append(text1); + block2.append(text2); + block3.append(text3); + root.append(block1, block2, block3); + }); + + expect(testEnv.outerHTML).toBe( + '

    A

    B

    C

    ', + ); + + await editor.update(() => { + text1.insertAfter(block2); + }); + + expect(testEnv.outerHTML).toBe( + '

    A

    B

    C

    ', + ); + }); + + test('LexicalNode.insertAfter() move blocks around #2', async () => { + const {editor} = testEnv; + let block1: ParagraphNode, + block2: ParagraphNode, + block3: ParagraphNode, + text1: TextNode, + text2: TextNode, + text3: TextNode; + + await editor.update(() => { + const root = $getRoot(); + root.clear(); + block1 = new ParagraphNode(); + block2 = new ParagraphNode(); + block3 = new ParagraphNode(); + text1 = new TextNode('A'); + text1.toggleUnmergeable(); + text2 = new TextNode('B'); + text2.toggleUnmergeable(); + text3 = new TextNode('C'); + text3.toggleUnmergeable(); + block1.append(text1); + block2.append(text2); + block3.append(text3); + root.append(block1); + root.append(block2); + root.append(block3); + }); + + expect(testEnv.outerHTML).toBe( + '

    A

    B

    C

    ', + ); + + await editor.update(() => { + text3.insertAfter(text1); + text3.insertAfter(text2); + }); + + expect(testEnv.outerHTML).toBe( + '



    CBA

    ', + ); + }); + + test('LexicalNode.insertBefore()', async () => { + const {editor} = testEnv; + + await editor.getEditorState().read(() => { + // @ts-expect-error + expect(() => textNode.insertBefore()).toThrow(); + }); + // @ts-expect-error + expect(() => textNode.insertBefore()).toThrow(); + }); + + test('LexicalNode.insertBefore(): from another parent', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + let barTextNode; + + await editor.update(() => { + const rootNode = $getRoot(); + const barParagraphNode = new ParagraphNode(); + barTextNode = new TextNode('bar'); + barParagraphNode.append(barTextNode); + rootNode.append(barParagraphNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    foo

    bar

    ', + ); + }); + + test('LexicalNode.insertBefore(): text', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    barfoo

    ', + ); + }); + + test('LexicalNode.insertBefore(): token', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('token'); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    barfoo

    ', + ); + }); + + test('LexicalNode.insertBefore(): segmented', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode('bar').setMode('segmented'); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    barfoo

    ', + ); + }); + + test('LexicalNode.insertBefore(): directionless', async () => { + const {editor} = testEnv; + + expect(testEnv.outerHTML).toBe( + '

    foo

    ', + ); + + await editor.update(() => { + const barTextNode = new TextNode(`bar`).toggleDirectionless(); + textNode.insertBefore(barTextNode); + }); + + expect(testEnv.outerHTML).toBe( + '

    barfoo

    ', + ); + }); + + test('LexicalNode.selectNext()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const barTextNode = new TextNode('bar'); + textNode.insertAfter(barTextNode); + + expect(barTextNode.isSelected()).not.toBe(true); + + textNode.selectNext(); + + expect(barTextNode.isSelected()).toBe(true); + // TODO: additional validation of anchorOffset and focusOffset + }); + }); + + test('LexicalNode.selectNext(): no next sibling', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const selection = textNode.selectNext(); + expect(selection.anchor.getNode()).toBe(paragraphNode); + expect(selection.anchor.offset).toBe(1); + }); + }); + + test('LexicalNode.selectNext(): non-text node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const barNode = new TestNode(); + textNode.insertAfter(barNode); + const selection = textNode.selectNext(); + + expect(selection.anchor.getNode()).toBe(textNode.getParent()); + expect(selection.anchor.offset).toBe(1); + }); + }); + }, + { + namespace: '', + nodes: [LexicalNode, TestNode, InlineDecoratorNode], + theme: {}, + }, + ); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx new file mode 100644 index 000000000..ecfbe6bf7 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx @@ -0,0 +1,176 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getRoot, + RangeSelection, +} from 'lexical'; + +import {$normalizeSelection} from '../../LexicalNormalization'; +import { + $createTestDecoratorNode, + $createTestElementNode, + initializeUnitTest, +} from '../utils'; + +describe('LexicalNormalization tests', () => { + initializeUnitTest((testEnv) => { + describe('$normalizeSelection', () => { + for (const reversed of [false, true]) { + const getAnchor = (x: RangeSelection) => + reversed ? x.focus : x.anchor; + const getFocus = (x: RangeSelection) => (reversed ? x.anchor : x.focus); + const reversedStr = reversed ? ' (reversed)' : ''; + + test(`paragraph to text nodes${reversedStr}`, async () => { + const {editor} = testEnv; + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text1 = $createTextNode('a'); + const text2 = $createTextNode('b'); + paragraph.append(text1, text2); + root.append(paragraph); + + const selection = paragraph.select(); + getAnchor(selection).set(paragraph.__key, 0, 'element'); + getFocus(selection).set(paragraph.__key, 2, 'element'); + + const normalizedSelection = $normalizeSelection(selection); + expect(getAnchor(normalizedSelection).type).toBe('text'); + expect(getAnchor(normalizedSelection).getNode().__key).toBe( + text1.__key, + ); + expect(getAnchor(normalizedSelection).offset).toBe(0); + expect(getFocus(normalizedSelection).type).toBe('text'); + expect(getFocus(normalizedSelection).getNode().__key).toBe( + text2.__key, + ); + expect(getFocus(normalizedSelection).offset).toBe(1); + }); + }); + + test(`paragraph to text node + element${reversedStr}`, async () => { + const {editor} = testEnv; + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text1 = $createTextNode('a'); + const elementNode = $createTestElementNode(); + paragraph.append(text1, elementNode); + root.append(paragraph); + + const selection = paragraph.select(); + getAnchor(selection).set(paragraph.__key, 0, 'element'); + getFocus(selection).set(paragraph.__key, 2, 'element'); + + const normalizedSelection = $normalizeSelection(selection); + expect(getAnchor(normalizedSelection).type).toBe('text'); + expect(getAnchor(normalizedSelection).getNode().__key).toBe( + text1.__key, + ); + expect(getAnchor(normalizedSelection).offset).toBe(0); + expect(getFocus(normalizedSelection).type).toBe('element'); + expect(getFocus(normalizedSelection).getNode().__key).toBe( + elementNode.__key, + ); + expect(getFocus(normalizedSelection).offset).toBe(0); + }); + }); + + test(`paragraph to text node + decorator${reversedStr}`, async () => { + const {editor} = testEnv; + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text1 = $createTextNode('a'); + const decoratorNode = $createTestDecoratorNode(); + paragraph.append(text1, decoratorNode); + root.append(paragraph); + + const selection = paragraph.select(); + getAnchor(selection).set(paragraph.__key, 0, 'element'); + getFocus(selection).set(paragraph.__key, 2, 'element'); + + const normalizedSelection = $normalizeSelection(selection); + expect(getAnchor(normalizedSelection).type).toBe('text'); + expect(getAnchor(normalizedSelection).getNode().__key).toBe( + text1.__key, + ); + expect(getAnchor(normalizedSelection).offset).toBe(0); + expect(getFocus(normalizedSelection).type).toBe('element'); + expect(getFocus(normalizedSelection).getNode().__key).toBe( + paragraph.__key, + ); + expect(getFocus(normalizedSelection).offset).toBe(2); + }); + }); + + test(`text + text node${reversedStr}`, async () => { + const {editor} = testEnv; + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text1 = $createTextNode('a'); + const text2 = $createTextNode('b'); + paragraph.append(text1, text2); + root.append(paragraph); + + const selection = paragraph.select(); + getAnchor(selection).set(text1.__key, 0, 'text'); + getFocus(selection).set(text2.__key, 1, 'text'); + + const normalizedSelection = $normalizeSelection(selection); + expect(getAnchor(normalizedSelection).type).toBe('text'); + expect(getAnchor(normalizedSelection).getNode().__key).toBe( + text1.__key, + ); + expect(getAnchor(normalizedSelection).offset).toBe(0); + expect(getFocus(normalizedSelection).type).toBe('text'); + expect(getFocus(normalizedSelection).getNode().__key).toBe( + text2.__key, + ); + expect(getFocus(normalizedSelection).offset).toBe(1); + }); + }); + + test(`paragraph to test element to text + text${reversedStr}`, async () => { + const {editor} = testEnv; + editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const elementNode = $createTestElementNode(); + const text1 = $createTextNode('a'); + const text2 = $createTextNode('b'); + elementNode.append(text1, text2); + paragraph.append(elementNode); + root.append(paragraph); + + const selection = paragraph.select(); + getAnchor(selection).set(text1.__key, 0, 'text'); + getFocus(selection).set(text2.__key, 1, 'text'); + + const normalizedSelection = $normalizeSelection(selection); + expect(getAnchor(normalizedSelection).type).toBe('text'); + expect(getAnchor(normalizedSelection).getNode().__key).toBe( + text1.__key, + ); + expect(getAnchor(normalizedSelection).offset).toBe(0); + expect(getFocus(normalizedSelection).type).toBe('text'); + expect(getFocus(normalizedSelection).getNode().__key).toBe( + text2.__key, + ); + expect(getFocus(normalizedSelection).offset).toBe(1); + }); + }); + } + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts new file mode 100644 index 000000000..7055f361a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts @@ -0,0 +1,342 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createLinkNode, $isLinkNode} from '@lexical/link'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isParagraphNode, + $isTextNode, + LexicalEditor, + RangeSelection, +} from 'lexical'; + +import {initializeUnitTest, invariant} from '../utils'; + +describe('LexicalSelection tests', () => { + initializeUnitTest((testEnv) => { + describe('Inserting text either side of inline elements', () => { + const setup = async ( + mode: 'start-of-paragraph' | 'mid-paragraph' | 'end-of-paragraph', + ) => { + const {container, editor} = testEnv; + + if (!container) { + throw new Error('Expected container to be truthy'); + } + + await editor.update(() => { + const root = $getRoot(); + if (root.getFirstChild() !== null) { + throw new Error('Expected root to be childless'); + } + + const paragraph = $createParagraphNode(); + if (mode === 'start-of-paragraph') { + paragraph.append( + $createLinkNode('https://', {}).append($createTextNode('a')), + $createTextNode('b'), + ); + } else if (mode === 'mid-paragraph') { + paragraph.append( + $createTextNode('a'), + $createLinkNode('https://', {}).append($createTextNode('b')), + $createTextNode('c'), + ); + } else { + paragraph.append( + $createTextNode('a'), + $createLinkNode('https://', {}).append($createTextNode('b')), + ); + } + + root.append(paragraph); + }); + + const expectation = + mode === 'start-of-paragraph' + ? '

    ab

    ' + : mode === 'mid-paragraph' + ? '

    abc

    ' + : '

    ab

    '; + + expect(container.innerHTML).toBe(expectation); + + return {container, editor}; + }; + + const $insertTextOrNodes = ( + selection: RangeSelection, + method: 'insertText' | 'insertNodes', + ) => { + if (method === 'insertText') { + // Insert text (mirroring what LexicalClipboard does when pasting + // inline plain text) + selection.insertText('x'); + } else { + // Insert a paragraph bearing a single text node (mirroring what + // LexicalClipboard does when pasting inline rich text) + selection.insertNodes([ + $createParagraphNode().append($createTextNode('x')), + ]); + } + }; + + describe('Inserting text before inline elements', () => { + describe('Start-of-paragraph inline elements', () => { + const insertText = async ({ + container, + editor, + method, + }: { + container: HTMLDivElement; + editor: LexicalEditor; + method: 'insertText' | 'insertNodes'; + }) => { + await editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + invariant($isParagraphNode(paragraph)); + const linkNode = paragraph.getFirstChildOrThrow(); + invariant($isLinkNode(linkNode)); + + // Place the cursor at the start of the link node + // For review: is there a way to select "outside" of the link + // node? + const selection = linkNode.select(0, 0); + $insertTextOrNodes(selection, method); + }); + + expect(container.innerHTML).toBe( + '

    xab

    ', + ); + }; + + test('Can insert text before a start-of-paragraph inline element, using insertText', async () => { + const {container, editor} = await setup('start-of-paragraph'); + + await insertText({container, editor, method: 'insertText'}); + }); + + // TODO: https://github.com/facebook/lexical/issues/4295 + // test('Can insert text before a start-of-paragraph inline element, using insertNodes', async () => { + // const {container, editor} = await setup('start-of-paragraph'); + + // await insertText({container, editor, method: 'insertNodes'}); + // }); + }); + + describe('Mid-paragraph inline elements', () => { + const insertText = async ({ + container, + editor, + method, + }: { + container: HTMLDivElement; + editor: LexicalEditor; + method: 'insertText' | 'insertNodes'; + }) => { + await editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + invariant($isParagraphNode(paragraph)); + const textNode = paragraph.getFirstChildOrThrow(); + invariant($isTextNode(textNode)); + + // Place the cursor between the link and the first text node by + // selecting the end of the text node + const selection = textNode.select(1, 1); + $insertTextOrNodes(selection, method); + }); + + expect(container.innerHTML).toBe( + '

    axbc

    ', + ); + }; + + test('Can insert text before a mid-paragraph inline element, using insertText', async () => { + const {container, editor} = await setup('mid-paragraph'); + + await insertText({container, editor, method: 'insertText'}); + }); + + test('Can insert text before a mid-paragraph inline element, using insertNodes', async () => { + const {container, editor} = await setup('mid-paragraph'); + + await insertText({container, editor, method: 'insertNodes'}); + }); + }); + + describe('End-of-paragraph inline elements', () => { + const insertText = async ({ + container, + editor, + method, + }: { + container: HTMLDivElement; + editor: LexicalEditor; + method: 'insertText' | 'insertNodes'; + }) => { + await editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + invariant($isParagraphNode(paragraph)); + const textNode = paragraph.getFirstChildOrThrow(); + invariant($isTextNode(textNode)); + + // Place the cursor before the link element by selecting the end + // of the text node + const selection = textNode.select(1, 1); + $insertTextOrNodes(selection, method); + }); + + expect(container.innerHTML).toBe( + '

    axb

    ', + ); + }; + + test('Can insert text before an end-of-paragraph inline element, using insertText', async () => { + const {container, editor} = await setup('end-of-paragraph'); + + await insertText({container, editor, method: 'insertText'}); + }); + + test('Can insert text before an end-of-paragraph inline element, using insertNodes', async () => { + const {container, editor} = await setup('end-of-paragraph'); + + await insertText({container, editor, method: 'insertNodes'}); + }); + }); + }); + + describe('Inserting text after inline elements', () => { + describe('Start-of-paragraph inline elements', () => { + const insertText = async ({ + container, + editor, + method, + }: { + container: HTMLDivElement; + editor: LexicalEditor; + method: 'insertText' | 'insertNodes'; + }) => { + await editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + invariant($isParagraphNode(paragraph)); + const textNode = paragraph.getLastChildOrThrow(); + invariant($isTextNode(textNode)); + + // Place the cursor between the link and the last text node by + // selecting the start of the text node + const selection = textNode.select(0, 0); + $insertTextOrNodes(selection, method); + }); + + expect(container.innerHTML).toBe( + '

    axb

    ', + ); + }; + + test('Can insert text after a start-of-paragraph inline element, using insertText', async () => { + const {container, editor} = await setup('start-of-paragraph'); + + await insertText({container, editor, method: 'insertText'}); + }); + + // TODO: https://github.com/facebook/lexical/issues/4295 + // test('Can insert text after a start-of-paragraph inline element, using insertNodes', async () => { + // const {container, editor} = await setup('start-of-paragraph'); + + // await insertText({container, editor, method: 'insertNodes'}); + // }); + }); + + describe('Mid-paragraph inline elements', () => { + const insertText = async ({ + container, + editor, + method, + }: { + container: HTMLDivElement; + editor: LexicalEditor; + method: 'insertText' | 'insertNodes'; + }) => { + await editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + invariant($isParagraphNode(paragraph)); + const textNode = paragraph.getLastChildOrThrow(); + invariant($isTextNode(textNode)); + + // Place the cursor between the link and the last text node by + // selecting the start of the text node + const selection = textNode.select(0, 0); + $insertTextOrNodes(selection, method); + }); + + expect(container.innerHTML).toBe( + '

    abxc

    ', + ); + }; + + test('Can insert text after a mid-paragraph inline element, using insertText', async () => { + const {container, editor} = await setup('mid-paragraph'); + + await insertText({container, editor, method: 'insertText'}); + }); + + // TODO: https://github.com/facebook/lexical/issues/4295 + // test('Can insert text after a mid-paragraph inline element, using insertNodes', async () => { + // const {container, editor} = await setup('mid-paragraph'); + + // await insertText({container, editor, method: 'insertNodes'}); + // }); + }); + + describe('End-of-paragraph inline elements', () => { + const insertText = async ({ + container, + editor, + method, + }: { + container: HTMLDivElement; + editor: LexicalEditor; + method: 'insertText' | 'insertNodes'; + }) => { + await editor.update(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + invariant($isParagraphNode(paragraph)); + const linkNode = paragraph.getLastChildOrThrow(); + invariant($isLinkNode(linkNode)); + + // Place the cursor at the end of the link element + // For review: not sure if there's a better way to select + // "outside" of the link element. + const selection = linkNode.select(1, 1); + $insertTextOrNodes(selection, method); + }); + + expect(container.innerHTML).toBe( + '

    abx

    ', + ); + }; + + test('Can insert text after an end-of-paragraph inline element, using insertText', async () => { + const {container, editor} = await setup('end-of-paragraph'); + + await insertText({container, editor, method: 'insertText'}); + }); + + // TODO: https://github.com/facebook/lexical/issues/4295 + // test('Can insert text after an end-of-paragraph inline element, using insertNodes', async () => { + // const {container, editor} = await setup('end-of-paragraph'); + + // await insertText({container, editor, method: 'insertNodes'}); + // }); + }); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts new file mode 100644 index 000000000..9237bc9d3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -0,0 +1,126 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createCodeHighlightNode, $createCodeNode} from '@lexical/code'; +import {$createLinkNode} from '@lexical/link'; +import {$createListItemNode, $createListNode} from '@lexical/list'; +import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text'; +import {$createTableNodeWithDimensions} from '@lexical/table'; +import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; + +import {initializeUnitTest} from '../utils'; + +function $createEditorContent() { + const root = $getRoot(); + if (root.getFirstChild() === null) { + const heading = $createHeadingNode('h1'); + heading.append($createTextNode('Welcome to the playground')); + root.append(heading); + const quote = $createQuoteNode(); + quote.append( + $createTextNode( + `In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. ` + + `You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.`, + ), + ); + root.append(quote); + const paragraph = $createParagraphNode(); + paragraph.append( + $createTextNode('The playground is a demo environment built with '), + $createTextNode('@lexical/react').toggleFormat('code'), + $createTextNode('.'), + $createTextNode(' Try typing in '), + $createTextNode('some text').toggleFormat('bold'), + $createTextNode(' with '), + $createTextNode('different').toggleFormat('italic'), + $createTextNode(' formats.'), + ); + root.append(paragraph); + const paragraph2 = $createParagraphNode(); + paragraph2.append( + $createTextNode( + 'Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!', + ), + ); + root.append(paragraph2); + const paragraph3 = $createParagraphNode(); + paragraph3.append( + $createTextNode(`If you'd like to find out more about Lexical, you can:`), + ); + root.append(paragraph3); + const list = $createListNode('bullet'); + list.append( + $createListItemNode().append( + $createTextNode(`Visit the `), + $createLinkNode('https://lexical.dev/').append( + $createTextNode('Lexical website'), + ), + $createTextNode(` for documentation and more information.`), + ), + $createListItemNode().append( + $createTextNode(`Check out the code on our `), + $createLinkNode('https://github.com/facebook/lexical').append( + $createTextNode('GitHub repository'), + ), + $createTextNode(`.`), + ), + $createListItemNode().append( + $createTextNode(`Playground code can be found `), + $createLinkNode( + 'https://github.com/facebook/lexical/tree/main/packages/lexical-playground', + ).append($createTextNode('here')), + $createTextNode(`.`), + ), + $createListItemNode().append( + $createTextNode(`Join our `), + $createLinkNode('https://discord.com/invite/KmG4wQnnD9').append( + $createTextNode('Discord Server'), + ), + $createTextNode(` and chat with the team.`), + ), + ); + root.append(list); + const paragraph4 = $createParagraphNode(); + paragraph4.append( + $createTextNode( + `Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).`, + ), + ); + root.append(paragraph4); + const codeBlock = $createCodeNode('javascript'); + codeBlock.append($createCodeHighlightNode('const lexical = "awesome"')); + root.append(codeBlock); + const table = $createTableNodeWithDimensions(5, 5, true); + root.append(table); + } +} + +describe('LexicalSerialization tests', () => { + initializeUnitTest((testEnv) => { + test('serializes and deserializes from JSON', async () => { + const {editor} = testEnv; + + await editor.update(() => { + $createEditorContent(); + }); + + const stringifiedEditorState = JSON.stringify(editor.getEditorState()); + const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"const lexical = \\"awesome\\"","type":"code-highlight","version":1}],"direction":"ltr","format":"","indent":0,"type":"code","version":1,"language":"javascript"},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1}],"direction":"ltr","format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; + + expect(stringifiedEditorState).toBe(expectedStringifiedEditorState); + + const editorState = editor.parseEditorState(stringifiedEditorState); + + const otherStringifiedEditorState = JSON.stringify(editorState); + + expect(otherStringifiedEditorState).toBe( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"const lexical = \\"awesome\\"","type":"code-highlight","version":1}],"direction":"ltr","format":"","indent":0,"type":"code","version":1,"language":"javascript"},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + ); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts new file mode 100644 index 000000000..0026cf5d6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalUtils.test.ts @@ -0,0 +1,293 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $getNodeByKey, + $getRoot, + $isTokenOrSegmented, + $nodesOfType, + emptyFunction, + generateRandomKey, + getCachedTypeToNodeMap, + getTextDirection, + isArray, + isSelectionWithinEditor, + resetRandomKey, + scheduleMicroTask, +} from '../../LexicalUtils'; +import { + $createParagraphNode, + ParagraphNode, +} from '../../nodes/LexicalParagraphNode'; +import {$createTextNode, TextNode} from '../../nodes/LexicalTextNode'; +import {initializeUnitTest} from '../utils'; + +describe('LexicalUtils tests', () => { + initializeUnitTest((testEnv) => { + test('scheduleMicroTask(): native', async () => { + jest.resetModules(); + + let flag = false; + + scheduleMicroTask(() => { + flag = true; + }); + + expect(flag).toBe(false); + + await null; + + expect(flag).toBe(true); + }); + + test('scheduleMicroTask(): promise', async () => { + jest.resetModules(); + const nativeQueueMicrotask = window.queueMicrotask; + const fn = jest.fn(); + try { + // @ts-ignore + window.queueMicrotask = undefined; + scheduleMicroTask(fn); + } finally { + // Reset it before yielding control + window.queueMicrotask = nativeQueueMicrotask; + } + + expect(fn).toHaveBeenCalledTimes(0); + + await null; + + expect(fn).toHaveBeenCalledTimes(1); + }); + + test('emptyFunction()', () => { + expect(emptyFunction).toBeInstanceOf(Function); + expect(emptyFunction.length).toBe(0); + expect(emptyFunction()).toBe(undefined); + }); + + test('resetRandomKey()', () => { + resetRandomKey(); + const key1 = generateRandomKey(); + resetRandomKey(); + const key2 = generateRandomKey(); + expect(typeof key1).toBe('string'); + expect(typeof key2).toBe('string'); + expect(key1).not.toBe(''); + expect(key2).not.toBe(''); + expect(key1).toEqual(key2); + }); + + test('generateRandomKey()', () => { + const key1 = generateRandomKey(); + const key2 = generateRandomKey(); + expect(typeof key1).toBe('string'); + expect(typeof key2).toBe('string'); + expect(key1).not.toBe(''); + expect(key2).not.toBe(''); + expect(key1).not.toEqual(key2); + }); + + test('isArray()', () => { + expect(isArray).toBeInstanceOf(Function); + expect(isArray).toBe(Array.isArray); + }); + + test('isSelectionWithinEditor()', async () => { + const {editor} = testEnv; + let textNode: TextNode; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + textNode = $createTextNode('foo'); + paragraph.append(textNode); + root.append(paragraph); + }); + + await editor.update(() => { + const domSelection = window.getSelection()!; + + expect( + isSelectionWithinEditor( + editor, + domSelection.anchorNode, + domSelection.focusNode, + ), + ).toBe(false); + + textNode.select(0, 0); + }); + + await editor.update(() => { + const domSelection = window.getSelection()!; + + expect( + isSelectionWithinEditor( + editor, + domSelection.anchorNode, + domSelection.focusNode, + ), + ).toBe(true); + }); + }); + + test('getTextDirection()', () => { + expect(getTextDirection('')).toBe(null); + expect(getTextDirection(' ')).toBe(null); + expect(getTextDirection('0')).toBe(null); + expect(getTextDirection('A')).toBe('ltr'); + expect(getTextDirection('Z')).toBe('ltr'); + expect(getTextDirection('a')).toBe('ltr'); + expect(getTextDirection('z')).toBe('ltr'); + expect(getTextDirection('\u00C0')).toBe('ltr'); + expect(getTextDirection('\u00D6')).toBe('ltr'); + expect(getTextDirection('\u00D8')).toBe('ltr'); + expect(getTextDirection('\u00F6')).toBe('ltr'); + expect(getTextDirection('\u00F8')).toBe('ltr'); + expect(getTextDirection('\u02B8')).toBe('ltr'); + expect(getTextDirection('\u0300')).toBe('ltr'); + expect(getTextDirection('\u0590')).toBe('ltr'); + expect(getTextDirection('\u0800')).toBe('ltr'); + expect(getTextDirection('\u1FFF')).toBe('ltr'); + expect(getTextDirection('\u200E')).toBe('ltr'); + expect(getTextDirection('\u2C00')).toBe('ltr'); + expect(getTextDirection('\uFB1C')).toBe('ltr'); + expect(getTextDirection('\uFE00')).toBe('ltr'); + expect(getTextDirection('\uFE6F')).toBe('ltr'); + expect(getTextDirection('\uFEFD')).toBe('ltr'); + expect(getTextDirection('\uFFFF')).toBe('ltr'); + expect(getTextDirection(`\u0591`)).toBe('rtl'); + expect(getTextDirection(`\u07FF`)).toBe('rtl'); + expect(getTextDirection(`\uFB1D`)).toBe('rtl'); + expect(getTextDirection(`\uFDFD`)).toBe('rtl'); + expect(getTextDirection(`\uFE70`)).toBe('rtl'); + expect(getTextDirection(`\uFEFC`)).toBe('rtl'); + }); + + test('isTokenOrSegmented()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = $createTextNode('foo'); + expect($isTokenOrSegmented(node)).toBe(false); + + const tokenNode = $createTextNode().setMode('token'); + expect($isTokenOrSegmented(tokenNode)).toBe(true); + + const segmentedNode = $createTextNode('foo').setMode('segmented'); + expect($isTokenOrSegmented(segmentedNode)).toBe(true); + }); + }); + + test('$getNodeByKey', async () => { + const {editor} = testEnv; + let paragraphNode: ParagraphNode; + let textNode: TextNode; + + await editor.update(() => { + const rootNode = $getRoot(); + paragraphNode = new ParagraphNode(); + textNode = new TextNode('foo'); + paragraphNode.append(textNode); + rootNode.append(paragraphNode); + }); + + await editor.getEditorState().read(() => { + expect($getNodeByKey('1')).toBe(paragraphNode); + expect($getNodeByKey('2')).toBe(textNode); + expect($getNodeByKey('3')).toBe(null); + }); + + // @ts-expect-error + expect(() => $getNodeByKey()).toThrow(); + }); + + test('$nodesOfType', async () => { + const {editor} = testEnv; + const paragraphKeys: string[] = []; + + const $paragraphKeys = () => + $nodesOfType(ParagraphNode).map((node) => node.getKey()); + + await editor.update(() => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + $createParagraphNode(); + root.append(paragraph1, paragraph2); + paragraphKeys.push(paragraph1.getKey(), paragraph2.getKey()); + const currentParagraphKeys = $paragraphKeys(); + expect(currentParagraphKeys).toHaveLength(paragraphKeys.length); + expect(currentParagraphKeys).toEqual( + expect.arrayContaining(paragraphKeys), + ); + }); + editor.getEditorState().read(() => { + const currentParagraphKeys = $paragraphKeys(); + expect(currentParagraphKeys).toHaveLength(paragraphKeys.length); + expect(currentParagraphKeys).toEqual( + expect.arrayContaining(paragraphKeys), + ); + }); + }); + + test('getCachedTypeToNodeMap', async () => { + const {editor} = testEnv; + const paragraphKeys: string[] = []; + + const initialTypeToNodeMap = getCachedTypeToNodeMap( + editor.getEditorState(), + ); + expect(getCachedTypeToNodeMap(editor.getEditorState())).toBe( + initialTypeToNodeMap, + ); + expect([...initialTypeToNodeMap.keys()]).toEqual(['root']); + expect(initialTypeToNodeMap.get('root')).toMatchObject({size: 1}); + + editor.update( + () => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode().append( + $createTextNode('a'), + ); + const paragraph2 = $createParagraphNode().append( + $createTextNode('b'), + ); + // these will be garbage collected and not in the readonly map + $createParagraphNode().append($createTextNode('c')); + root.append(paragraph1, paragraph2); + paragraphKeys.push(paragraph1.getKey(), paragraph2.getKey()); + }, + {discrete: true}, + ); + + const typeToNodeMap = getCachedTypeToNodeMap(editor.getEditorState()); + // verify that the initial cache was not used + expect(typeToNodeMap).not.toBe(initialTypeToNodeMap); + // verify that the cache is used for subsequent calls + expect(getCachedTypeToNodeMap(editor.getEditorState())).toBe( + typeToNodeMap, + ); + expect(typeToNodeMap.size).toEqual(3); + expect([...typeToNodeMap.keys()]).toEqual( + expect.arrayContaining(['root', 'paragraph', 'text']), + ); + const paragraphMap = typeToNodeMap.get('paragraph')!; + expect(paragraphMap.size).toEqual(paragraphKeys.length); + expect([...paragraphMap.keys()]).toEqual( + expect.arrayContaining(paragraphKeys), + ); + const textMap = typeToNodeMap.get('text')!; + expect(textMap.size).toEqual(2); + expect( + [...textMap.values()].map((node) => (node as TextNode).__text), + ).toEqual(expect.arrayContaining(['a', 'b'])); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts new file mode 100644 index 000000000..b7ccfab1e --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -0,0 +1,751 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {createHeadlessEditor} from '@lexical/headless'; +import {AutoLinkNode, LinkNode} from '@lexical/link'; +import {ListItemNode, ListNode} from '@lexical/list'; + +import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; + +import { + $isRangeSelection, + createEditor, + DecoratorNode, + EditorState, + EditorThemeClasses, + ElementNode, + Klass, + LexicalEditor, + LexicalNode, + RangeSelection, + SerializedElementNode, + SerializedLexicalNode, + SerializedTextNode, + TextNode, +} from 'lexical'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +import { + CreateEditorArgs, + HTMLConfig, + LexicalNodeReplacement, +} from '../../LexicalEditor'; +import {resetRandomKey} from '../../LexicalUtils'; + + +type TestEnv = { + readonly container: HTMLDivElement; + readonly editor: LexicalEditor; + readonly outerHTML: string; + readonly innerHTML: string; +}; + +export function initializeUnitTest( + runTests: (testEnv: TestEnv) => void, + editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}}, +) { + const testEnv = { + _container: null as HTMLDivElement | null, + _editor: null as LexicalEditor | null, + get container() { + if (!this._container) { + throw new Error('testEnv.container not initialized.'); + } + return this._container; + }, + set container(container) { + this._container = container; + }, + get editor() { + if (!this._editor) { + throw new Error('testEnv.editor not initialized.'); + } + return this._editor; + }, + set editor(editor) { + this._editor = editor; + }, + get innerHTML() { + return (this.container.firstChild as HTMLElement).innerHTML; + }, + get outerHTML() { + return this.container.innerHTML; + }, + reset() { + this._container = null; + this._editor = null; + }, + }; + + beforeEach(async () => { + resetRandomKey(); + + testEnv.container = document.createElement('div'); + document.body.appendChild(testEnv.container); + + const useLexicalEditor = ( + rootElementRef: React.RefObject, + ) => { + const lexicalEditor = React.useMemo(() => { + const lexical = createTestEditor(editorConfig); + return lexical; + }, []); + + React.useEffect(() => { + const rootElement = rootElementRef.current; + lexicalEditor.setRootElement(rootElement); + }, [rootElementRef, lexicalEditor]); + return lexicalEditor; + }; + + const Editor = () => { + testEnv.editor = useLexicalEditor(ref); + const context = createLexicalComposerContext( + null, + editorConfig?.theme ?? {}, + ); + return ( + +
    + {plugins} + + ); + }; + + ReactTestUtils.act(() => { + createRoot(testEnv.container).render(); + }); + }); + + afterEach(() => { + document.body.removeChild(testEnv.container); + testEnv.reset(); + }); + + runTests(testEnv); +} + +export function initializeClipboard() { + Object.defineProperty(window, 'DragEvent', { + value: class DragEvent {}, + }); + Object.defineProperty(window, 'ClipboardEvent', { + value: class ClipboardEvent {}, + }); +} + +export type SerializedTestElementNode = SerializedElementNode; + +export class TestElementNode extends ElementNode { + static getType(): string { + return 'test_block'; + } + + static clone(node: TestElementNode) { + return new TestElementNode(node.__key); + } + + static importJSON( + serializedNode: SerializedTestElementNode, + ): TestInlineElementNode { + const node = $createTestInlineElementNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedTestElementNode { + return { + ...super.exportJSON(), + type: 'test_block', + version: 1, + }; + } + + createDOM() { + return document.createElement('div'); + } + + updateDOM() { + return false; + } +} + +export function $createTestElementNode(): TestElementNode { + return new TestElementNode(); +} + +type SerializedTestTextNode = SerializedTextNode; + +export class TestTextNode extends TextNode { + static getType() { + return 'test_text'; + } + + static clone(node: TestTextNode): TestTextNode { + return new TestTextNode(node.__text, node.__key); + } + + static importJSON(serializedNode: SerializedTestTextNode): TestTextNode { + return new TestTextNode(serializedNode.text); + } + + exportJSON(): SerializedTestTextNode { + return { + ...super.exportJSON(), + type: 'test_text', + version: 1, + }; + } +} + +export type SerializedTestInlineElementNode = SerializedElementNode; + +export class TestInlineElementNode extends ElementNode { + static getType(): string { + return 'test_inline_block'; + } + + static clone(node: TestInlineElementNode) { + return new TestInlineElementNode(node.__key); + } + + static importJSON( + serializedNode: SerializedTestInlineElementNode, + ): TestInlineElementNode { + const node = $createTestInlineElementNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedTestInlineElementNode { + return { + ...super.exportJSON(), + type: 'test_inline_block', + version: 1, + }; + } + + createDOM() { + return document.createElement('a'); + } + + updateDOM() { + return false; + } + + isInline() { + return true; + } +} + +export function $createTestInlineElementNode(): TestInlineElementNode { + return new TestInlineElementNode(); +} + +export type SerializedTestShadowRootNode = SerializedElementNode; + +export class TestShadowRootNode extends ElementNode { + static getType(): string { + return 'test_shadow_root'; + } + + static clone(node: TestShadowRootNode) { + return new TestElementNode(node.__key); + } + + static importJSON( + serializedNode: SerializedTestShadowRootNode, + ): TestShadowRootNode { + const node = $createTestShadowRootNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedTestShadowRootNode { + return { + ...super.exportJSON(), + type: 'test_block', + version: 1, + }; + } + + createDOM() { + return document.createElement('div'); + } + + updateDOM() { + return false; + } + + isShadowRoot() { + return true; + } +} + +export function $createTestShadowRootNode(): TestShadowRootNode { + return new TestShadowRootNode(); +} + +export type SerializedTestSegmentedNode = SerializedTextNode; + +export class TestSegmentedNode extends TextNode { + static getType(): string { + return 'test_segmented'; + } + + static clone(node: TestSegmentedNode): TestSegmentedNode { + return new TestSegmentedNode(node.__text, node.__key); + } + + static importJSON( + serializedNode: SerializedTestSegmentedNode, + ): TestSegmentedNode { + const node = $createTestSegmentedNode(serializedNode.text); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; + } + + exportJSON(): SerializedTestSegmentedNode { + return { + ...super.exportJSON(), + type: 'test_segmented', + version: 1, + }; + } +} + +export function $createTestSegmentedNode(text: string): TestSegmentedNode { + return new TestSegmentedNode(text).setMode('segmented'); +} + +export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode; + +export class TestExcludeFromCopyElementNode extends ElementNode { + static getType(): string { + return 'test_exclude_from_copy_block'; + } + + static clone(node: TestExcludeFromCopyElementNode) { + return new TestExcludeFromCopyElementNode(node.__key); + } + + static importJSON( + serializedNode: SerializedTestExcludeFromCopyElementNode, + ): TestExcludeFromCopyElementNode { + const node = $createTestExcludeFromCopyElementNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedTestExcludeFromCopyElementNode { + return { + ...super.exportJSON(), + type: 'test_exclude_from_copy_block', + version: 1, + }; + } + + createDOM() { + return document.createElement('div'); + } + + updateDOM() { + return false; + } + + excludeFromCopy() { + return true; + } +} + +export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode { + return new TestExcludeFromCopyElementNode(); +} + +export type SerializedTestDecoratorNode = SerializedLexicalNode; + +export class TestDecoratorNode extends DecoratorNode { + static getType(): string { + return 'test_decorator'; + } + + static clone(node: TestDecoratorNode) { + return new TestDecoratorNode(node.__key); + } + + static importJSON( + serializedNode: SerializedTestDecoratorNode, + ): TestDecoratorNode { + return $createTestDecoratorNode(); + } + + exportJSON(): SerializedTestDecoratorNode { + return { + ...super.exportJSON(), + type: 'test_decorator', + version: 1, + }; + } + + static importDOM() { + return { + 'test-decorator': (domNode: HTMLElement) => { + return { + conversion: () => ({node: $createTestDecoratorNode()}), + }; + }, + }; + } + + exportDOM() { + return { + element: document.createElement('test-decorator'), + }; + } + + getTextContent() { + return 'Hello world'; + } + + createDOM() { + return document.createElement('span'); + } + + updateDOM() { + return false; + } + + decorate() { + return ; + } +} + +function Decorator({text}: {text: string}): JSX.Element { + return {text}; +} + +export function $createTestDecoratorNode(): TestDecoratorNode { + return new TestDecoratorNode(); +} + +const DEFAULT_NODES: NonNullable = [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + TableNode, + TableCellNode, + TableRowNode, + HashtagNode, + CodeHighlightNode, + AutoLinkNode, + LinkNode, + OverflowNode, + TestElementNode, + TestSegmentedNode, + TestExcludeFromCopyElementNode, + TestDecoratorNode, + TestInlineElementNode, + TestShadowRootNode, + TestTextNode, +]; + +export function createTestEditor( + config: { + namespace?: string; + editorState?: EditorState; + theme?: EditorThemeClasses; + parentEditor?: LexicalEditor; + nodes?: ReadonlyArray | LexicalNodeReplacement>; + onError?: (error: Error) => void; + disableEvents?: boolean; + readOnly?: boolean; + html?: HTMLConfig; + } = {}, +): LexicalEditor { + const customNodes = config.nodes || []; + const editor = createEditor({ + namespace: config.namespace, + onError: (e) => { + throw e; + }, + ...config, + nodes: DEFAULT_NODES.concat(customNodes), + }); + return editor; +} + +export function createTestHeadlessEditor( + editorState?: EditorState, +): LexicalEditor { + return createHeadlessEditor({ + editorState, + onError: (error) => { + throw error; + }, + }); +} + +export function $assertRangeSelection(selection: unknown): RangeSelection { + if (!$isRangeSelection(selection)) { + throw new Error(`Expected RangeSelection, got ${selection}`); + } + return selection; +} + +export function invariant(cond?: boolean, message?: string): asserts cond { + if (cond) { + return; + } + throw new Error(`Invariant: ${message}`); +} + +export class ClipboardDataMock { + getData: jest.Mock; + setData: jest.Mock; + + constructor() { + this.getData = jest.fn(); + this.setData = jest.fn(); + } +} + +export class DataTransferMock implements DataTransfer { + _data: Map = new Map(); + get dropEffect(): DataTransfer['dropEffect'] { + throw new Error('Getter not implemented.'); + } + get effectAllowed(): DataTransfer['effectAllowed'] { + throw new Error('Getter not implemented.'); + } + get files(): FileList { + throw new Error('Getter not implemented.'); + } + get items(): DataTransferItemList { + throw new Error('Getter not implemented.'); + } + get types(): ReadonlyArray { + return Array.from(this._data.keys()); + } + clearData(dataType?: string): void { + // + } + getData(dataType: string): string { + return this._data.get(dataType) || ''; + } + setData(dataType: string, data: string): void { + this._data.set(dataType, data); + } + setDragImage(image: Element, x: number, y: number): void { + // + } +} + +export class EventMock implements Event { + get bubbles(): boolean { + throw new Error('Getter not implemented.'); + } + get cancelBubble(): boolean { + throw new Error('Gettter not implemented.'); + } + get cancelable(): boolean { + throw new Error('Gettter not implemented.'); + } + get composed(): boolean { + throw new Error('Gettter not implemented.'); + } + get currentTarget(): EventTarget | null { + throw new Error('Gettter not implemented.'); + } + get defaultPrevented(): boolean { + throw new Error('Gettter not implemented.'); + } + get eventPhase(): number { + throw new Error('Gettter not implemented.'); + } + get isTrusted(): boolean { + throw new Error('Gettter not implemented.'); + } + get returnValue(): boolean { + throw new Error('Gettter not implemented.'); + } + get srcElement(): EventTarget | null { + throw new Error('Gettter not implemented.'); + } + get target(): EventTarget | null { + throw new Error('Gettter not implemented.'); + } + get timeStamp(): number { + throw new Error('Gettter not implemented.'); + } + get type(): string { + throw new Error('Gettter not implemented.'); + } + composedPath(): EventTarget[] { + throw new Error('Method not implemented.'); + } + initEvent( + type: string, + bubbles?: boolean | undefined, + cancelable?: boolean | undefined, + ): void { + throw new Error('Method not implemented.'); + } + stopImmediatePropagation(): void { + return; + } + stopPropagation(): void { + return; + } + NONE = 0 as const; + CAPTURING_PHASE = 1 as const; + AT_TARGET = 2 as const; + BUBBLING_PHASE = 3 as const; + preventDefault() { + return; + } +} + +export class KeyboardEventMock extends EventMock implements KeyboardEvent { + altKey = false; + get charCode(): number { + throw new Error('Getter not implemented.'); + } + get code(): string { + throw new Error('Getter not implemented.'); + } + ctrlKey = false; + get isComposing(): boolean { + throw new Error('Getter not implemented.'); + } + get key(): string { + throw new Error('Getter not implemented.'); + } + get keyCode(): number { + throw new Error('Getter not implemented.'); + } + get location(): number { + throw new Error('Getter not implemented.'); + } + metaKey = false; + get repeat(): boolean { + throw new Error('Getter not implemented.'); + } + shiftKey = false; + constructor(type: void | string) { + super(); + } + getModifierState(keyArg: string): boolean { + throw new Error('Method not implemented.'); + } + initKeyboardEvent( + typeArg: string, + bubblesArg?: boolean | undefined, + cancelableArg?: boolean | undefined, + viewArg?: Window | null | undefined, + keyArg?: string | undefined, + locationArg?: number | undefined, + ctrlKey?: boolean | undefined, + altKey?: boolean | undefined, + shiftKey?: boolean | undefined, + metaKey?: boolean | undefined, + ): void { + throw new Error('Method not implemented.'); + } + DOM_KEY_LOCATION_STANDARD = 0 as const; + DOM_KEY_LOCATION_LEFT = 1 as const; + DOM_KEY_LOCATION_RIGHT = 2 as const; + DOM_KEY_LOCATION_NUMPAD = 3 as const; + get detail(): number { + throw new Error('Getter not implemented.'); + } + get view(): Window | null { + throw new Error('Getter not implemented.'); + } + get which(): number { + throw new Error('Getter not implemented.'); + } + initUIEvent( + typeArg: string, + bubblesArg?: boolean | undefined, + cancelableArg?: boolean | undefined, + viewArg?: Window | null | undefined, + detailArg?: number | undefined, + ): void { + throw new Error('Method not implemented.'); + } +} + +export function tabKeyboardEvent() { + return new KeyboardEventMock('keydown'); +} + +export function shiftTabKeyboardEvent() { + const keyboardEvent = new KeyboardEventMock('keydown'); + keyboardEvent.shiftKey = true; + return keyboardEvent; +} + +export function generatePermutations( + values: T[], + maxLength = values.length, +): T[][] { + if (maxLength > values.length) { + throw new Error('maxLength over values.length'); + } + const result: T[][] = []; + const current: T[] = []; + const seen = new Set(); + (function permutationsImpl() { + if (current.length > maxLength) { + return; + } + result.push(current.slice()); + for (let i = 0; i < values.length; i++) { + const key = values[i]; + if (seen.has(key)) { + continue; + } + seen.add(key); + current.push(key); + permutationsImpl(); + seen.delete(key); + current.pop(); + } + })(); + return result; +} + +// This tag function is just used to trigger prettier auto-formatting. +// (https://prettier.io/blog/2020/08/24/2.1.0.html#api) +export function html( + partials: TemplateStringsArray, + ...params: string[] +): string { + let output = ''; + for (let i = 0; i < partials.length; i++) { + output += partials[i]; + if (i < partials.length - 1) { + output += params[i]; + } + } + return output; +} diff --git a/resources/js/wysiwyg/lexical/core/index.ts b/resources/js/wysiwyg/lexical/core/index.ts new file mode 100644 index 000000000..5ef926b5a --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/index.ts @@ -0,0 +1,208 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export type {PasteCommandType} from './LexicalCommands'; +export type { + CommandListener, + CommandListenerPriority, + CommandPayloadType, + CreateEditorArgs, + EditableListener, + EditorConfig, + EditorSetOptions, + EditorThemeClasses, + EditorThemeClassName, + EditorUpdateOptions, + HTMLConfig, + Klass, + KlassConstructor, + LexicalCommand, + LexicalEditor, + LexicalNodeReplacement, + MutationListener, + NodeMutation, + SerializedEditor, + Spread, + Transform, +} from './LexicalEditor'; +export type { + EditorState, + EditorStateReadOptions, + SerializedEditorState, +} from './LexicalEditorState'; +export type { + DOMChildConversion, + DOMConversion, + DOMConversionFn, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + LexicalNode, + NodeKey, + NodeMap, + SerializedLexicalNode, +} from './LexicalNode'; +export type { + BaseSelection, + ElementPointType as ElementPoint, + NodeSelection, + Point, + PointType, + RangeSelection, + TextPointType as TextPoint, +} from './LexicalSelection'; +export type { + ElementFormatType, + SerializedElementNode, +} from './nodes/LexicalElementNode'; +export type {SerializedRootNode} from './nodes/LexicalRootNode'; +export type { + SerializedTextNode, + TextFormatType, + TextModeType, +} from './nodes/LexicalTextNode'; + +// TODO Move this somewhere else and/or recheck if we still need this +export { + BLUR_COMMAND, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + CLEAR_EDITOR_COMMAND, + CLEAR_HISTORY_COMMAND, + CLICK_COMMAND, + CONTROLLED_TEXT_INSERTION_COMMAND, + COPY_COMMAND, + createCommand, + CUT_COMMAND, + DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, + DRAGEND_COMMAND, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + FOCUS_COMMAND, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + INDENT_CONTENT_COMMAND, + INSERT_LINE_BREAK_COMMAND, + INSERT_PARAGRAPH_COMMAND, + INSERT_TAB_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_DOWN_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_MODIFIER_COMMAND, + KEY_SPACE_COMMAND, + KEY_TAB_COMMAND, + MOVE_TO_END, + MOVE_TO_START, + OUTDENT_CONTENT_COMMAND, + PASTE_COMMAND, + REDO_COMMAND, + REMOVE_TEXT_COMMAND, + SELECT_ALL_COMMAND, + SELECTION_CHANGE_COMMAND, + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, + UNDO_COMMAND, +} from './LexicalCommands'; +export { + IS_ALL_FORMATTING, + IS_BOLD, + IS_CODE, + IS_HIGHLIGHT, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, + TEXT_TYPE_TO_FORMAT, +} from './LexicalConstants'; +export { + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_NORMAL, + createEditor, +} from './LexicalEditor'; +export type {EventHandler} from './LexicalEvents'; +export {$normalizeSelection as $normalizeSelection__EXPERIMENTAL} from './LexicalNormalization'; +export { + $createNodeSelection, + $createPoint, + $createRangeSelection, + $createRangeSelectionFromDom, + $getCharacterOffsets, + $getPreviousSelection, + $getSelection, + $getTextContent, + $insertNodes, + $isBlockElementNode, + $isNodeSelection, + $isRangeSelection, +} from './LexicalSelection'; +export {$parseSerializedNode, isCurrentlyReadOnlyMode} from './LexicalUpdates'; +export { + $addUpdateTag, + $applyNodeReplacement, + $cloneWithProperties, + $copyNode, + $getAdjacentNode, + $getEditor, + $getNearestNodeFromDOMNode, + $getNearestRootOrShadowRoot, + $getNodeByKey, + $getNodeByKeyOrThrow, + $getRoot, + $hasAncestor, + $hasUpdateTag, + $isInlineElementOrDecoratorNode, + $isLeafNode, + $isRootOrShadowRoot, + $isTokenOrSegmented, + $nodesOfType, + $selectAll, + $setCompositionKey, + $setSelection, + $splitNode, + getEditorPropertyFromDOMNode, + getNearestEditorFromDOMNode, + isBlockDomNode, + isHTMLAnchorElement, + isHTMLElement, + isInlineDomNode, + isLexicalEditor, + isSelectionCapturedInDecoratorInput, + isSelectionWithinEditor, + resetRandomKey, +} from './LexicalUtils'; +export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; +export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; +export {$isElementNode, ElementNode} from './nodes/LexicalElementNode'; +export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode'; +export { + $createLineBreakNode, + $isLineBreakNode, + LineBreakNode, +} from './nodes/LexicalLineBreakNode'; +export type {SerializedParagraphNode} from './nodes/LexicalParagraphNode'; +export { + $createParagraphNode, + $isParagraphNode, + ParagraphNode, +} from './nodes/LexicalParagraphNode'; +export {$isRootNode, RootNode} from './nodes/LexicalRootNode'; +export type {SerializedTabNode} from './nodes/LexicalTabNode'; +export {$createTabNode, $isTabNode, TabNode} from './nodes/LexicalTabNode'; +export {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode'; diff --git a/resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts b/resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts new file mode 100644 index 000000000..0f01d2c34 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/ArtificialNode.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {EditorConfig} from 'lexical'; + +import {ElementNode} from './LexicalElementNode'; + +// TODO: Cleanup ArtificialNode__DO_NOT_USE #5966 +export class ArtificialNode__DO_NOT_USE extends ElementNode { + static getType(): string { + return 'artificial'; + } + + createDOM(config: EditorConfig): HTMLElement { + // this isnt supposed to be used and is not used anywhere but defining it to appease the API + const dom = document.createElement('div'); + return dom; + } +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts new file mode 100644 index 000000000..99d2669d9 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalDecoratorNode.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {KlassConstructor, LexicalEditor} from '../LexicalEditor'; +import type {NodeKey} from '../LexicalNode'; +import type {ElementNode} from './LexicalElementNode'; + +import {EditorConfig} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {LexicalNode} from '../LexicalNode'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface DecoratorNode { + getTopLevelElement(): ElementNode | this | null; + getTopLevelElementOrThrow(): ElementNode | this; +} + +/** @noInheritDoc */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class DecoratorNode extends LexicalNode { + ['constructor']!: KlassConstructor>; + constructor(key?: NodeKey) { + super(key); + } + + /** + * The returned value is added to the LexicalEditor._decorators + */ + decorate(editor: LexicalEditor, config: EditorConfig): T { + invariant(false, 'decorate: base method not extended'); + } + + isIsolated(): boolean { + return false; + } + + isInline(): boolean { + return true; + } + + isKeyboardSelectable(): boolean { + return true; + } +} + +export function $isDecoratorNode( + node: LexicalNode | null | undefined, +): node is DecoratorNode { + return node instanceof DecoratorNode; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts new file mode 100644 index 000000000..88c6d5678 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -0,0 +1,635 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {NodeKey, SerializedLexicalNode} from '../LexicalNode'; +import type { + BaseSelection, + PointType, + RangeSelection, +} from '../LexicalSelection'; +import type {KlassConstructor, Spread} from 'lexical'; + +import invariant from 'lexical/shared/invariant'; + +import {$isTextNode, TextNode} from '../index'; +import { + DOUBLE_LINE_BREAK, + ELEMENT_FORMAT_TO_TYPE, + ELEMENT_TYPE_TO_FORMAT, +} from '../LexicalConstants'; +import {LexicalNode} from '../LexicalNode'; +import { + $getSelection, + $internalMakeRangeSelection, + $isRangeSelection, + moveSelectionPointToSibling, +} from '../LexicalSelection'; +import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; +import { + $getNodeByKey, + $isRootOrShadowRoot, + removeFromParent, +} from '../LexicalUtils'; + +export type SerializedElementNode< + T extends SerializedLexicalNode = SerializedLexicalNode, +> = Spread< + { + children: Array; + direction: 'ltr' | 'rtl' | null; + format: ElementFormatType; + indent: number; + }, + SerializedLexicalNode +>; + +export type ElementFormatType = + | 'left' + | 'start' + | 'center' + | 'right' + | 'end' + | 'justify' + | ''; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface ElementNode { + getTopLevelElement(): ElementNode | null; + getTopLevelElementOrThrow(): ElementNode; +} + +/** @noInheritDoc */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class ElementNode extends LexicalNode { + ['constructor']!: KlassConstructor; + /** @internal */ + __first: null | NodeKey; + /** @internal */ + __last: null | NodeKey; + /** @internal */ + __size: number; + /** @internal */ + __format: number; + /** @internal */ + __style: string; + /** @internal */ + __indent: number; + /** @internal */ + __dir: 'ltr' | 'rtl' | null; + + constructor(key?: NodeKey) { + super(key); + this.__first = null; + this.__last = null; + this.__size = 0; + this.__format = 0; + this.__style = ''; + this.__indent = 0; + this.__dir = null; + } + + afterCloneFrom(prevNode: this) { + super.afterCloneFrom(prevNode); + this.__first = prevNode.__first; + this.__last = prevNode.__last; + this.__size = prevNode.__size; + this.__indent = prevNode.__indent; + this.__format = prevNode.__format; + this.__style = prevNode.__style; + this.__dir = prevNode.__dir; + } + + getFormat(): number { + const self = this.getLatest(); + return self.__format; + } + getFormatType(): ElementFormatType { + const format = this.getFormat(); + return ELEMENT_FORMAT_TO_TYPE[format] || ''; + } + getStyle(): string { + const self = this.getLatest(); + return self.__style; + } + getIndent(): number { + const self = this.getLatest(); + return self.__indent; + } + getChildren(): Array { + const children: Array = []; + let child: T | null = this.getFirstChild(); + while (child !== null) { + children.push(child); + child = child.getNextSibling(); + } + return children; + } + getChildrenKeys(): Array { + const children: Array = []; + let child: LexicalNode | null = this.getFirstChild(); + while (child !== null) { + children.push(child.__key); + child = child.getNextSibling(); + } + return children; + } + getChildrenSize(): number { + const self = this.getLatest(); + return self.__size; + } + isEmpty(): boolean { + return this.getChildrenSize() === 0; + } + isDirty(): boolean { + const editor = getActiveEditor(); + const dirtyElements = editor._dirtyElements; + return dirtyElements !== null && dirtyElements.has(this.__key); + } + isLastChild(): boolean { + const self = this.getLatest(); + const parentLastChild = this.getParentOrThrow().getLastChild(); + return parentLastChild !== null && parentLastChild.is(self); + } + getAllTextNodes(): Array { + const textNodes = []; + let child: LexicalNode | null = this.getFirstChild(); + while (child !== null) { + if ($isTextNode(child)) { + textNodes.push(child); + } + if ($isElementNode(child)) { + const subChildrenNodes = child.getAllTextNodes(); + textNodes.push(...subChildrenNodes); + } + child = child.getNextSibling(); + } + return textNodes; + } + getFirstDescendant(): null | T { + let node = this.getFirstChild(); + while ($isElementNode(node)) { + const child = node.getFirstChild(); + if (child === null) { + break; + } + node = child; + } + return node; + } + getLastDescendant(): null | T { + let node = this.getLastChild(); + while ($isElementNode(node)) { + const child = node.getLastChild(); + if (child === null) { + break; + } + node = child; + } + return node; + } + getDescendantByIndex(index: number): null | T { + const children = this.getChildren(); + const childrenLength = children.length; + // For non-empty element nodes, we resolve its descendant + // (either a leaf node or the bottom-most element) + if (index >= childrenLength) { + const resolvedNode = children[childrenLength - 1]; + return ( + ($isElementNode(resolvedNode) && resolvedNode.getLastDescendant()) || + resolvedNode || + null + ); + } + const resolvedNode = children[index]; + return ( + ($isElementNode(resolvedNode) && resolvedNode.getFirstDescendant()) || + resolvedNode || + null + ); + } + getFirstChild(): null | T { + const self = this.getLatest(); + const firstKey = self.__first; + return firstKey === null ? null : $getNodeByKey(firstKey); + } + getFirstChildOrThrow(): T { + const firstChild = this.getFirstChild(); + if (firstChild === null) { + invariant(false, 'Expected node %s to have a first child.', this.__key); + } + return firstChild; + } + getLastChild(): null | T { + const self = this.getLatest(); + const lastKey = self.__last; + return lastKey === null ? null : $getNodeByKey(lastKey); + } + getLastChildOrThrow(): T { + const lastChild = this.getLastChild(); + if (lastChild === null) { + invariant(false, 'Expected node %s to have a last child.', this.__key); + } + return lastChild; + } + getChildAtIndex(index: number): null | T { + const size = this.getChildrenSize(); + let node: null | T; + let i; + if (index < size / 2) { + node = this.getFirstChild(); + i = 0; + while (node !== null && i <= index) { + if (i === index) { + return node; + } + node = node.getNextSibling(); + i++; + } + return null; + } + node = this.getLastChild(); + i = size - 1; + while (node !== null && i >= index) { + if (i === index) { + return node; + } + node = node.getPreviousSibling(); + i--; + } + return null; + } + getTextContent(): string { + let textContent = ''; + const children = this.getChildren(); + const childrenLength = children.length; + for (let i = 0; i < childrenLength; i++) { + const child = children[i]; + textContent += child.getTextContent(); + if ( + $isElementNode(child) && + i !== childrenLength - 1 && + !child.isInline() + ) { + textContent += DOUBLE_LINE_BREAK; + } + } + return textContent; + } + getTextContentSize(): number { + let textContentSize = 0; + const children = this.getChildren(); + const childrenLength = children.length; + for (let i = 0; i < childrenLength; i++) { + const child = children[i]; + textContentSize += child.getTextContentSize(); + if ( + $isElementNode(child) && + i !== childrenLength - 1 && + !child.isInline() + ) { + textContentSize += DOUBLE_LINE_BREAK.length; + } + } + return textContentSize; + } + getDirection(): 'ltr' | 'rtl' | null { + const self = this.getLatest(); + return self.__dir; + } + hasFormat(type: ElementFormatType): boolean { + if (type !== '') { + const formatFlag = ELEMENT_TYPE_TO_FORMAT[type]; + return (this.getFormat() & formatFlag) !== 0; + } + return false; + } + + // Mutators + + select(_anchorOffset?: number, _focusOffset?: number): RangeSelection { + errorOnReadOnly(); + const selection = $getSelection(); + let anchorOffset = _anchorOffset; + let focusOffset = _focusOffset; + const childrenCount = this.getChildrenSize(); + if (!this.canBeEmpty()) { + if (_anchorOffset === 0 && _focusOffset === 0) { + const firstChild = this.getFirstChild(); + if ($isTextNode(firstChild) || $isElementNode(firstChild)) { + return firstChild.select(0, 0); + } + } else if ( + (_anchorOffset === undefined || _anchorOffset === childrenCount) && + (_focusOffset === undefined || _focusOffset === childrenCount) + ) { + const lastChild = this.getLastChild(); + if ($isTextNode(lastChild) || $isElementNode(lastChild)) { + return lastChild.select(); + } + } + } + if (anchorOffset === undefined) { + anchorOffset = childrenCount; + } + if (focusOffset === undefined) { + focusOffset = childrenCount; + } + const key = this.__key; + if (!$isRangeSelection(selection)) { + return $internalMakeRangeSelection( + key, + anchorOffset, + key, + focusOffset, + 'element', + 'element', + ); + } else { + selection.anchor.set(key, anchorOffset, 'element'); + selection.focus.set(key, focusOffset, 'element'); + selection.dirty = true; + } + return selection; + } + selectStart(): RangeSelection { + const firstNode = this.getFirstDescendant(); + return firstNode ? firstNode.selectStart() : this.select(); + } + selectEnd(): RangeSelection { + const lastNode = this.getLastDescendant(); + return lastNode ? lastNode.selectEnd() : this.select(); + } + clear(): this { + const writableSelf = this.getWritable(); + const children = this.getChildren(); + children.forEach((child) => child.remove()); + return writableSelf; + } + append(...nodesToAppend: LexicalNode[]): this { + return this.splice(this.getChildrenSize(), 0, nodesToAppend); + } + setDirection(direction: 'ltr' | 'rtl' | null): this { + const self = this.getWritable(); + self.__dir = direction; + return self; + } + setFormat(type: ElementFormatType): this { + const self = this.getWritable(); + self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] : 0; + return this; + } + setStyle(style: string): this { + const self = this.getWritable(); + self.__style = style || ''; + return this; + } + setIndent(indentLevel: number): this { + const self = this.getWritable(); + self.__indent = indentLevel; + return this; + } + splice( + start: number, + deleteCount: number, + nodesToInsert: Array, + ): this { + const nodesToInsertLength = nodesToInsert.length; + const oldSize = this.getChildrenSize(); + const writableSelf = this.getWritable(); + const writableSelfKey = writableSelf.__key; + const nodesToInsertKeys = []; + const nodesToRemoveKeys = []; + const nodeAfterRange = this.getChildAtIndex(start + deleteCount); + let nodeBeforeRange = null; + let newSize = oldSize - deleteCount + nodesToInsertLength; + + if (start !== 0) { + if (start === oldSize) { + nodeBeforeRange = this.getLastChild(); + } else { + const node = this.getChildAtIndex(start); + if (node !== null) { + nodeBeforeRange = node.getPreviousSibling(); + } + } + } + + if (deleteCount > 0) { + let nodeToDelete = + nodeBeforeRange === null + ? this.getFirstChild() + : nodeBeforeRange.getNextSibling(); + for (let i = 0; i < deleteCount; i++) { + if (nodeToDelete === null) { + invariant(false, 'splice: sibling not found'); + } + const nextSibling = nodeToDelete.getNextSibling(); + const nodeKeyToDelete = nodeToDelete.__key; + const writableNodeToDelete = nodeToDelete.getWritable(); + removeFromParent(writableNodeToDelete); + nodesToRemoveKeys.push(nodeKeyToDelete); + nodeToDelete = nextSibling; + } + } + + let prevNode = nodeBeforeRange; + for (let i = 0; i < nodesToInsertLength; i++) { + const nodeToInsert = nodesToInsert[i]; + if (prevNode !== null && nodeToInsert.is(prevNode)) { + nodeBeforeRange = prevNode = prevNode.getPreviousSibling(); + } + const writableNodeToInsert = nodeToInsert.getWritable(); + if (writableNodeToInsert.__parent === writableSelfKey) { + newSize--; + } + removeFromParent(writableNodeToInsert); + const nodeKeyToInsert = nodeToInsert.__key; + if (prevNode === null) { + writableSelf.__first = nodeKeyToInsert; + writableNodeToInsert.__prev = null; + } else { + const writablePrevNode = prevNode.getWritable(); + writablePrevNode.__next = nodeKeyToInsert; + writableNodeToInsert.__prev = writablePrevNode.__key; + } + if (nodeToInsert.__key === writableSelfKey) { + invariant(false, 'append: attempting to append self'); + } + // Set child parent to self + writableNodeToInsert.__parent = writableSelfKey; + nodesToInsertKeys.push(nodeKeyToInsert); + prevNode = nodeToInsert; + } + + if (start + deleteCount === oldSize) { + if (prevNode !== null) { + const writablePrevNode = prevNode.getWritable(); + writablePrevNode.__next = null; + writableSelf.__last = prevNode.__key; + } + } else if (nodeAfterRange !== null) { + const writableNodeAfterRange = nodeAfterRange.getWritable(); + if (prevNode !== null) { + const writablePrevNode = prevNode.getWritable(); + writableNodeAfterRange.__prev = prevNode.__key; + writablePrevNode.__next = nodeAfterRange.__key; + } else { + writableNodeAfterRange.__prev = null; + } + } + + writableSelf.__size = newSize; + + // In case of deletion we need to adjust selection, unlink removed nodes + // and clean up node itself if it becomes empty. None of these needed + // for insertion-only cases + if (nodesToRemoveKeys.length) { + // Adjusting selection, in case node that was anchor/focus will be deleted + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const nodesToRemoveKeySet = new Set(nodesToRemoveKeys); + const nodesToInsertKeySet = new Set(nodesToInsertKeys); + + const {anchor, focus} = selection; + if (isPointRemoved(anchor, nodesToRemoveKeySet, nodesToInsertKeySet)) { + moveSelectionPointToSibling( + anchor, + anchor.getNode(), + this, + nodeBeforeRange, + nodeAfterRange, + ); + } + if (isPointRemoved(focus, nodesToRemoveKeySet, nodesToInsertKeySet)) { + moveSelectionPointToSibling( + focus, + focus.getNode(), + this, + nodeBeforeRange, + nodeAfterRange, + ); + } + // Cleanup if node can't be empty + if (newSize === 0 && !this.canBeEmpty() && !$isRootOrShadowRoot(this)) { + this.remove(); + } + } + } + + return writableSelf; + } + // JSON serialization + exportJSON(): SerializedElementNode { + return { + children: [], + direction: this.getDirection(), + format: this.getFormatType(), + indent: this.getIndent(), + type: 'element', + version: 1, + }; + } + // These are intended to be extends for specific element heuristics. + insertNewAfter( + selection: RangeSelection, + restoreSelection?: boolean, + ): null | LexicalNode { + return null; + } + canIndent(): boolean { + return true; + } + /* + * This method controls the behavior of a the node during backwards + * deletion (i.e., backspace) when selection is at the beginning of + * the node (offset 0) + */ + collapseAtStart(selection: RangeSelection): boolean { + return false; + } + excludeFromCopy(destination?: 'clone' | 'html'): boolean { + return false; + } + /** @deprecated @internal */ + canReplaceWith(replacement: LexicalNode): boolean { + return true; + } + /** @deprecated @internal */ + canInsertAfter(node: LexicalNode): boolean { + return true; + } + canBeEmpty(): boolean { + return true; + } + canInsertTextBefore(): boolean { + return true; + } + canInsertTextAfter(): boolean { + return true; + } + isInline(): boolean { + return false; + } + // A shadow root is a Node that behaves like RootNode. The shadow root (and RootNode) mark the + // end of the hiercharchy, most implementations should treat it as there's nothing (upwards) + // beyond this point. For example, node.getTopLevelElement(), when performed inside a TableCellNode + // will return the immediate first child underneath TableCellNode instead of RootNode. + isShadowRoot(): boolean { + return false; + } + /** @deprecated @internal */ + canMergeWith(node: ElementNode): boolean { + return false; + } + extractWithChild( + child: LexicalNode, + selection: BaseSelection | null, + destination: 'clone' | 'html', + ): boolean { + return false; + } + + /** + * Determines whether this node, when empty, can merge with a first block + * of nodes being inserted. + * + * This method is specifically called in {@link RangeSelection.insertNodes} + * to determine merging behavior during nodes insertion. + * + * @example + * // In a ListItemNode or QuoteNode implementation: + * canMergeWhenEmpty(): true { + * return true; + * } + */ + canMergeWhenEmpty(): boolean { + return false; + } +} + +export function $isElementNode( + node: LexicalNode | null | undefined, +): node is ElementNode { + return node instanceof ElementNode; +} + +function isPointRemoved( + point: PointType, + nodesToRemoveKeySet: Set, + nodesToInsertKeySet: Set, +): boolean { + let node: ElementNode | TextNode | null = point.getNode(); + while (node) { + const nodeKey = node.__key; + if (nodesToRemoveKeySet.has(nodeKey) && !nodesToInsertKeySet.has(nodeKey)) { + return true; + } + node = node.getParent(); + } + return false; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts new file mode 100644 index 000000000..2d28db08c --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalLineBreakNode.ts @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {KlassConstructor} from '../LexicalEditor'; +import type { + DOMConversionMap, + DOMConversionOutput, + NodeKey, + SerializedLexicalNode, +} from '../LexicalNode'; + +import {DOM_TEXT_TYPE} from '../LexicalConstants'; +import {LexicalNode} from '../LexicalNode'; +import {$applyNodeReplacement, isBlockDomNode} from '../LexicalUtils'; + +export type SerializedLineBreakNode = SerializedLexicalNode; + +/** @noInheritDoc */ +export class LineBreakNode extends LexicalNode { + ['constructor']!: KlassConstructor; + static getType(): string { + return 'linebreak'; + } + + static clone(node: LineBreakNode): LineBreakNode { + return new LineBreakNode(node.__key); + } + + constructor(key?: NodeKey) { + super(key); + } + + getTextContent(): '\n' { + return '\n'; + } + + createDOM(): HTMLElement { + return document.createElement('br'); + } + + updateDOM(): false { + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + br: (node: Node) => { + if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) { + return null; + } + return { + conversion: $convertLineBreakElement, + priority: 0, + }; + }, + }; + } + + static importJSON( + serializedLineBreakNode: SerializedLineBreakNode, + ): LineBreakNode { + return $createLineBreakNode(); + } + + exportJSON(): SerializedLexicalNode { + return { + type: 'linebreak', + version: 1, + }; + } +} + +function $convertLineBreakElement(node: Node): DOMConversionOutput { + return {node: $createLineBreakNode()}; +} + +export function $createLineBreakNode(): LineBreakNode { + return $applyNodeReplacement(new LineBreakNode()); +} + +export function $isLineBreakNode( + node: LexicalNode | null | undefined, +): node is LineBreakNode { + return node instanceof LineBreakNode; +} + +function isOnlyChildInBlockNode(node: Node): boolean { + const parentElement = node.parentElement; + if (parentElement !== null && isBlockDomNode(parentElement)) { + const firstChild = parentElement.firstChild!; + if ( + firstChild === node || + (firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild)) + ) { + const lastChild = parentElement.lastChild!; + if ( + lastChild === node || + (lastChild.previousSibling === node && + isWhitespaceDomTextNode(lastChild)) + ) { + return true; + } + } + } + return false; +} + +function isLastChildInBlockNode(node: Node): boolean { + const parentElement = node.parentElement; + if (parentElement !== null && isBlockDomNode(parentElement)) { + // check if node is first child, because only childs dont count + const firstChild = parentElement.firstChild!; + if ( + firstChild === node || + (firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild)) + ) { + return false; + } + + // check if its last child + const lastChild = parentElement.lastChild!; + if ( + lastChild === node || + (lastChild.previousSibling === node && isWhitespaceDomTextNode(lastChild)) + ) { + return true; + } + } + return false; +} + +function isWhitespaceDomTextNode(node: Node): boolean { + return ( + node.nodeType === DOM_TEXT_TYPE && + /^( |\t|\r?\n)+$/.test(node.textContent || '') + ); +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts new file mode 100644 index 000000000..deab3a2cc --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -0,0 +1,236 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + EditorConfig, + KlassConstructor, + LexicalEditor, + Spread, +} from '../LexicalEditor'; +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + LexicalNode, + NodeKey, +} from '../LexicalNode'; +import type { + ElementFormatType, + SerializedElementNode, +} from './LexicalElementNode'; +import type {RangeSelection} from 'lexical'; + +import {TEXT_TYPE_TO_FORMAT} from '../LexicalConstants'; +import { + $applyNodeReplacement, + getCachedClassNameArray, + isHTMLElement, +} from '../LexicalUtils'; +import {ElementNode} from './LexicalElementNode'; +import {$isTextNode, TextFormatType} from './LexicalTextNode'; + +export type SerializedParagraphNode = Spread< + { + textFormat: number; + textStyle: string; + }, + SerializedElementNode +>; + +/** @noInheritDoc */ +export class ParagraphNode extends ElementNode { + ['constructor']!: KlassConstructor; + /** @internal */ + __textFormat: number; + __textStyle: string; + + constructor(key?: NodeKey) { + super(key); + this.__textFormat = 0; + this.__textStyle = ''; + } + + static getType(): string { + return 'paragraph'; + } + + getTextFormat(): number { + const self = this.getLatest(); + return self.__textFormat; + } + + setTextFormat(type: number): this { + const self = this.getWritable(); + self.__textFormat = type; + return self; + } + + hasTextFormat(type: TextFormatType): boolean { + const formatFlag = TEXT_TYPE_TO_FORMAT[type]; + return (this.getTextFormat() & formatFlag) !== 0; + } + + getTextStyle(): string { + const self = this.getLatest(); + return self.__textStyle; + } + + setTextStyle(style: string): this { + const self = this.getWritable(); + self.__textStyle = style; + return self; + } + + static clone(node: ParagraphNode): ParagraphNode { + return new ParagraphNode(node.__key); + } + + afterCloneFrom(prevNode: this) { + super.afterCloneFrom(prevNode); + this.__textFormat = prevNode.__textFormat; + this.__textStyle = prevNode.__textStyle; + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const dom = document.createElement('p'); + const classNames = getCachedClassNameArray(config.theme, 'paragraph'); + if (classNames !== undefined) { + const domClassList = dom.classList; + domClassList.add(...classNames); + } + return dom; + } + updateDOM( + prevNode: ParagraphNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + p: (node: Node) => ({ + conversion: $convertParagraphElement, + priority: 0, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element && isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + + const formatType = this.getFormatType(); + element.style.textAlign = formatType; + + const direction = this.getDirection(); + if (direction) { + element.dir = direction; + } + const indent = this.getIndent(); + if (indent > 0) { + // padding-inline-start is not widely supported in email HTML, but + // Lexical Reconciler uses padding-inline-start. Using text-indent instead. + element.style.textIndent = `${indent * 20}px`; + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode { + const node = $createParagraphNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + node.setTextFormat(serializedNode.textFormat); + return node; + } + + exportJSON(): SerializedParagraphNode { + return { + ...super.exportJSON(), + textFormat: this.getTextFormat(), + textStyle: this.getTextStyle(), + type: 'paragraph', + version: 1, + }; + } + + // Mutation + + insertNewAfter( + rangeSelection: RangeSelection, + restoreSelection: boolean, + ): ParagraphNode { + const newElement = $createParagraphNode(); + newElement.setTextFormat(rangeSelection.format); + newElement.setTextStyle(rangeSelection.style); + const direction = this.getDirection(); + newElement.setDirection(direction); + newElement.setFormat(this.getFormatType()); + newElement.setStyle(this.getTextStyle()); + this.insertAfter(newElement, restoreSelection); + return newElement; + } + + collapseAtStart(): boolean { + const children = this.getChildren(); + // If we have an empty (trimmed) first paragraph and try and remove it, + // delete the paragraph as long as we have another sibling to go to + if ( + children.length === 0 || + ($isTextNode(children[0]) && children[0].getTextContent().trim() === '') + ) { + const nextSibling = this.getNextSibling(); + if (nextSibling !== null) { + this.selectNext(); + this.remove(); + return true; + } + const prevSibling = this.getPreviousSibling(); + if (prevSibling !== null) { + this.selectPrevious(); + this.remove(); + return true; + } + } + return false; + } +} + +function $convertParagraphElement(element: HTMLElement): DOMConversionOutput { + const node = $createParagraphNode(); + if (element.style) { + node.setFormat(element.style.textAlign as ElementFormatType); + const indent = parseInt(element.style.textIndent, 10) / 20; + if (indent > 0) { + node.setIndent(indent); + } + } + return {node}; +} + +export function $createParagraphNode(): ParagraphNode { + return $applyNodeReplacement(new ParagraphNode()); +} + +export function $isParagraphNode( + node: LexicalNode | null | undefined, +): node is ParagraphNode { + return node instanceof ParagraphNode; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts new file mode 100644 index 000000000..74c8d5a7f --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts @@ -0,0 +1,132 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalNode, SerializedLexicalNode} from '../LexicalNode'; +import type {SerializedElementNode} from './LexicalElementNode'; + +import invariant from 'lexical/shared/invariant'; + +import {NO_DIRTY_NODES} from '../LexicalConstants'; +import {getActiveEditor, isCurrentlyReadOnlyMode} from '../LexicalUpdates'; +import {$getRoot} from '../LexicalUtils'; +import {$isDecoratorNode} from './LexicalDecoratorNode'; +import {$isElementNode, ElementNode} from './LexicalElementNode'; + +export type SerializedRootNode< + T extends SerializedLexicalNode = SerializedLexicalNode, +> = SerializedElementNode; + +/** @noInheritDoc */ +export class RootNode extends ElementNode { + /** @internal */ + __cachedText: null | string; + + static getType(): string { + return 'root'; + } + + static clone(): RootNode { + return new RootNode(); + } + + constructor() { + super('root'); + this.__cachedText = null; + } + + getTopLevelElementOrThrow(): never { + invariant( + false, + 'getTopLevelElementOrThrow: root nodes are not top level elements', + ); + } + + getTextContent(): string { + const cachedText = this.__cachedText; + if ( + isCurrentlyReadOnlyMode() || + getActiveEditor()._dirtyType === NO_DIRTY_NODES + ) { + if (cachedText !== null) { + return cachedText; + } + } + return super.getTextContent(); + } + + remove(): never { + invariant(false, 'remove: cannot be called on root nodes'); + } + + replace(node: N): never { + invariant(false, 'replace: cannot be called on root nodes'); + } + + insertBefore(nodeToInsert: LexicalNode): LexicalNode { + invariant(false, 'insertBefore: cannot be called on root nodes'); + } + + insertAfter(nodeToInsert: LexicalNode): LexicalNode { + invariant(false, 'insertAfter: cannot be called on root nodes'); + } + + // View + + updateDOM(prevNode: RootNode, dom: HTMLElement): false { + return false; + } + + // Mutate + + append(...nodesToAppend: LexicalNode[]): this { + for (let i = 0; i < nodesToAppend.length; i++) { + const node = nodesToAppend[i]; + if (!$isElementNode(node) && !$isDecoratorNode(node)) { + invariant( + false, + 'rootNode.append: Only element or decorator nodes can be appended to the root node', + ); + } + } + return super.append(...nodesToAppend); + } + + static importJSON(serializedNode: SerializedRootNode): RootNode { + // We don't create a root, and instead use the existing root. + const node = $getRoot(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedRootNode { + return { + children: [], + direction: this.getDirection(), + format: this.getFormatType(), + indent: this.getIndent(), + type: 'root', + version: 1, + }; + } + + collapseAtStart(): true { + return true; + } +} + +export function $createRootNode(): RootNode { + return new RootNode(); +} + +export function $isRootNode( + node: RootNode | LexicalNode | null | undefined, +): node is RootNode { + return node instanceof RootNode; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts new file mode 100644 index 000000000..5fa3623d4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTabNode.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {DOMConversionMap, NodeKey} from '../LexicalNode'; + +import invariant from 'lexical/shared/invariant'; + +import {IS_UNMERGEABLE} from '../LexicalConstants'; +import {LexicalNode} from '../LexicalNode'; +import {$applyNodeReplacement} from '../LexicalUtils'; +import { + SerializedTextNode, + TextDetailType, + TextModeType, + TextNode, +} from './LexicalTextNode'; + +export type SerializedTabNode = SerializedTextNode; + +/** @noInheritDoc */ +export class TabNode extends TextNode { + static getType(): string { + return 'tab'; + } + + static clone(node: TabNode): TabNode { + return new TabNode(node.__key); + } + + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + // TabNode __text can be either '\t' or ''. insertText will remove the empty Node + this.__text = prevNode.__text; + } + + constructor(key?: NodeKey) { + super('\t', key); + this.__detail = IS_UNMERGEABLE; + } + + static importDOM(): DOMConversionMap | null { + return null; + } + + static importJSON(serializedTabNode: SerializedTabNode): TabNode { + const node = $createTabNode(); + node.setFormat(serializedTabNode.format); + node.setStyle(serializedTabNode.style); + return node; + } + + exportJSON(): SerializedTabNode { + return { + ...super.exportJSON(), + type: 'tab', + version: 1, + }; + } + + setTextContent(_text: string): this { + invariant(false, 'TabNode does not support setTextContent'); + } + + setDetail(_detail: TextDetailType | number): this { + invariant(false, 'TabNode does not support setDetail'); + } + + setMode(_type: TextModeType): this { + invariant(false, 'TabNode does not support setMode'); + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } +} + +export function $createTabNode(): TabNode { + return $applyNodeReplacement(new TabNode()); +} + +export function $isTabNode( + node: LexicalNode | null | undefined, +): node is TabNode { + return node instanceof TabNode; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts new file mode 100644 index 000000000..43bef7e83 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -0,0 +1,1364 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + EditorConfig, + KlassConstructor, + LexicalEditor, + Spread, + TextNodeThemeClasses, +} from '../LexicalEditor'; +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + NodeKey, + SerializedLexicalNode, +} from '../LexicalNode'; +import type {BaseSelection, RangeSelection} from '../LexicalSelection'; +import type {ElementNode} from './LexicalElementNode'; + +import {IS_FIREFOX} from 'lexical/shared/environment'; +import invariant from 'lexical/shared/invariant'; + +import { + COMPOSITION_SUFFIX, + DETAIL_TYPE_TO_DETAIL, + DOM_ELEMENT_TYPE, + DOM_TEXT_TYPE, + IS_BOLD, + IS_CODE, + IS_DIRECTIONLESS, + IS_HIGHLIGHT, + IS_ITALIC, + IS_SEGMENTED, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_TOKEN, + IS_UNDERLINE, + IS_UNMERGEABLE, + TEXT_MODE_TO_TYPE, + TEXT_TYPE_TO_FORMAT, + TEXT_TYPE_TO_MODE, +} from '../LexicalConstants'; +import {LexicalNode} from '../LexicalNode'; +import { + $getSelection, + $internalMakeRangeSelection, + $isRangeSelection, + $updateElementSelectionOnCreateDeleteNode, + adjustPointOffsetForMergedSibling, +} from '../LexicalSelection'; +import {errorOnReadOnly} from '../LexicalUpdates'; +import { + $applyNodeReplacement, + $getCompositionKey, + $setCompositionKey, + getCachedClassNameArray, + internalMarkSiblingsAsDirty, + isHTMLElement, + isInlineDomNode, + toggleTextFormatType, +} from '../LexicalUtils'; +import {$createLineBreakNode} from './LexicalLineBreakNode'; +import {$createTabNode} from './LexicalTabNode'; + +export type SerializedTextNode = Spread< + { + detail: number; + format: number; + mode: TextModeType; + style: string; + text: string; + }, + SerializedLexicalNode +>; + +export type TextDetailType = 'directionless' | 'unmergable'; + +export type TextFormatType = + | 'bold' + | 'underline' + | 'strikethrough' + | 'italic' + | 'highlight' + | 'code' + | 'subscript' + | 'superscript'; + +export type TextModeType = 'normal' | 'token' | 'segmented'; + +export type TextMark = {end: null | number; id: string; start: null | number}; + +export type TextMarks = Array; + +function getElementOuterTag(node: TextNode, format: number): string | null { + if (format & IS_CODE) { + return 'code'; + } + if (format & IS_HIGHLIGHT) { + return 'mark'; + } + if (format & IS_SUBSCRIPT) { + return 'sub'; + } + if (format & IS_SUPERSCRIPT) { + return 'sup'; + } + return null; +} + +function getElementInnerTag(node: TextNode, format: number): string { + if (format & IS_BOLD) { + return 'strong'; + } + if (format & IS_ITALIC) { + return 'em'; + } + return 'span'; +} + +function setTextThemeClassNames( + tag: string, + prevFormat: number, + nextFormat: number, + dom: HTMLElement, + textClassNames: TextNodeThemeClasses, +): void { + const domClassList = dom.classList; + // Firstly we handle the base theme. + let classNames = getCachedClassNameArray(textClassNames, 'base'); + if (classNames !== undefined) { + domClassList.add(...classNames); + } + // Secondly we handle the special case: underline + strikethrough. + // We have to do this as we need a way to compose the fact that + // the same CSS property will need to be used: text-decoration. + // In an ideal world we shouldn't have to do this, but there's no + // easy workaround for many atomic CSS systems today. + classNames = getCachedClassNameArray( + textClassNames, + 'underlineStrikethrough', + ); + let hasUnderlineStrikethrough = false; + const prevUnderlineStrikethrough = + prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH; + const nextUnderlineStrikethrough = + nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH; + + if (classNames !== undefined) { + if (nextUnderlineStrikethrough) { + hasUnderlineStrikethrough = true; + if (!prevUnderlineStrikethrough) { + domClassList.add(...classNames); + } + } else if (prevUnderlineStrikethrough) { + domClassList.remove(...classNames); + } + } + + for (const key in TEXT_TYPE_TO_FORMAT) { + const format = key; + const flag = TEXT_TYPE_TO_FORMAT[format]; + classNames = getCachedClassNameArray(textClassNames, key); + if (classNames !== undefined) { + if (nextFormat & flag) { + if ( + hasUnderlineStrikethrough && + (key === 'underline' || key === 'strikethrough') + ) { + if (prevFormat & flag) { + domClassList.remove(...classNames); + } + continue; + } + if ( + (prevFormat & flag) === 0 || + (prevUnderlineStrikethrough && key === 'underline') || + key === 'strikethrough' + ) { + domClassList.add(...classNames); + } + } else if (prevFormat & flag) { + domClassList.remove(...classNames); + } + } + } +} + +function diffComposedText(a: string, b: string): [number, number, string] { + const aLength = a.length; + const bLength = b.length; + let left = 0; + let right = 0; + + while (left < aLength && left < bLength && a[left] === b[left]) { + left++; + } + while ( + right + left < aLength && + right + left < bLength && + a[aLength - right - 1] === b[bLength - right - 1] + ) { + right++; + } + + return [left, aLength - left - right, b.slice(left, bLength - right)]; +} + +function setTextContent( + nextText: string, + dom: HTMLElement, + node: TextNode, +): void { + const firstChild = dom.firstChild; + const isComposing = node.isComposing(); + // Always add a suffix if we're composing a node + const suffix = isComposing ? COMPOSITION_SUFFIX : ''; + const text: string = nextText + suffix; + + if (firstChild == null) { + dom.textContent = text; + } else { + const nodeValue = firstChild.nodeValue; + if (nodeValue !== text) { + if (isComposing || IS_FIREFOX) { + // We also use the diff composed text for general text in FF to avoid + // the spellcheck red line from flickering. + const [index, remove, insert] = diffComposedText( + nodeValue as string, + text, + ); + if (remove !== 0) { + // @ts-expect-error + firstChild.deleteData(index, remove); + } + // @ts-expect-error + firstChild.insertData(index, insert); + } else { + firstChild.nodeValue = text; + } + } + } +} + +function createTextInnerDOM( + innerDOM: HTMLElement, + node: TextNode, + innerTag: string, + format: number, + text: string, + config: EditorConfig, +): void { + setTextContent(text, innerDOM, node); + const theme = config.theme; + // Apply theme class names + const textClassNames = theme.text; + + if (textClassNames !== undefined) { + setTextThemeClassNames(innerTag, 0, format, innerDOM, textClassNames); + } +} + +function wrapElementWith( + element: HTMLElement | Text, + tag: string, +): HTMLElement { + const el = document.createElement(tag); + el.appendChild(element); + return el; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface TextNode { + getTopLevelElement(): ElementNode | null; + getTopLevelElementOrThrow(): ElementNode; +} + +/** @noInheritDoc */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class TextNode extends LexicalNode { + ['constructor']!: KlassConstructor; + __text: string; + /** @internal */ + __format: number; + /** @internal */ + __style: string; + /** @internal */ + __mode: 0 | 1 | 2 | 3; + /** @internal */ + __detail: number; + + static getType(): string { + return 'text'; + } + + static clone(node: TextNode): TextNode { + return new TextNode(node.__text, node.__key); + } + + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + this.__format = prevNode.__format; + this.__style = prevNode.__style; + this.__mode = prevNode.__mode; + this.__detail = prevNode.__detail; + } + + constructor(text: string, key?: NodeKey) { + super(key); + this.__text = text; + this.__format = 0; + this.__style = ''; + this.__mode = 0; + this.__detail = 0; + } + + /** + * Returns a 32-bit integer that represents the TextFormatTypes currently applied to the + * TextNode. You probably don't want to use this method directly - consider using TextNode.hasFormat instead. + * + * @returns a number representing the format of the text node. + */ + getFormat(): number { + const self = this.getLatest(); + return self.__format; + } + + /** + * Returns a 32-bit integer that represents the TextDetailTypes currently applied to the + * TextNode. You probably don't want to use this method directly - consider using TextNode.isDirectionless + * or TextNode.isUnmergeable instead. + * + * @returns a number representing the detail of the text node. + */ + getDetail(): number { + const self = this.getLatest(); + return self.__detail; + } + + /** + * Returns the mode (TextModeType) of the TextNode, which may be "normal", "token", or "segmented" + * + * @returns TextModeType. + */ + getMode(): TextModeType { + const self = this.getLatest(); + return TEXT_TYPE_TO_MODE[self.__mode]; + } + + /** + * Returns the styles currently applied to the node. This is analogous to CSSText in the DOM. + * + * @returns CSSText-like string of styles applied to the underlying DOM node. + */ + getStyle(): string { + const self = this.getLatest(); + return self.__style; + } + + /** + * Returns whether or not the node is in "token" mode. TextNodes in token mode can be navigated through character-by-character + * with a RangeSelection, but are deleted as a single entity (not invdividually by character). + * + * @returns true if the node is in token mode, false otherwise. + */ + isToken(): boolean { + const self = this.getLatest(); + return self.__mode === IS_TOKEN; + } + + /** + * + * @returns true if Lexical detects that an IME or other 3rd-party script is attempting to + * mutate the TextNode, false otherwise. + */ + isComposing(): boolean { + return this.__key === $getCompositionKey(); + } + + /** + * Returns whether or not the node is in "segemented" mode. TextNodes in segemented mode can be navigated through character-by-character + * with a RangeSelection, but are deleted in space-delimited "segments". + * + * @returns true if the node is in segmented mode, false otherwise. + */ + isSegmented(): boolean { + const self = this.getLatest(); + return self.__mode === IS_SEGMENTED; + } + /** + * Returns whether or not the node is "directionless". Directionless nodes don't respect changes between RTL and LTR modes. + * + * @returns true if the node is directionless, false otherwise. + */ + isDirectionless(): boolean { + const self = this.getLatest(); + return (self.__detail & IS_DIRECTIONLESS) !== 0; + } + /** + * Returns whether or not the node is unmergeable. In some scenarios, Lexical tries to merge + * adjacent TextNodes into a single TextNode. If a TextNode is unmergeable, this won't happen. + * + * @returns true if the node is unmergeable, false otherwise. + */ + isUnmergeable(): boolean { + const self = this.getLatest(); + return (self.__detail & IS_UNMERGEABLE) !== 0; + } + + /** + * Returns whether or not the node has the provided format applied. Use this with the human-readable TextFormatType + * string values to get the format of a TextNode. + * + * @param type - the TextFormatType to check for. + * + * @returns true if the node has the provided format, false otherwise. + */ + hasFormat(type: TextFormatType): boolean { + const formatFlag = TEXT_TYPE_TO_FORMAT[type]; + return (this.getFormat() & formatFlag) !== 0; + } + + /** + * Returns whether or not the node is simple text. Simple text is defined as a TextNode that has the string type "text" + * (i.e., not a subclass) and has no mode applied to it (i.e., not segmented or token). + * + * @returns true if the node is simple text, false otherwise. + */ + isSimpleText(): boolean { + return this.__type === 'text' && this.__mode === 0; + } + + /** + * Returns the text content of the node as a string. + * + * @returns a string representing the text content of the node. + */ + getTextContent(): string { + const self = this.getLatest(); + return self.__text; + } + + /** + * Returns the format flags applied to the node as a 32-bit integer. + * + * @returns a number representing the TextFormatTypes applied to the node. + */ + getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number { + const self = this.getLatest(); + const format = self.__format; + return toggleTextFormatType(format, type, alignWithFormat); + } + + /** + * + * @returns true if the text node supports font styling, false otherwise. + */ + canHaveFormat(): boolean { + return true; + } + + // View + + createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement { + const format = this.__format; + const outerTag = getElementOuterTag(this, format); + const innerTag = getElementInnerTag(this, format); + const tag = outerTag === null ? innerTag : outerTag; + const dom = document.createElement(tag); + let innerDOM = dom; + if (this.hasFormat('code')) { + dom.setAttribute('spellcheck', 'false'); + } + if (outerTag !== null) { + innerDOM = document.createElement(innerTag); + dom.appendChild(innerDOM); + } + const text = this.__text; + createTextInnerDOM(innerDOM, this, innerTag, format, text, config); + const style = this.__style; + if (style !== '') { + dom.style.cssText = style; + } + return dom; + } + + updateDOM( + prevNode: TextNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + const nextText = this.__text; + const prevFormat = prevNode.__format; + const nextFormat = this.__format; + const prevOuterTag = getElementOuterTag(this, prevFormat); + const nextOuterTag = getElementOuterTag(this, nextFormat); + const prevInnerTag = getElementInnerTag(this, prevFormat); + const nextInnerTag = getElementInnerTag(this, nextFormat); + const prevTag = prevOuterTag === null ? prevInnerTag : prevOuterTag; + const nextTag = nextOuterTag === null ? nextInnerTag : nextOuterTag; + + if (prevTag !== nextTag) { + return true; + } + if (prevOuterTag === nextOuterTag && prevInnerTag !== nextInnerTag) { + // should always be an element + const prevInnerDOM: HTMLElement = dom.firstChild as HTMLElement; + if (prevInnerDOM == null) { + invariant(false, 'updateDOM: prevInnerDOM is null or undefined'); + } + const nextInnerDOM = document.createElement(nextInnerTag); + createTextInnerDOM( + nextInnerDOM, + this, + nextInnerTag, + nextFormat, + nextText, + config, + ); + dom.replaceChild(nextInnerDOM, prevInnerDOM); + return false; + } + let innerDOM = dom; + if (nextOuterTag !== null) { + if (prevOuterTag !== null) { + innerDOM = dom.firstChild as HTMLElement; + if (innerDOM == null) { + invariant(false, 'updateDOM: innerDOM is null or undefined'); + } + } + } + setTextContent(nextText, innerDOM, this); + const theme = config.theme; + // Apply theme class names + const textClassNames = theme.text; + + if (textClassNames !== undefined && prevFormat !== nextFormat) { + setTextThemeClassNames( + nextInnerTag, + prevFormat, + nextFormat, + innerDOM, + textClassNames, + ); + } + const prevStyle = prevNode.__style; + const nextStyle = this.__style; + if (prevStyle !== nextStyle) { + dom.style.cssText = nextStyle; + } + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + '#text': () => ({ + conversion: $convertTextDOMNode, + priority: 0, + }), + b: () => ({ + conversion: convertBringAttentionToElement, + priority: 0, + }), + code: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + em: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + i: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + s: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + span: () => ({ + conversion: convertSpanElement, + priority: 0, + }), + strong: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + sub: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + sup: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + u: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedTextNode): TextNode { + const node = $createTextNode(serializedNode.text); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; + } + + // This improves Lexical's basic text output in copy+paste plus + // for headless mode where people might use Lexical to generate + // HTML content and not have the ability to use CSS classes. + exportDOM(editor: LexicalEditor): DOMExportOutput { + let {element} = super.exportDOM(editor); + invariant( + element !== null && isHTMLElement(element), + 'Expected TextNode createDOM to always return a HTMLElement', + ); + element.style.whiteSpace = 'pre-wrap'; + // This is the only way to properly add support for most clients, + // even if it's semantically incorrect to have to resort to using + // , , , elements. + if (this.hasFormat('bold')) { + element = wrapElementWith(element, 'b'); + } + if (this.hasFormat('italic')) { + element = wrapElementWith(element, 'i'); + } + if (this.hasFormat('strikethrough')) { + element = wrapElementWith(element, 's'); + } + if (this.hasFormat('underline')) { + element = wrapElementWith(element, 'u'); + } + + return { + element, + }; + } + + exportJSON(): SerializedTextNode { + return { + detail: this.getDetail(), + format: this.getFormat(), + mode: this.getMode(), + style: this.getStyle(), + text: this.getTextContent(), + type: 'text', + version: 1, + }; + } + + // Mutators + selectionTransform( + prevSelection: null | BaseSelection, + nextSelection: RangeSelection, + ): void { + return; + } + + /** + * Sets the node format to the provided TextFormatType or 32-bit integer. Note that the TextFormatType + * version of the argument can only specify one format and doing so will remove all other formats that + * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleFormat} + * + * @param format - TextFormatType or 32-bit integer representing the node format. + * + * @returns this TextNode. + * // TODO 0.12 This should just be a `string`. + */ + setFormat(format: TextFormatType | number): this { + const self = this.getWritable(); + self.__format = + typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format; + return self; + } + + /** + * Sets the node detail to the provided TextDetailType or 32-bit integer. Note that the TextDetailType + * version of the argument can only specify one detail value and doing so will remove all other detail values that + * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleDirectionless} + * or {@link TextNode.toggleUnmergeable} + * + * @param detail - TextDetailType or 32-bit integer representing the node detail. + * + * @returns this TextNode. + * // TODO 0.12 This should just be a `string`. + */ + setDetail(detail: TextDetailType | number): this { + const self = this.getWritable(); + self.__detail = + typeof detail === 'string' ? DETAIL_TYPE_TO_DETAIL[detail] : detail; + return self; + } + + /** + * Sets the node style to the provided CSSText-like string. Set this property as you + * would an HTMLElement style attribute to apply inline styles to the underlying DOM Element. + * + * @param style - CSSText to be applied to the underlying HTMLElement. + * + * @returns this TextNode. + */ + setStyle(style: string): this { + const self = this.getWritable(); + self.__style = style; + return self; + } + + /** + * Applies the provided format to this TextNode if it's not present. Removes it if it's present. + * The subscript and superscript formats are mutually exclusive. + * Prefer using this method to turn specific formats on and off. + * + * @param type - TextFormatType to toggle. + * + * @returns this TextNode. + */ + toggleFormat(type: TextFormatType): this { + const format = this.getFormat(); + const newFormat = toggleTextFormatType(format, type, null); + return this.setFormat(newFormat); + } + + /** + * Toggles the directionless detail value of the node. Prefer using this method over setDetail. + * + * @returns this TextNode. + */ + toggleDirectionless(): this { + const self = this.getWritable(); + self.__detail ^= IS_DIRECTIONLESS; + return self; + } + + /** + * Toggles the unmergeable detail value of the node. Prefer using this method over setDetail. + * + * @returns this TextNode. + */ + toggleUnmergeable(): this { + const self = this.getWritable(); + self.__detail ^= IS_UNMERGEABLE; + return self; + } + + /** + * Sets the mode of the node. + * + * @returns this TextNode. + */ + setMode(type: TextModeType): this { + const mode = TEXT_MODE_TO_TYPE[type]; + if (this.__mode === mode) { + return this; + } + const self = this.getWritable(); + self.__mode = mode; + return self; + } + + /** + * Sets the text content of the node. + * + * @param text - the string to set as the text value of the node. + * + * @returns this TextNode. + */ + setTextContent(text: string): this { + if (this.__text === text) { + return this; + } + const self = this.getWritable(); + self.__text = text; + return self; + } + + /** + * Sets the current Lexical selection to be a RangeSelection with anchor and focus on this TextNode at the provided offsets. + * + * @param _anchorOffset - the offset at which the Selection anchor will be placed. + * @param _focusOffset - the offset at which the Selection focus will be placed. + * + * @returns the new RangeSelection. + */ + select(_anchorOffset?: number, _focusOffset?: number): RangeSelection { + errorOnReadOnly(); + let anchorOffset = _anchorOffset; + let focusOffset = _focusOffset; + const selection = $getSelection(); + const text = this.getTextContent(); + const key = this.__key; + if (typeof text === 'string') { + const lastOffset = text.length; + if (anchorOffset === undefined) { + anchorOffset = lastOffset; + } + if (focusOffset === undefined) { + focusOffset = lastOffset; + } + } else { + anchorOffset = 0; + focusOffset = 0; + } + if (!$isRangeSelection(selection)) { + return $internalMakeRangeSelection( + key, + anchorOffset, + key, + focusOffset, + 'text', + 'text', + ); + } else { + const compositionKey = $getCompositionKey(); + if ( + compositionKey === selection.anchor.key || + compositionKey === selection.focus.key + ) { + $setCompositionKey(key); + } + selection.setTextNodeRange(this, anchorOffset, this, focusOffset); + } + return selection; + } + + selectStart(): RangeSelection { + return this.select(0, 0); + } + + selectEnd(): RangeSelection { + const size = this.getTextContentSize(); + return this.select(size, size); + } + + /** + * Inserts the provided text into this TextNode at the provided offset, deleting the number of characters + * specified. Can optionally calculate a new selection after the operation is complete. + * + * @param offset - the offset at which the splice operation should begin. + * @param delCount - the number of characters to delete, starting from the offset. + * @param newText - the text to insert into the TextNode at the offset. + * @param moveSelection - optional, whether or not to move selection to the end of the inserted substring. + * + * @returns this TextNode. + */ + spliceText( + offset: number, + delCount: number, + newText: string, + moveSelection?: boolean, + ): TextNode { + const writableSelf = this.getWritable(); + const text = writableSelf.__text; + const handledTextLength = newText.length; + let index = offset; + if (index < 0) { + index = handledTextLength + index; + if (index < 0) { + index = 0; + } + } + const selection = $getSelection(); + if (moveSelection && $isRangeSelection(selection)) { + const newOffset = offset + handledTextLength; + selection.setTextNodeRange( + writableSelf, + newOffset, + writableSelf, + newOffset, + ); + } + + const updatedText = + text.slice(0, index) + newText + text.slice(index + delCount); + + writableSelf.__text = updatedText; + return writableSelf; + } + + /** + * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes + * when a user event would cause text to be inserted before them in the editor. If true, Lexical will attempt + * to insert text into this node. If false, it will insert the text in a new sibling node. + * + * @returns true if text can be inserted before the node, false otherwise. + */ + canInsertTextBefore(): boolean { + return true; + } + + /** + * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes + * when a user event would cause text to be inserted after them in the editor. If true, Lexical will attempt + * to insert text into this node. If false, it will insert the text in a new sibling node. + * + * @returns true if text can be inserted after the node, false otherwise. + */ + canInsertTextAfter(): boolean { + return true; + } + + /** + * Splits this TextNode at the provided character offsets, forming new TextNodes from the substrings + * formed by the split, and inserting those new TextNodes into the editor, replacing the one that was split. + * + * @param splitOffsets - rest param of the text content character offsets at which this node should be split. + * + * @returns an Array containing the newly-created TextNodes. + */ + splitText(...splitOffsets: Array): Array { + errorOnReadOnly(); + const self = this.getLatest(); + const textContent = self.getTextContent(); + const key = self.__key; + const compositionKey = $getCompositionKey(); + const offsetsSet = new Set(splitOffsets); + const parts = []; + const textLength = textContent.length; + let string = ''; + for (let i = 0; i < textLength; i++) { + if (string !== '' && offsetsSet.has(i)) { + parts.push(string); + string = ''; + } + string += textContent[i]; + } + if (string !== '') { + parts.push(string); + } + const partsLength = parts.length; + if (partsLength === 0) { + return []; + } else if (parts[0] === textContent) { + return [self]; + } + const firstPart = parts[0]; + const parent = self.getParent(); + let writableNode; + const format = self.getFormat(); + const style = self.getStyle(); + const detail = self.__detail; + let hasReplacedSelf = false; + + if (self.isSegmented()) { + // Create a new TextNode + writableNode = $createTextNode(firstPart); + writableNode.__format = format; + writableNode.__style = style; + writableNode.__detail = detail; + hasReplacedSelf = true; + } else { + // For the first part, update the existing node + writableNode = self.getWritable(); + writableNode.__text = firstPart; + } + + // Handle selection + const selection = $getSelection(); + + // Then handle all other parts + const splitNodes: TextNode[] = [writableNode]; + let textSize = firstPart.length; + + for (let i = 1; i < partsLength; i++) { + const part = parts[i]; + const partSize = part.length; + const sibling = $createTextNode(part).getWritable(); + sibling.__format = format; + sibling.__style = style; + sibling.__detail = detail; + const siblingKey = sibling.__key; + const nextTextSize = textSize + partSize; + + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const focus = selection.focus; + + if ( + anchor.key === key && + anchor.type === 'text' && + anchor.offset > textSize && + anchor.offset <= nextTextSize + ) { + anchor.key = siblingKey; + anchor.offset -= textSize; + selection.dirty = true; + } + if ( + focus.key === key && + focus.type === 'text' && + focus.offset > textSize && + focus.offset <= nextTextSize + ) { + focus.key = siblingKey; + focus.offset -= textSize; + selection.dirty = true; + } + } + if (compositionKey === key) { + $setCompositionKey(siblingKey); + } + textSize = nextTextSize; + splitNodes.push(sibling); + } + + // Insert the nodes into the parent's children + if (parent !== null) { + internalMarkSiblingsAsDirty(this); + const writableParent = parent.getWritable(); + const insertionIndex = this.getIndexWithinParent(); + if (hasReplacedSelf) { + writableParent.splice(insertionIndex, 0, splitNodes); + this.remove(); + } else { + writableParent.splice(insertionIndex, 1, splitNodes); + } + + if ($isRangeSelection(selection)) { + $updateElementSelectionOnCreateDeleteNode( + selection, + parent, + insertionIndex, + partsLength - 1, + ); + } + } + + return splitNodes; + } + + /** + * Merges the target TextNode into this TextNode, removing the target node. + * + * @param target - the TextNode to merge into this one. + * + * @returns this TextNode. + */ + mergeWithSibling(target: TextNode): TextNode { + const isBefore = target === this.getPreviousSibling(); + if (!isBefore && target !== this.getNextSibling()) { + invariant( + false, + 'mergeWithSibling: sibling must be a previous or next sibling', + ); + } + const key = this.__key; + const targetKey = target.__key; + const text = this.__text; + const textLength = text.length; + const compositionKey = $getCompositionKey(); + + if (compositionKey === targetKey) { + $setCompositionKey(key); + } + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const focus = selection.focus; + if (anchor !== null && anchor.key === targetKey) { + adjustPointOffsetForMergedSibling( + anchor, + isBefore, + key, + target, + textLength, + ); + selection.dirty = true; + } + if (focus !== null && focus.key === targetKey) { + adjustPointOffsetForMergedSibling( + focus, + isBefore, + key, + target, + textLength, + ); + selection.dirty = true; + } + } + const targetText = target.__text; + const newText = isBefore ? targetText + text : text + targetText; + this.setTextContent(newText); + const writableSelf = this.getWritable(); + target.remove(); + return writableSelf; + } + + /** + * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes + * when used with the registerLexicalTextEntity function. If you're using registerLexicalTextEntity, the + * node class that you create and replace matched text with should return true from this method. + * + * @returns true if the node is to be treated as a "text entity", false otherwise. + */ + isTextEntity(): boolean { + return false; + } +} + +function convertSpanElement(domNode: HTMLSpanElement): DOMConversionOutput { + // domNode is a since we matched it by nodeName + const span = domNode; + const style = span.style; + + return { + forChild: applyTextFormatFromStyle(style), + node: null, + }; +} + +function convertBringAttentionToElement( + domNode: HTMLElement, +): DOMConversionOutput { + // domNode is a since we matched it by nodeName + const b = domNode; + // Google Docs wraps all copied HTML in a with font-weight normal + const hasNormalFontWeight = b.style.fontWeight === 'normal'; + + return { + forChild: applyTextFormatFromStyle( + b.style, + hasNormalFontWeight ? undefined : 'bold', + ), + node: null, + }; +} + +const preParentCache = new WeakMap(); + +function isNodePre(node: Node): boolean { + return ( + node.nodeName === 'PRE' || + (node.nodeType === DOM_ELEMENT_TYPE && + (node as HTMLElement).style !== undefined && + (node as HTMLElement).style.whiteSpace !== undefined && + (node as HTMLElement).style.whiteSpace.startsWith('pre')) + ); +} + +export function findParentPreDOMNode(node: Node) { + let cached; + let parent = node.parentNode; + const visited = [node]; + while ( + parent !== null && + (cached = preParentCache.get(parent)) === undefined && + !isNodePre(parent) + ) { + visited.push(parent); + parent = parent.parentNode; + } + const resultNode = cached === undefined ? parent : cached; + for (let i = 0; i < visited.length; i++) { + preParentCache.set(visited[i], resultNode); + } + return resultNode; +} + +function $convertTextDOMNode(domNode: Node): DOMConversionOutput { + const domNode_ = domNode as Text; + const parentDom = domNode.parentElement; + invariant( + parentDom !== null, + 'Expected parentElement of Text not to be null', + ); + let textContent = domNode_.textContent || ''; + // No collapse and preserve segment break for pre, pre-wrap and pre-line + if (findParentPreDOMNode(domNode_) !== null) { + const parts = textContent.split(/(\r?\n|\t)/); + const nodes: Array = []; + const length = parts.length; + for (let i = 0; i < length; i++) { + const part = parts[i]; + if (part === '\n' || part === '\r\n') { + nodes.push($createLineBreakNode()); + } else if (part === '\t') { + nodes.push($createTabNode()); + } else if (part !== '') { + nodes.push($createTextNode(part)); + } + } + return {node: nodes}; + } + textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' '); + if (textContent === '') { + return {node: null}; + } + if (textContent[0] === ' ') { + // Traverse backward while in the same line. If content contains new line or tab -> pontential + // delete, other elements can borrow from this one. Deletion depends on whether it's also the + // last space (see next condition: textContent[textContent.length - 1] === ' ')) + let previousText: null | Text = domNode_; + let isStartOfLine = true; + while ( + previousText !== null && + (previousText = findTextInLine(previousText, false)) !== null + ) { + const previousTextContent = previousText.textContent || ''; + if (previousTextContent.length > 0) { + if (/[ \t\n]$/.test(previousTextContent)) { + textContent = textContent.slice(1); + } + isStartOfLine = false; + break; + } + } + if (isStartOfLine) { + textContent = textContent.slice(1); + } + } + if (textContent[textContent.length - 1] === ' ') { + // Traverse forward while in the same line, preserve if next inline will require a space + let nextText: null | Text = domNode_; + let isEndOfLine = true; + while ( + nextText !== null && + (nextText = findTextInLine(nextText, true)) !== null + ) { + const nextTextContent = (nextText.textContent || '').replace( + /^( |\t|\r?\n)+/, + '', + ); + if (nextTextContent.length > 0) { + isEndOfLine = false; + break; + } + } + if (isEndOfLine) { + textContent = textContent.slice(0, textContent.length - 1); + } + } + if (textContent === '') { + return {node: null}; + } + return {node: $createTextNode(textContent)}; +} + +function findTextInLine(text: Text, forward: boolean): null | Text { + let node: Node = text; + // eslint-disable-next-line no-constant-condition + while (true) { + let sibling: null | Node; + while ( + (sibling = forward ? node.nextSibling : node.previousSibling) === null + ) { + const parentElement = node.parentElement; + if (parentElement === null) { + return null; + } + node = parentElement; + } + node = sibling; + if (node.nodeType === DOM_ELEMENT_TYPE) { + const display = (node as HTMLElement).style.display; + if ( + (display === '' && !isInlineDomNode(node)) || + (display !== '' && !display.startsWith('inline')) + ) { + return null; + } + } + let descendant: null | Node = node; + while ((descendant = forward ? node.firstChild : node.lastChild) !== null) { + node = descendant; + } + if (node.nodeType === DOM_TEXT_TYPE) { + return node as Text; + } else if (node.nodeName === 'BR') { + return null; + } + } +} + +const nodeNameToTextFormat: Record = { + code: 'code', + em: 'italic', + i: 'italic', + s: 'strikethrough', + strong: 'bold', + sub: 'subscript', + sup: 'superscript', + u: 'underline', +}; + +function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput { + const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()]; + if (format === undefined) { + return {node: null}; + } + return { + forChild: applyTextFormatFromStyle(domNode.style, format), + node: null, + }; +} + +export function $createTextNode(text = ''): TextNode { + return $applyNodeReplacement(new TextNode(text)); +} + +export function $isTextNode( + node: LexicalNode | null | undefined, +): node is TextNode { + return node instanceof TextNode; +} + +function applyTextFormatFromStyle( + style: CSSStyleDeclaration, + shouldApply?: TextFormatType, +) { + const fontWeight = style.fontWeight; + const textDecoration = style.textDecoration.split(' '); + // Google Docs uses span tags + font-weight for bold text + const hasBoldFontWeight = fontWeight === '700' || fontWeight === 'bold'; + // Google Docs uses span tags + text-decoration: line-through for strikethrough text + const hasLinethroughTextDecoration = textDecoration.includes('line-through'); + // Google Docs uses span tags + font-style for italic text + const hasItalicFontStyle = style.fontStyle === 'italic'; + // Google Docs uses span tags + text-decoration: underline for underline text + const hasUnderlineTextDecoration = textDecoration.includes('underline'); + // Google Docs uses span tags + vertical-align to specify subscript and superscript + const verticalAlign = style.verticalAlign; + + return (lexicalNode: LexicalNode) => { + if (!$isTextNode(lexicalNode)) { + return lexicalNode; + } + if (hasBoldFontWeight && !lexicalNode.hasFormat('bold')) { + lexicalNode.toggleFormat('bold'); + } + if ( + hasLinethroughTextDecoration && + !lexicalNode.hasFormat('strikethrough') + ) { + lexicalNode.toggleFormat('strikethrough'); + } + if (hasItalicFontStyle && !lexicalNode.hasFormat('italic')) { + lexicalNode.toggleFormat('italic'); + } + if (hasUnderlineTextDecoration && !lexicalNode.hasFormat('underline')) { + lexicalNode.toggleFormat('underline'); + } + if (verticalAlign === 'sub' && !lexicalNode.hasFormat('subscript')) { + lexicalNode.toggleFormat('subscript'); + } + if (verticalAlign === 'super' && !lexicalNode.hasFormat('superscript')) { + lexicalNode.toggleFormat('superscript'); + } + + if (shouldApply && !lexicalNode.hasFormat(shouldApply)) { + lexicalNode.toggleFormat(shouldApply); + } + + return lexicalNode; + }; +} diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx new file mode 100644 index 000000000..e165df7a9 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx @@ -0,0 +1,635 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + ElementNode, + LexicalEditor, + LexicalNode, + TextNode, +} from 'lexical'; +import * as React from 'react'; +import {createRef, useEffect} from 'react'; +import {createRoot} from 'react-dom/client'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +import { + $createTestElementNode, + createTestEditor, +} from '../../../__tests__/utils'; + +describe('LexicalElementNode tests', () => { + let container: HTMLElement; + + beforeEach(async () => { + container = document.createElement('div'); + document.body.appendChild(container); + + await init(); + }); + + afterEach(() => { + document.body.removeChild(container); + // @ts-ignore + container = null; + }); + + async function update(fn: () => void) { + editor.update(fn); + return Promise.resolve().then(); + } + + function useLexicalEditor(rootElementRef: React.RefObject) { + const editor = React.useMemo(() => createTestEditor(), []); + + useEffect(() => { + const rootElement = rootElementRef.current; + editor.setRootElement(rootElement); + }, [rootElementRef, editor]); + + return editor; + } + + let editor: LexicalEditor; + + async function init() { + const ref = createRef(); + + function TestBase() { + editor = useLexicalEditor(ref); + + return
    ; + } + + ReactTestUtils.act(() => { + createRoot(container).render(); + }); + + // Insert initial block + await update(() => { + const block = $createTestElementNode(); + const text = $createTextNode('Foo'); + const text2 = $createTextNode('Bar'); + // Prevent text nodes from combining. + text2.setMode('segmented'); + const text3 = $createTextNode('Baz'); + // Some operations require a selection to exist, hence + // we make a selection in the setup code. + text.select(0, 0); + block.append(text, text2, text3); + $getRoot().append(block); + }); + } + + describe('exportJSON()', () => { + test('should return and object conforming to the expected schema', async () => { + await update(() => { + const node = $createTestElementNode(); + + // If you broke this test, you changed the public interface of a + // serialized Lexical Core Node. Please ensure the correct adapter + // logic is in place in the corresponding importJSON method + // to accomodate these changes. + + expect(node.exportJSON()).toStrictEqual({ + children: [], + direction: null, + format: '', + indent: 0, + type: 'test_block', + version: 1, + }); + }); + }); + }); + + describe('getChildren()', () => { + test('no children', async () => { + await update(() => { + const block = $createTestElementNode(); + const children = block.getChildren(); + expect(children).toHaveLength(0); + expect(children).toEqual([]); + }); + }); + + test('some children', async () => { + await update(() => { + const children = $getRoot().getFirstChild()!.getChildren(); + expect(children).toHaveLength(3); + }); + }); + }); + + describe('getAllTextNodes()', () => { + test('basic', async () => { + await update(() => { + const textNodes = $getRoot() + .getFirstChild()! + .getAllTextNodes(); + expect(textNodes).toHaveLength(3); + }); + }); + + test('nested', async () => { + await update(() => { + const block = $createTestElementNode(); + const innerBlock = $createTestElementNode(); + const text = $createTextNode('Foo'); + text.select(0, 0); + const text2 = $createTextNode('Bar'); + const text3 = $createTextNode('Baz'); + const text4 = $createTextNode('Qux'); + block.append(text, innerBlock, text4); + innerBlock.append(text2, text3); + const children = block.getAllTextNodes(); + + expect(children).toHaveLength(4); + expect(children).toEqual([text, text2, text3, text4]); + + const innerInnerBlock = $createTestElementNode(); + const text5 = $createTextNode('More'); + const text6 = $createTextNode('Stuff'); + innerInnerBlock.append(text5, text6); + innerBlock.append(innerInnerBlock); + const children2 = block.getAllTextNodes(); + + expect(children2).toHaveLength(6); + expect(children2).toEqual([text, text2, text3, text5, text6, text4]); + + $getRoot().append(block); + }); + }); + }); + + describe('getFirstChild()', () => { + test('basic', async () => { + await update(() => { + expect( + $getRoot() + .getFirstChild()! + .getFirstChild()! + .getTextContent(), + ).toBe('Foo'); + }); + }); + + test('empty', async () => { + await update(() => { + const block = $createTestElementNode(); + expect(block.getFirstChild()).toBe(null); + }); + }); + }); + + describe('getLastChild()', () => { + test('basic', async () => { + await update(() => { + expect( + $getRoot() + .getFirstChild()! + .getLastChild()! + .getTextContent(), + ).toBe('Baz'); + }); + }); + + test('empty', async () => { + await update(() => { + const block = $createTestElementNode(); + expect(block.getLastChild()).toBe(null); + }); + }); + }); + + describe('getTextContent()', () => { + test('basic', async () => { + await update(() => { + expect($getRoot().getFirstChild()!.getTextContent()).toBe('FooBarBaz'); + }); + }); + + test('empty', async () => { + await update(() => { + const block = $createTestElementNode(); + expect(block.getTextContent()).toBe(''); + }); + }); + + test('nested', async () => { + await update(() => { + const block = $createTestElementNode(); + const innerBlock = $createTestElementNode(); + const text = $createTextNode('Foo'); + text.select(0, 0); + const text2 = $createTextNode('Bar'); + const text3 = $createTextNode('Baz'); + text3.setMode('token'); + const text4 = $createTextNode('Qux'); + block.append(text, innerBlock, text4); + innerBlock.append(text2, text3); + + expect(block.getTextContent()).toEqual('FooBarBaz\n\nQux'); + + const innerInnerBlock = $createTestElementNode(); + const text5 = $createTextNode('More'); + text5.setMode('token'); + const text6 = $createTextNode('Stuff'); + innerInnerBlock.append(text5, text6); + innerBlock.append(innerInnerBlock); + + expect(block.getTextContent()).toEqual('FooBarBazMoreStuff\n\nQux'); + + $getRoot().append(block); + }); + }); + }); + + describe('getTextContentSize()', () => { + test('basic', async () => { + await update(() => { + expect($getRoot().getFirstChild()!.getTextContentSize()).toBe( + $getRoot().getFirstChild()!.getTextContent().length, + ); + }); + }); + + test('child node getTextContentSize() can be overridden and is then reflected when calling the same method on parent node', async () => { + await update(() => { + const block = $createTestElementNode(); + const text = $createTextNode('Foo'); + text.getTextContentSize = () => 1; + block.append(text); + + expect(block.getTextContentSize()).toBe(1); + }); + }); + }); + + describe('splice', () => { + let block: ElementNode; + + beforeEach(async () => { + await update(() => { + block = $getRoot().getFirstChildOrThrow(); + }); + }); + + const BASE_INSERTIONS: Array<{ + deleteCount: number; + deleteOnly: boolean | null | undefined; + expectedText: string; + name: string; + start: number; + }> = [ + // Do nothing + { + deleteCount: 0, + deleteOnly: true, + expectedText: 'FooBarBaz', + name: 'Do nothing', + start: 0, + }, + // Insert + { + deleteCount: 0, + deleteOnly: false, + expectedText: 'QuxQuuzFooBarBaz', + name: 'Insert in the beginning', + start: 0, + }, + { + deleteCount: 0, + deleteOnly: false, + expectedText: 'FooQuxQuuzBarBaz', + name: 'Insert in the middle', + start: 1, + }, + { + deleteCount: 0, + deleteOnly: false, + expectedText: 'FooBarBazQuxQuuz', + name: 'Insert in the end', + start: 3, + }, + // Delete + { + deleteCount: 1, + deleteOnly: true, + expectedText: 'BarBaz', + name: 'Delete in the beginning', + start: 0, + }, + { + deleteCount: 1, + deleteOnly: true, + expectedText: 'FooBaz', + name: 'Delete in the middle', + start: 1, + }, + { + deleteCount: 1, + deleteOnly: true, + expectedText: 'FooBar', + name: 'Delete in the end', + start: 2, + }, + { + deleteCount: 3, + deleteOnly: true, + expectedText: '', + name: 'Delete all', + start: 0, + }, + // Replace + { + deleteCount: 1, + deleteOnly: false, + expectedText: 'QuxQuuzBarBaz', + name: 'Replace in the beginning', + start: 0, + }, + { + deleteCount: 1, + deleteOnly: false, + expectedText: 'FooQuxQuuzBaz', + name: 'Replace in the middle', + start: 1, + }, + { + deleteCount: 1, + deleteOnly: false, + expectedText: 'FooBarQuxQuuz', + name: 'Replace in the end', + start: 2, + }, + { + deleteCount: 3, + deleteOnly: false, + expectedText: 'QuxQuuz', + name: 'Replace all', + start: 0, + }, + ]; + + BASE_INSERTIONS.forEach((testCase) => { + it(`Plain text: ${testCase.name}`, async () => { + await update(() => { + block.splice( + testCase.start, + testCase.deleteCount, + testCase.deleteOnly + ? [] + : [$createTextNode('Qux'), $createTextNode('Quuz')], + ); + + expect(block.getTextContent()).toEqual(testCase.expectedText); + }); + }); + }); + + let nodes: Record = {}; + + const NESTED_ELEMENTS_TESTS: Array<{ + deleteCount: number; + deleteOnly?: boolean; + expectedSelection: () => { + anchor: { + key: string; + offset: number; + type: string; + }; + focus: { + key: string; + offset: number; + type: string; + }; + }; + expectedText: string; + name: string; + start: number; + }> = [ + { + deleteCount: 0, + deleteOnly: true, + expectedSelection: () => { + return { + anchor: { + key: nodes.nestedText1.__key, + offset: 1, + type: 'text', + }, + focus: { + key: nodes.nestedText1.__key, + offset: 1, + type: 'text', + }, + }; + }, + expectedText: 'FooWiz\n\nFuz\n\nBar', + name: 'Do nothing', + start: 1, + }, + { + deleteCount: 1, + deleteOnly: true, + expectedSelection: () => { + return { + anchor: { + key: nodes.text1.__key, + offset: 3, + type: 'text', + }, + focus: { + key: nodes.text1.__key, + offset: 3, + type: 'text', + }, + }; + }, + expectedText: 'FooFuz\n\nBar', + name: 'Delete selected element (selection moves to the previous)', + start: 1, + }, + { + deleteCount: 1, + expectedSelection: () => { + return { + anchor: { + key: nodes.text1.__key, + offset: 3, + type: 'text', + }, + focus: { + key: nodes.text1.__key, + offset: 3, + type: 'text', + }, + }; + }, + expectedText: 'FooQuxQuuzFuz\n\nBar', + name: 'Replace selected element (selection moves to the previous)', + start: 1, + }, + { + deleteCount: 2, + deleteOnly: true, + expectedSelection: () => { + return { + anchor: { + key: nodes.nestedText2.__key, + offset: 0, + type: 'text', + }, + focus: { + key: nodes.nestedText2.__key, + offset: 0, + type: 'text', + }, + }; + }, + expectedText: 'Fuz\n\nBar', + name: 'Delete selected with previous element (selection moves to the next)', + start: 0, + }, + { + deleteCount: 4, + deleteOnly: true, + expectedSelection: () => { + return { + anchor: { + key: block.__key, + offset: 0, + type: 'element', + }, + focus: { + key: block.__key, + offset: 0, + type: 'element', + }, + }; + }, + expectedText: '', + name: 'Delete selected with all siblings (selection moves up to the element)', + start: 0, + }, + ]; + + NESTED_ELEMENTS_TESTS.forEach((testCase) => { + it(`Nested elements: ${testCase.name}`, async () => { + await update(() => { + const text1 = $createTextNode('Foo'); + const text2 = $createTextNode('Bar'); + + const nestedBlock1 = $createTestElementNode(); + const nestedText1 = $createTextNode('Wiz'); + nestedBlock1.append(nestedText1); + + const nestedBlock2 = $createTestElementNode(); + const nestedText2 = $createTextNode('Fuz'); + nestedBlock2.append(nestedText2); + + block.clear(); + block.append(text1, nestedBlock1, nestedBlock2, text2); + nestedText1.select(1, 1); + + expect(block.getTextContent()).toEqual('FooWiz\n\nFuz\n\nBar'); + + nodes = { + nestedBlock1, + nestedBlock2, + nestedText1, + nestedText2, + text1, + text2, + }; + }); + + await update(() => { + block.splice( + testCase.start, + testCase.deleteCount, + testCase.deleteOnly + ? [] + : [$createTextNode('Qux'), $createTextNode('Quuz')], + ); + }); + + await update(() => { + expect(block.getTextContent()).toEqual(testCase.expectedText); + + const selection = $getSelection(); + const expectedSelection = testCase.expectedSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect({ + key: selection.anchor.key, + offset: selection.anchor.offset, + type: selection.anchor.type, + }).toEqual(expectedSelection.anchor); + expect({ + key: selection.focus.key, + offset: selection.focus.offset, + type: selection.focus.type, + }).toEqual(expectedSelection.focus); + }); + }); + }); + + it('Running transforms for inserted nodes, their previous siblings and new siblings', async () => { + const transforms = new Set(); + const expectedTransforms: string[] = []; + + const removeTransform = editor.registerNodeTransform(TextNode, (node) => { + transforms.add(node.__key); + }); + + await update(() => { + const anotherBlock = $createTestElementNode(); + const text1 = $createTextNode('1'); + // Prevent text nodes from combining + const text2 = $createTextNode('2'); + text2.setMode('segmented'); + const text3 = $createTextNode('3'); + anotherBlock.append(text1, text2, text3); + $getRoot().append(anotherBlock); + + // Expect inserted node, its old siblings and new siblings to receive + // transformer calls + expectedTransforms.push( + text1.__key, + text2.__key, + text3.__key, + block.getChildAtIndex(0)!.__key, + block.getChildAtIndex(1)!.__key, + ); + }); + + await update(() => { + block.splice(1, 0, [ + $getRoot().getLastChild()!.getChildAtIndex(1)!, + ]); + }); + + removeTransform(); + + await update(() => { + expect(block.getTextContent()).toEqual('Foo2BarBaz'); + expectedTransforms.forEach((key) => { + expect(transforms).toContain(key); + }); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx new file mode 100644 index 000000000..2c7e978a1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx @@ -0,0 +1,119 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $isElementNode, +} from 'lexical'; + +import { + $createTestElementNode, + generatePermutations, + initializeUnitTest, + invariant, +} from '../../../__tests__/utils'; + +describe('LexicalGC tests', () => { + initializeUnitTest((testEnv) => { + test('RootNode.clear() with a child and subchild', async () => { + const {editor} = testEnv; + await editor.update(() => { + $getRoot().append( + $createParagraphNode().append($createTextNode('foo')), + ); + }); + expect(editor.getEditorState()._nodeMap.size).toBe(3); + await editor.update(() => { + $getRoot().clear(); + }); + expect(editor.getEditorState()._nodeMap.size).toBe(1); + }); + + test('RootNode.clear() with a child and three subchildren', async () => { + const {editor} = testEnv; + await editor.update(() => { + const text1 = $createTextNode('foo'); + const text2 = $createTextNode('bar').toggleUnmergeable(); + const text3 = $createTextNode('zzz').toggleUnmergeable(); + const paragraph = $createParagraphNode(); + paragraph.append(text1, text2, text3); + $getRoot().append(paragraph); + }); + expect(editor.getEditorState()._nodeMap.size).toBe(5); + await editor.update(() => { + $getRoot().clear(); + }); + expect(editor.getEditorState()._nodeMap.size).toBe(1); + }); + + for (let i = 0; i < 3; i++) { + test(`RootNode.clear() with a child and three subchildren, subchild ${i} removed first`, async () => { + const {editor} = testEnv; + await editor.update(() => { + const text1 = $createTextNode('foo'); // 1 + const text2 = $createTextNode('bar').toggleUnmergeable(); // 2 + const text3 = $createTextNode('zzz').toggleUnmergeable(); // 3 + const paragraph = $createParagraphNode(); // 4 + paragraph.append(text1, text2, text3); + $getRoot().append(paragraph); + }); + expect(editor.getEditorState()._nodeMap.size).toBe(5); + await editor.update(() => { + const root = $getRoot(); + const firstChild = root.getFirstChild(); + invariant($isElementNode(firstChild)); + const subchild = firstChild.getChildAtIndex(i)!; + expect(subchild.getTextContent()).toBe(['foo', 'bar', 'zzz'][i]); + subchild.remove(); + root.clear(); + }); + expect(editor.getEditorState()._nodeMap.size).toEqual(1); + }); + } + + const permutations2 = generatePermutations( + ['1', '2', '3', '4', '5', '6'], + 2, + ); + for (let i = 0; i < permutations2.length; i++) { + const removeKeys = permutations2[i]; + /** + * R + * P + * T TE T + * T T + */ + test(`RootNode.clear() with a complex tree, nodes ${removeKeys.toString()} removed first`, async () => { + const {editor} = testEnv; + await editor.update(() => { + const testElement = $createTestElementNode(); // 1 + const testElementText1 = $createTextNode('te1').toggleUnmergeable(); // 2 + const testElementText2 = $createTextNode('te2').toggleUnmergeable(); // 3 + const text1 = $createTextNode('a').toggleUnmergeable(); // 4 + const text2 = $createTextNode('b').toggleUnmergeable(); // 5 + const paragraph = $createParagraphNode(); // 6 + testElement.append(testElementText1, testElementText2); + paragraph.append(text1, testElement, text2); + $getRoot().append(paragraph); + }); + expect(editor.getEditorState()._nodeMap.size).toBe(7); + await editor.update(() => { + for (const key of removeKeys) { + const node = $getNodeByKey(String(key))!; + node.remove(); + } + $getRoot().clear(); + }); + expect(editor.getEditorState()._nodeMap.size).toEqual(1); + }); + } + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts new file mode 100644 index 000000000..110086ac8 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalLineBreakNode.test.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createLineBreakNode, $isLineBreakNode} from 'lexical'; + +import {initializeUnitTest} from '../../../__tests__/utils'; + +describe('LexicalLineBreakNode tests', () => { + initializeUnitTest((testEnv) => { + test('LineBreakNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const lineBreakNode = $createLineBreakNode(); + + expect(lineBreakNode.getType()).toEqual('linebreak'); + expect(lineBreakNode.getTextContent()).toEqual('\n'); + }); + }); + + test('LineBreakNode.exportJSON() should return and object conforming to the expected schema', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = $createLineBreakNode(); + + // If you broke this test, you changed the public interface of a + // serialized Lexical Core Node. Please ensure the correct adapter + // logic is in place in the corresponding importJSON method + // to accomodate these changes. + expect(node.exportJSON()).toStrictEqual({ + type: 'linebreak', + version: 1, + }); + }); + }); + + test('LineBreakNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const lineBreakNode = $createLineBreakNode(); + const element = lineBreakNode.createDOM(); + + expect(element.outerHTML).toBe('
    '); + }); + }); + + test('LineBreakNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const lineBreakNode = $createLineBreakNode(); + + expect(lineBreakNode.updateDOM()).toBe(false); + }); + }); + + test('LineBreakNode.$isLineBreakNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const lineBreakNode = $createLineBreakNode(); + + expect($isLineBreakNode(lineBreakNode)).toBe(true); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts new file mode 100644 index 000000000..1f7c4cfc3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts @@ -0,0 +1,153 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $getRoot, + $isParagraphNode, + ParagraphNode, + RangeSelection, +} from 'lexical'; + +import {initializeUnitTest} from '../../../__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + paragraph: 'my-paragraph-class', + }, +}); + +describe('LexicalParagraphNode tests', () => { + initializeUnitTest((testEnv) => { + test('ParagraphNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = new ParagraphNode(); + + expect(paragraphNode.getType()).toBe('paragraph'); + expect(paragraphNode.getTextContent()).toBe(''); + }); + expect(() => new ParagraphNode()).toThrow(); + }); + + test('ParagraphNode.exportJSON() should return and object conforming to the expected schema', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = $createParagraphNode(); + + // If you broke this test, you changed the public interface of a + // serialized Lexical Core Node. Please ensure the correct adapter + // logic is in place in the corresponding importJSON method + // to accomodate these changes. + expect(node.exportJSON()).toStrictEqual({ + children: [], + direction: null, + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }); + }); + }); + + test('ParagraphNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = new ParagraphNode(); + + expect(paragraphNode.createDOM(editorConfig).outerHTML).toBe( + '

    ', + ); + expect( + paragraphNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('

    '); + }); + }); + + test('ParagraphNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = new ParagraphNode(); + const domElement = paragraphNode.createDOM(editorConfig); + + expect(domElement.outerHTML).toBe('

    '); + + const newParagraphNode = new ParagraphNode(); + const result = newParagraphNode.updateDOM( + paragraphNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe('

    '); + }); + }); + + test('ParagraphNode.insertNewAfter()', async () => { + const {editor} = testEnv; + let paragraphNode: ParagraphNode; + + await editor.update(() => { + const root = $getRoot(); + paragraphNode = new ParagraphNode(); + root.append(paragraphNode); + }); + + expect(testEnv.outerHTML).toBe( + '


    ', + ); + + await editor.update(() => { + const selection = paragraphNode.select(); + const result = paragraphNode.insertNewAfter( + selection as RangeSelection, + false, + ); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(paragraphNode.getDirection()); + expect(testEnv.outerHTML).toBe( + '


    ', + ); + }); + }); + + test('$createParagraphNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = new ParagraphNode(); + const createdParagraphNode = $createParagraphNode(); + + expect(paragraphNode.__type).toEqual(createdParagraphNode.__type); + expect(paragraphNode.__parent).toEqual(createdParagraphNode.__parent); + expect(paragraphNode.__key).not.toEqual(createdParagraphNode.__key); + }); + }); + + test('$isParagraphNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = new ParagraphNode(); + + expect($isParagraphNode(paragraphNode)).toBe(true); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts new file mode 100644 index 000000000..123cb3375 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts @@ -0,0 +1,271 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $isRootNode, + ElementNode, + RootNode, + TextNode, +} from 'lexical'; + +import { + $createTestDecoratorNode, + $createTestElementNode, + $createTestInlineElementNode, + initializeUnitTest, +} from '../../../__tests__/utils'; +import {$createRootNode} from '../../LexicalRootNode'; + +describe('LexicalRootNode tests', () => { + initializeUnitTest((testEnv) => { + let rootNode: RootNode; + + function expectRootTextContentToBe(text: string): void { + const {editor} = testEnv; + editor.getEditorState().read(() => { + const root = $getRoot(); + + expect(root.__cachedText).toBe(text); + + // Copy root to remove __cachedText because it's frozen + const rootCopy = Object.assign({}, root); + rootCopy.__cachedText = null; + Object.setPrototypeOf(rootCopy, Object.getPrototypeOf(root)); + + expect(rootCopy.getTextContent()).toBe(text); + }); + } + + beforeEach(async () => { + const {editor} = testEnv; + + await editor.update(() => { + rootNode = $createRootNode(); + }); + }); + + test('RootNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + expect(rootNode).toStrictEqual($createRootNode()); + expect(rootNode.getType()).toBe('root'); + expect(rootNode.getTextContent()).toBe(''); + }); + }); + + test('RootNode.exportJSON() should return and object conforming to the expected schema', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const node = $createRootNode(); + + // If you broke this test, you changed the public interface of a + // serialized Lexical Core Node. Please ensure the correct adapter + // logic is in place in the corresponding importJSON method + // to accomodate these changes. + expect(node.exportJSON()).toStrictEqual({ + children: [], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }); + }); + }); + + test('RootNode.clone()', async () => { + const rootNodeClone = (rootNode.constructor as typeof RootNode).clone(); + + expect(rootNodeClone).not.toBe(rootNode); + expect(rootNodeClone).toStrictEqual(rootNode); + }); + + test('RootNode.createDOM()', async () => { + // @ts-expect-error + expect(() => rootNode.createDOM()).toThrow(); + }); + + test('RootNode.updateDOM()', async () => { + // @ts-expect-error + expect(rootNode.updateDOM()).toBe(false); + }); + + test('RootNode.isAttached()', async () => { + expect(rootNode.isAttached()).toBe(true); + }); + + test('RootNode.isRootNode()', () => { + expect($isRootNode(rootNode)).toBe(true); + }); + + test('Cached getTextContent with decorators', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.append($createTestDecoratorNode()); + }); + + expect( + editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }), + ).toBe('Hello world'); + }); + + test('RootNode.clear() to handle selection update', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const text = $createTextNode('Hello'); + paragraph.append(text); + text.select(); + }); + + await editor.update(() => { + const root = $getRoot(); + root.clear(); + }); + + await editor.update(() => { + const root = $getRoot(); + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor.getNode()).toBe(root); + expect(selection.focus.getNode()).toBe(root); + }); + }); + + test('RootNode is selected when its only child removed', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const text = $createTextNode('Hello'); + paragraph.append(text); + text.select(); + }); + + await editor.update(() => { + const root = $getRoot(); + root.getFirstChild()!.remove(); + }); + + await editor.update(() => { + const root = $getRoot(); + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor.getNode()).toBe(root); + expect(selection.focus.getNode()).toBe(root); + }); + }); + + test('RootNode __cachedText', async () => { + const {editor} = testEnv; + + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + + expectRootTextContentToBe(''); + + await editor.update(() => { + const firstParagraph = $getRoot().getFirstChild()!; + + firstParagraph.append($createTextNode('first line')); + }); + + expectRootTextContentToBe('first line'); + + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + + expectRootTextContentToBe('first line\n\n'); + + await editor.update(() => { + const secondParagraph = $getRoot().getLastChild()!; + + secondParagraph.append($createTextNode('second line')); + }); + + expectRootTextContentToBe('first line\n\nsecond line'); + + await editor.update(() => { + $getRoot().append($createParagraphNode()); + }); + + expectRootTextContentToBe('first line\n\nsecond line\n\n'); + + await editor.update(() => { + const thirdParagraph = $getRoot().getLastChild()!; + thirdParagraph.append($createTextNode('third line')); + }); + + expectRootTextContentToBe('first line\n\nsecond line\n\nthird line'); + + await editor.update(() => { + const secondParagraph = $getRoot().getChildAtIndex(1)!; + const secondParagraphText = secondParagraph.getFirstChild()!; + secondParagraphText.setTextContent('second line!'); + }); + + expectRootTextContentToBe('first line\n\nsecond line!\n\nthird line'); + }); + + test('RootNode __cachedText (empty paragraph)', async () => { + const {editor} = testEnv; + + await editor.update(() => { + $getRoot().append($createParagraphNode(), $createParagraphNode()); + }); + + expectRootTextContentToBe('\n\n'); + }); + + test('RootNode __cachedText (inlines)', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.append( + $createTextNode('a'), + $createTestElementNode(), + $createTextNode('b'), + $createTestInlineElementNode(), + $createTextNode('c'), + ); + }); + + expectRootTextContentToBe('a\n\nbc'); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx new file mode 100644 index 000000000..0c06273ec --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx @@ -0,0 +1,257 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $insertDataTransferForPlainText, + $insertDataTransferForRichText, +} from '@lexical/clipboard'; +import {$createListItemNode, $createListNode} from '@lexical/list'; +import {registerTabIndentation} from '@lexical/react/LexicalTabIndentationPlugin'; +import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createRangeSelection, + $createTabNode, + $createTextNode, + $getRoot, + $getSelection, + $insertNodes, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setSelection, + KEY_TAB_COMMAND, +} from 'lexical'; + +import { + DataTransferMock, + initializeUnitTest, + invariant, +} from '../../../__tests__/utils'; + +describe('LexicalTabNode tests', () => { + initializeUnitTest((testEnv) => { + beforeEach(async () => { + const {editor} = testEnv; + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.select(); + }); + }); + + test('can paste plain text with tabs and newlines in plain text', async () => { + const {editor} = testEnv; + const dataTransfer = new DataTransferMock(); + dataTransfer.setData('text/plain', 'hello\tworld\nhello\tworld'); + await editor.update(() => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'isRangeSelection(selection)'); + $insertDataTransferForPlainText(dataTransfer, selection); + }); + expect(testEnv.innerHTML).toBe( + '

    hello\tworld
    hello\tworld

    ', + ); + }); + + test('can paste plain text with tabs and newlines in rich text', async () => { + const {editor} = testEnv; + const dataTransfer = new DataTransferMock(); + dataTransfer.setData('text/plain', 'hello\tworld\nhello\tworld'); + await editor.update(() => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'isRangeSelection(selection)'); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe( + '

    hello\tworld

    hello\tworld

    ', + ); + }); + + // TODO fixme + // test('can paste HTML with tabs and new lines #4429', async () => { + // const {editor} = testEnv; + // const dataTransfer = new DataTransferMock(); + // // https://codepen.io/zurfyx/pen/bGmrzMR + // dataTransfer.setData( + // 'text/html', + // `hello world + // hello world`, + // ); + // await editor.update(() => { + // const selection = $getSelection(); + // invariant($isRangeSelection(selection), 'isRangeSelection(selection)'); + // $insertDataTransferForRichText(dataTransfer, selection, editor); + // }); + // expect(testEnv.innerHTML).toBe( + // '

    hello\tworld
    hello\tworld

    ', + // ); + // }); + + test('can paste HTML with tabs and new lines (2)', async () => { + const {editor} = testEnv; + const dataTransfer = new DataTransferMock(); + // GDoc 2-liner hello\tworld (like previous test) + dataTransfer.setData( + 'text/html', + `

    Hello world

    Hello world
    `, + ); + await editor.update(() => { + const selection = $getSelection(); + invariant($isRangeSelection(selection), 'isRangeSelection(selection)'); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe( + '

    Hello\tworld

    Hello\tworld

    ', + ); + }); + + test('element indents when selection at the start of the block', async () => { + const {editor} = testEnv; + registerRichText(editor); + registerTabIndentation(editor); + await editor.update(() => { + const selection = $getSelection()!; + selection.insertText('foo'); + $getRoot().selectStart(); + }); + await editor.dispatchCommand( + KEY_TAB_COMMAND, + new KeyboardEvent('keydown'), + ); + expect(testEnv.innerHTML).toBe( + '

    foo

    ', + ); + }); + + test('elements indent when selection spans across multiple blocks', async () => { + const {editor} = testEnv; + registerRichText(editor); + registerTabIndentation(editor); + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + invariant($isElementNode(paragraph)); + const heading = $createHeadingNode('h1'); + const list = $createListNode('number'); + const listItem = $createListItemNode(); + const paragraphText = $createTextNode('foo'); + const headingText = $createTextNode('bar'); + const listItemText = $createTextNode('xyz'); + root.append(heading, list); + paragraph.append(paragraphText); + heading.append(headingText); + list.append(listItem); + listItem.append(listItemText); + const selection = $createRangeSelection(); + selection.focus.set(paragraphText.getKey(), 1, 'text'); + selection.anchor.set(listItemText.getKey(), 1, 'text'); + $setSelection(selection); + }); + await editor.dispatchCommand( + KEY_TAB_COMMAND, + new KeyboardEvent('keydown'), + ); + expect(testEnv.innerHTML).toBe( + '

    foo

    bar

      1. xyz
    ', + ); + }); + + test('element tabs when selection is not at the start (1)', async () => { + const {editor} = testEnv; + registerRichText(editor); + registerTabIndentation(editor); + await editor.update(() => { + $getSelection()!.insertText('foo'); + }); + await editor.dispatchCommand( + KEY_TAB_COMMAND, + new KeyboardEvent('keydown'), + ); + expect(testEnv.innerHTML).toBe( + '

    foo\t

    ', + ); + }); + + test('element tabs when selection is not at the start (2)', async () => { + const {editor} = testEnv; + registerRichText(editor); + registerTabIndentation(editor); + await editor.update(() => { + $getSelection()!.insertText('foo'); + const textNode = $getRoot().getLastDescendant(); + invariant($isTextNode(textNode)); + textNode.select(1, 1); + }); + await editor.dispatchCommand( + KEY_TAB_COMMAND, + new KeyboardEvent('keydown'), + ); + expect(testEnv.innerHTML).toBe( + '

    f\too

    ', + ); + }); + + test('element tabs when selection is not at the start (3)', async () => { + const {editor} = testEnv; + registerRichText(editor); + registerTabIndentation(editor); + await editor.update(() => { + $getSelection()!.insertText('foo'); + const textNode = $getRoot().getLastDescendant(); + invariant($isTextNode(textNode)); + textNode.select(1, 2); + }); + await editor.dispatchCommand( + KEY_TAB_COMMAND, + new KeyboardEvent('keydown'), + ); + expect(testEnv.innerHTML).toBe( + '

    f\to

    ', + ); + }); + + test('elements tabs when selection is not at the start and overlaps another tab', async () => { + const {editor} = testEnv; + registerRichText(editor); + registerTabIndentation(editor); + await editor.update(() => { + $getSelection()!.insertRawText('hello\tworld'); + const root = $getRoot(); + const firstTextNode = root.getFirstDescendant(); + const lastTextNode = root.getLastDescendant(); + const selection = $createRangeSelection(); + selection.anchor.set(firstTextNode!.getKey(), 'hell'.length, 'text'); + selection.focus.set(lastTextNode!.getKey(), 'wo'.length, 'text'); + $setSelection(selection); + }); + await editor.dispatchCommand( + KEY_TAB_COMMAND, + new KeyboardEvent('keydown'), + ); + expect(testEnv.innerHTML).toBe( + '

    hell\trld

    ', + ); + }); + + test('can type between two (leaf nodes) canInsertBeforeAfter false', async () => { + const {editor} = testEnv; + await editor.update(() => { + const tab1 = $createTabNode(); + const tab2 = $createTabNode(); + $insertNodes([tab1, tab2]); + tab1.select(1, 1); + $getSelection()!.insertText('f'); + }); + expect(testEnv.innerHTML).toBe( + '

    \tf\t

    ', + ); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx new file mode 100644 index 000000000..7fc509dfd --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx @@ -0,0 +1,843 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isNodeSelection, + $isRangeSelection, + ElementNode, + LexicalEditor, + ParagraphNode, + TextFormatType, + TextModeType, + TextNode, +} from 'lexical'; +import * as React from 'react'; +import {createRef, useEffect, useMemo} from 'react'; +import {createRoot} from 'react-dom/client'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +import { + $createTestSegmentedNode, + createTestEditor, +} from '../../../__tests__/utils'; +import { + IS_BOLD, + IS_CODE, + IS_HIGHLIGHT, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, +} from '../../../LexicalConstants'; +import { + $getCompositionKey, + $setCompositionKey, + getEditorStateTextContent, +} from '../../../LexicalUtils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + text: { + bold: 'my-bold-class', + code: 'my-code-class', + highlight: 'my-highlight-class', + italic: 'my-italic-class', + strikethrough: 'my-strikethrough-class', + underline: 'my-underline-class', + underlineStrikethrough: 'my-underline-strikethrough-class', + }, + }, +}); + +describe('LexicalTextNode tests', () => { + let container: HTMLElement; + + beforeEach(async () => { + container = document.createElement('div'); + document.body.appendChild(container); + + await init(); + }); + afterEach(() => { + document.body.removeChild(container); + // @ts-ignore + container = null; + }); + + async function update(fn: () => void) { + editor.update(fn); + return Promise.resolve().then(); + } + + function useLexicalEditor(rootElementRef: React.RefObject) { + const editor = useMemo(() => createTestEditor(editorConfig), []); + + useEffect(() => { + const rootElement = rootElementRef.current; + + editor.setRootElement(rootElement); + }, [rootElementRef, editor]); + + return editor; + } + + let editor: LexicalEditor; + + async function init() { + const ref = createRef(); + + function TestBase() { + editor = useLexicalEditor(ref); + + return
    ; + } + + ReactTestUtils.act(() => { + createRoot(container).render(); + }); + + // Insert initial block + await update(() => { + const paragraph = $createParagraphNode(); + const text = $createTextNode(); + text.toggleUnmergeable(); + paragraph.append(text); + $getRoot().append(paragraph); + }); + } + + describe('exportJSON()', () => { + test('should return and object conforming to the expected schema', async () => { + await update(() => { + const node = $createTextNode(); + + // If you broke this test, you changed the public interface of a + // serialized Lexical Core Node. Please ensure the correct adapter + // logic is in place in the corresponding importJSON method + // to accomodate these changes. + + expect(node.exportJSON()).toStrictEqual({ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: '', + type: 'text', + version: 1, + }); + }); + }); + }); + + describe('root.getTextContent()', () => { + test('writable nodes', async () => { + let nodeKey: string; + + await update(() => { + const textNode = $createTextNode('Text'); + nodeKey = textNode.getKey(); + + expect(textNode.getTextContent()).toBe('Text'); + expect(textNode.__text).toBe('Text'); + + $getRoot().getFirstChild()!.append(textNode); + }); + + expect( + editor.getEditorState().read(() => { + const root = $getRoot(); + return root.__cachedText; + }), + ); + expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text'); + + // Make sure that the editor content is still set after further reconciliations + await update(() => { + $getNodeByKey(nodeKey)!.markDirty(); + }); + expect(getEditorStateTextContent(editor.getEditorState())).toBe('Text'); + }); + + test('prepend node', async () => { + await update(() => { + const textNode = $createTextNode('World').toggleUnmergeable(); + $getRoot().getFirstChild()!.append(textNode); + }); + + await update(() => { + const textNode = $createTextNode('Hello ').toggleUnmergeable(); + const previousTextNode = $getRoot() + .getFirstChild()! + .getFirstChild()!; + previousTextNode.insertBefore(textNode); + }); + + expect(getEditorStateTextContent(editor.getEditorState())).toBe( + 'Hello World', + ); + }); + }); + + describe('setTextContent()', () => { + test('writable nodes', async () => { + await update(() => { + const textNode = $createTextNode('My new text node'); + textNode.setTextContent('My newer text node'); + + expect(textNode.getTextContent()).toBe('My newer text node'); + }); + }); + }); + + describe.each([ + ['bold', IS_BOLD], + ['italic', IS_ITALIC], + ['strikethrough', IS_STRIKETHROUGH], + ['underline', IS_UNDERLINE], + ['code', IS_CODE], + ['subscript', IS_SUBSCRIPT], + ['superscript', IS_SUPERSCRIPT], + ['highlight', IS_HIGHLIGHT], + ] as const)('%s flag', (formatFlag: TextFormatType, stateFormat: number) => { + const flagPredicate = (node: TextNode) => node.hasFormat(formatFlag); + const flagToggle = (node: TextNode) => node.toggleFormat(formatFlag); + + test(`getFormatFlags(${formatFlag})`, async () => { + await update(() => { + const root = $getRoot(); + const paragraphNode = root.getFirstChild()!; + const textNode = paragraphNode.getFirstChild()!; + const newFormat = textNode.getFormatFlags(formatFlag, null); + + expect(newFormat).toBe(stateFormat); + + textNode.setFormat(newFormat); + const newFormat2 = textNode.getFormatFlags(formatFlag, null); + + expect(newFormat2).toBe(0); + }); + }); + + test(`predicate for ${formatFlag}`, async () => { + await update(() => { + const root = $getRoot(); + const paragraphNode = root.getFirstChild()!; + const textNode = paragraphNode.getFirstChild()!; + + textNode.setFormat(stateFormat); + + expect(flagPredicate(textNode)).toBe(true); + }); + }); + + test(`toggling for ${formatFlag}`, async () => { + // Toggle method hasn't been implemented for this flag. + if (flagToggle === null) { + return; + } + + await update(() => { + const root = $getRoot(); + const paragraphNode = root.getFirstChild()!; + const textNode = paragraphNode.getFirstChild()!; + + expect(flagPredicate(textNode)).toBe(false); + + flagToggle(textNode); + + expect(flagPredicate(textNode)).toBe(true); + + flagToggle(textNode); + + expect(flagPredicate(textNode)).toBe(false); + }); + }); + }); + + test('setting subscript clears superscript', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + textNode.toggleFormat('superscript'); + textNode.toggleFormat('subscript'); + expect(textNode.hasFormat('subscript')).toBe(true); + expect(textNode.hasFormat('superscript')).toBe(false); + }); + }); + + test('setting superscript clears subscript', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + textNode.toggleFormat('subscript'); + textNode.toggleFormat('superscript'); + expect(textNode.hasFormat('superscript')).toBe(true); + expect(textNode.hasFormat('subscript')).toBe(false); + }); + }); + + test('clearing subscript does not set superscript', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + textNode.toggleFormat('subscript'); + textNode.toggleFormat('subscript'); + expect(textNode.hasFormat('subscript')).toBe(false); + expect(textNode.hasFormat('superscript')).toBe(false); + }); + }); + + test('clearing superscript does not set subscript', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + textNode.toggleFormat('superscript'); + textNode.toggleFormat('superscript'); + expect(textNode.hasFormat('superscript')).toBe(false); + expect(textNode.hasFormat('subscript')).toBe(false); + }); + }); + + test('selectPrevious()', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + const textNode2 = $createTextNode('Goodbye Earth'); + paragraphNode.append(textNode, textNode2); + $getRoot().append(paragraphNode); + + let selection = textNode2.selectPrevious(); + + expect(selection.anchor.getNode()).toBe(textNode); + expect(selection.anchor.offset).toBe(11); + expect(selection.focus.getNode()).toBe(textNode); + expect(selection.focus.offset).toBe(11); + + selection = textNode.selectPrevious(); + + expect(selection.anchor.getNode()).toBe(paragraphNode); + expect(selection.anchor.offset).toBe(0); + }); + }); + + test('selectNext()', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + const textNode2 = $createTextNode('Goodbye Earth'); + paragraphNode.append(textNode, textNode2); + $getRoot().append(paragraphNode); + let selection = textNode.selectNext(1, 3); + + if ($isNodeSelection(selection)) { + return; + } + + expect(selection.anchor.getNode()).toBe(textNode2); + expect(selection.anchor.offset).toBe(1); + expect(selection.focus.getNode()).toBe(textNode2); + expect(selection.focus.offset).toBe(3); + + selection = textNode2.selectNext(); + + expect(selection.anchor.getNode()).toBe(paragraphNode); + expect(selection.anchor.offset).toBe(2); + }); + }); + + describe('select()', () => { + test.each([ + [ + [2, 4], + [2, 4], + ], + [ + [4, 2], + [4, 2], + ], + [ + [undefined, 2], + [11, 2], + ], + [ + [2, undefined], + [2, 11], + ], + [ + [undefined, undefined], + [11, 11], + ], + ])( + 'select(...%p)', + async ( + [anchorOffset, focusOffset], + [expectedAnchorOffset, expectedFocusOffset], + ) => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + + const selection = textNode.select(anchorOffset, focusOffset); + + expect(selection.focus.getNode()).toBe(textNode); + expect(selection.anchor.offset).toBe(expectedAnchorOffset); + expect(selection.focus.getNode()).toBe(textNode); + expect(selection.focus.offset).toBe(expectedFocusOffset); + }); + }, + ); + }); + + describe('splitText()', () => { + test('convert segmented node into plain text', async () => { + await update(() => { + const segmentedNode = $createTestSegmentedNode('Hello World'); + const paragraphNode = $createParagraphNode(); + paragraphNode.append(segmentedNode); + + const [middle, next] = segmentedNode.splitText(5); + + const children = paragraphNode.getAllTextNodes(); + expect(paragraphNode.getTextContent()).toBe('Hello World'); + expect(children[0].isSimpleText()).toBe(true); + expect(children[0].getTextContent()).toBe('Hello'); + expect(middle).toBe(children[0]); + expect(next).toBe(children[1]); + }); + }); + test.each([ + ['a', [], ['a']], + ['a', [1], ['a']], + ['a', [5], ['a']], + ['Hello World', [], ['Hello World']], + ['Hello World', [3], ['Hel', 'lo World']], + ['Hello World', [3, 3], ['Hel', 'lo World']], + ['Hello World', [3, 7], ['Hel', 'lo W', 'orld']], + ['Hello World', [7, 3], ['Hel', 'lo W', 'orld']], + ['Hello World', [3, 7, 99], ['Hel', 'lo W', 'orld']], + ])( + '"%s" splitText(...%p)', + async (initialString, splitOffsets, splitStrings) => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(initialString); + paragraphNode.append(textNode); + + const splitNodes = textNode.splitText(...splitOffsets); + + expect(paragraphNode.getChildren()).toHaveLength(splitStrings.length); + expect(splitNodes.map((node) => node.getTextContent())).toEqual( + splitStrings, + ); + }); + }, + ); + + test('splitText moves composition key to last node', async () => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('12345'); + paragraphNode.append(textNode); + $setCompositionKey(textNode.getKey()); + + const [, splitNode2] = textNode.splitText(1); + expect($getCompositionKey()).toBe(splitNode2.getKey()); + }); + }); + + test.each([ + [ + 'Hello', + [4], + [3, 3], + { + anchorNodeIndex: 0, + anchorOffset: 3, + focusNodeIndex: 0, + focusOffset: 3, + }, + ], + [ + 'Hello', + [4], + [5, 5], + { + anchorNodeIndex: 1, + anchorOffset: 1, + focusNodeIndex: 1, + focusOffset: 1, + }, + ], + [ + 'Hello World', + [4], + [2, 7], + { + anchorNodeIndex: 0, + anchorOffset: 2, + focusNodeIndex: 1, + focusOffset: 3, + }, + ], + [ + 'Hello World', + [4], + [2, 4], + { + anchorNodeIndex: 0, + anchorOffset: 2, + focusNodeIndex: 0, + focusOffset: 4, + }, + ], + [ + 'Hello World', + [4], + [7, 2], + { + anchorNodeIndex: 1, + anchorOffset: 3, + focusNodeIndex: 0, + focusOffset: 2, + }, + ], + [ + 'Hello World', + [4, 6], + [2, 9], + { + anchorNodeIndex: 0, + anchorOffset: 2, + focusNodeIndex: 2, + focusOffset: 3, + }, + ], + [ + 'Hello World', + [4, 6], + [9, 2], + { + anchorNodeIndex: 2, + anchorOffset: 3, + focusNodeIndex: 0, + focusOffset: 2, + }, + ], + [ + 'Hello World', + [4, 6], + [9, 9], + { + anchorNodeIndex: 2, + anchorOffset: 3, + focusNodeIndex: 2, + focusOffset: 3, + }, + ], + ])( + '"%s" splitText(...%p) with select(...%p)', + async ( + initialString, + splitOffsets, + selectionOffsets, + {anchorNodeIndex, anchorOffset, focusNodeIndex, focusOffset}, + ) => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(initialString); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + + const selection = textNode.select(...selectionOffsets); + const childrenNodes = textNode.splitText(...splitOffsets); + + expect(selection.anchor.getNode()).toBe( + childrenNodes[anchorNodeIndex], + ); + expect(selection.anchor.offset).toBe(anchorOffset); + expect(selection.focus.getNode()).toBe(childrenNodes[focusNodeIndex]); + expect(selection.focus.offset).toBe(focusOffset); + }); + }, + ); + + test('with detached parent', async () => { + await update(() => { + const textNode = $createTextNode('foo'); + const splits = textNode.splitText(1, 2); + expect(splits.map((split) => split.getTextContent())).toEqual([ + 'f', + 'o', + 'o', + ]); + }); + }); + }); + + describe('createDOM()', () => { + test.each([ + ['no formatting', 0, 'My text node', 'My text node'], + [ + 'bold', + IS_BOLD, + 'My text node', + 'My text node', + ], + ['bold + empty', IS_BOLD, '', ``], + [ + 'underline', + IS_UNDERLINE, + 'My text node', + 'My text node', + ], + [ + 'strikethrough', + IS_STRIKETHROUGH, + 'My text node', + 'My text node', + ], + [ + 'highlight', + IS_HIGHLIGHT, + 'My text node', + 'My text node', + ], + [ + 'italic', + IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code', + IS_CODE, + 'My text node', + 'My text node', + ], + [ + 'underline + strikethrough', + IS_UNDERLINE | IS_STRIKETHROUGH, + 'My text node', + '' + + 'My text node', + ], + [ + 'code + italic', + IS_CODE | IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code + underline + strikethrough', + IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH, + 'My text node', + '' + + 'My text node', + ], + [ + 'highlight + italic', + IS_HIGHLIGHT | IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code + underline + strikethrough + bold + italic', + IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC, + 'My text node', + 'My text node', + ], + [ + 'code + underline + strikethrough + bold + italic + highlight', + IS_CODE | + IS_UNDERLINE | + IS_STRIKETHROUGH | + IS_BOLD | + IS_ITALIC | + IS_HIGHLIGHT, + 'My text node', + 'My text node', + ], + ])('%s text format type', async (_type, format, contents, expectedHTML) => { + await update(() => { + const textNode = $createTextNode(contents); + textNode.setFormat(format); + const element = textNode.createDOM(editorConfig); + + expect(element.outerHTML).toBe(expectedHTML); + }); + }); + + describe('has parent node', () => { + test.each([ + ['no formatting', 0, 'My text node', 'My text node'], + ['no formatting + empty string', 0, '', ``], + ])( + '%s text format type', + async (_type, format, contents, expectedHTML) => { + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(contents); + textNode.setFormat(format); + paragraphNode.append(textNode); + const element = textNode.createDOM(editorConfig); + + expect(element.outerHTML).toBe(expectedHTML); + }); + }, + ); + }); + }); + + describe('updateDOM()', () => { + test.each([ + [ + 'different tags', + { + format: IS_BOLD, + mode: 'normal', + text: 'My text node', + }, + { + format: IS_ITALIC, + mode: 'normal', + text: 'My text node', + }, + { + expectedHTML: null, + result: true, + }, + ], + [ + 'no change in tags', + { + format: IS_BOLD, + mode: 'normal', + text: 'My text node', + }, + { + format: IS_BOLD, + mode: 'normal', + text: 'My text node', + }, + { + expectedHTML: 'My text node', + result: false, + }, + ], + [ + 'change in text', + { + format: IS_BOLD, + mode: 'normal', + text: 'My text node', + }, + { + format: IS_BOLD, + mode: 'normal', + text: 'My new text node', + }, + { + expectedHTML: + 'My new text node', + result: false, + }, + ], + [ + 'removing code block', + { + format: IS_CODE | IS_BOLD, + mode: 'normal', + text: 'My text node', + }, + { + format: IS_BOLD, + mode: 'normal', + text: 'My new text node', + }, + { + expectedHTML: null, + result: true, + }, + ], + ])( + '%s', + async ( + _desc, + {text: prevText, mode: prevMode, format: prevFormat}, + {text: nextText, mode: nextMode, format: nextFormat}, + {result, expectedHTML}, + ) => { + await update(() => { + const prevTextNode = $createTextNode(prevText); + prevTextNode.setMode(prevMode as TextModeType); + prevTextNode.setFormat(prevFormat); + const element = prevTextNode.createDOM(editorConfig); + const textNode = $createTextNode(nextText); + textNode.setMode(nextMode as TextModeType); + textNode.setFormat(nextFormat); + + expect(textNode.updateDOM(prevTextNode, element, editorConfig)).toBe( + result, + ); + // Only need to bother about DOM element contents if updateDOM() + // returns false. + if (!result) { + expect(element.outerHTML).toBe(expectedHTML); + } + }); + }, + ); + }); + + test('mergeWithSibling', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + const textNode1 = $createTextNode('1'); + const textNode2 = $createTextNode('2'); + const textNode3 = $createTextNode('3'); + paragraph.append(textNode1, textNode2, textNode3); + textNode2.select(); + + const selection = $getSelection(); + textNode2.mergeWithSibling(textNode1); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor.getNode()).toBe(textNode2); + expect(selection.anchor.offset).toBe(1); + expect(selection.focus.offset).toBe(1); + + textNode2.mergeWithSibling(textNode3); + + expect(selection.anchor.getNode()).toBe(textNode2); + expect(selection.anchor.offset).toBe(1); + expect(selection.focus.offset).toBe(1); + }); + + expect(getEditorStateTextContent(editor.getEditorState())).toBe('123'); + }); +}); diff --git a/resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts b/resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts new file mode 100644 index 000000000..ff3b7cbf1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// invariant(condition, message) will refine types based on "condition", and +// if "condition" is false will throw an error. This function is special-cased +// in flow itself, so we can't name it anything else. +export default function invariant( + cond?: boolean, + message?: string, + ...args: string[] +): asserts cond { + if (cond) { + return; + } + + throw new Error( + args.reduce((msg, arg) => msg.replace('%s', String(arg)), message || ''), + ); +} diff --git a/resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts b/resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts new file mode 100644 index 000000000..78db6aafd --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/canUseDOM.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export const CAN_USE_DOM: boolean = + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined'; diff --git a/resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts b/resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts new file mode 100644 index 000000000..642e070e1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/caretFromPoint.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function caretFromPoint( + x: number, + y: number, +): null | { + offset: number; + node: Node; +} { + if (typeof document.caretRangeFromPoint !== 'undefined') { + const range = document.caretRangeFromPoint(x, y); + if (range === null) { + return null; + } + return { + node: range.startContainer, + offset: range.startOffset, + }; + // @ts-ignore + } else if (document.caretPositionFromPoint !== 'undefined') { + // @ts-ignore FF - no types + const range = document.caretPositionFromPoint(x, y); + if (range === null) { + return null; + } + return { + node: range.offsetNode, + offset: range.offset, + }; + } else { + // Gracefully handle IE + return null; + } +} diff --git a/resources/js/wysiwyg/lexical/core/shared/environment.ts b/resources/js/wysiwyg/lexical/core/shared/environment.ts new file mode 100644 index 000000000..c05d33221 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/environment.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; + +declare global { + interface Document { + documentMode?: unknown; + } + + interface Window { + MSStream?: unknown; + } +} + +const documentMode = + CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null; + +export const IS_APPLE: boolean = + CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform); + +export const IS_FIREFOX: boolean = + CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent); + +export const CAN_USE_BEFORE_INPUT: boolean = + CAN_USE_DOM && 'InputEvent' in window && !documentMode + ? 'getTargetRanges' in new window.InputEvent('input') + : false; + +export const IS_SAFARI: boolean = + CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent); + +export const IS_IOS: boolean = + CAN_USE_DOM && + /iPad|iPhone|iPod/.test(navigator.userAgent) && + !window.MSStream; + +export const IS_ANDROID: boolean = + CAN_USE_DOM && /Android/.test(navigator.userAgent); + +// Keep these in case we need to use them in the future. +// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform); +export const IS_CHROME: boolean = + CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent); +// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode; + +export const IS_ANDROID_CHROME: boolean = + CAN_USE_DOM && IS_ANDROID && IS_CHROME; + +export const IS_APPLE_WEBKIT = + CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME; diff --git a/resources/js/wysiwyg/lexical/core/shared/invariant.ts b/resources/js/wysiwyg/lexical/core/shared/invariant.ts new file mode 100644 index 000000000..0e73848ba --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/invariant.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// invariant(condition, message) will refine types based on "condition", and +// if "condition" is false will throw an error. This function is special-cased +// in flow itself, so we can't name it anything else. +export default function invariant( + cond?: boolean, + message?: string, + ...args: string[] +): asserts cond { + if (cond) { + return; + } + + throw new Error( + 'Internal Lexical error: invariant() is meant to be replaced at compile ' + + 'time. There is no runtime version. Error: ' + + message, + ); +} diff --git a/resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts b/resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts new file mode 100644 index 000000000..22ea3a940 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/normalizeClassNames.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function normalizeClassNames( + ...classNames: Array +): Array { + const rval = []; + for (const className of classNames) { + if (className && typeof className === 'string') { + for (const [s] of className.matchAll(/\S+/g)) { + rval.push(s); + } + } + } + return rval; +} diff --git a/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts b/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts new file mode 100644 index 000000000..8e086744d --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import * as React from 'react'; +import * as ReactTestUtils from 'react-dom/test-utils'; + +/** + * React 19 moved act from react-dom/test-utils to react + * https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-react-dom-test-utils + */ +export const act = + 'act' in React + ? (React.act as typeof ReactTestUtils.act) + : ReactTestUtils.act; diff --git a/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts b/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts new file mode 100644 index 000000000..9685cd89e --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +// Webpack + React 17 fails to compile on the usage of `React.startTransition` or +// `React["startTransition"]` even if it's behind a feature detection of +// `"startTransition" in React`. Moving this to a constant avoids the issue :/ +const START_TRANSITION = 'startTransition'; + +export function startTransition(callback: () => void) { + if (START_TRANSITION in React) { + React[START_TRANSITION](callback); + } else { + callback(); + } +} diff --git a/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts b/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts new file mode 100644 index 000000000..39f3d3b33 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/simpleDiffWithCursor.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function simpleDiffWithCursor( + a: string, + b: string, + cursor: number, +): {index: number; insert: string; remove: number} { + const aLength = a.length; + const bLength = b.length; + let left = 0; // number of same characters counting from left + let right = 0; // number of same characters counting from right + // Iterate left to the right until we find a changed character + // First iteration considers the current cursor position + while ( + left < aLength && + left < bLength && + a[left] === b[left] && + left < cursor + ) { + left++; + } + // Iterate right to the left until we find a changed character + while ( + right + left < aLength && + right + left < bLength && + a[aLength - right - 1] === b[bLength - right - 1] + ) { + right++; + } + // Try to iterate left further to the right without caring about the current cursor position + while ( + right + left < aLength && + right + left < bLength && + a[left] === b[left] + ) { + left++; + } + return { + index: left, + insert: b.slice(left, bLength - right), + remove: aLength - left - right, + }; +} diff --git a/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts b/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts new file mode 100644 index 000000000..6149879cb --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {useEffect, useLayoutEffect} from 'react'; +import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; + +// This workaround is no longer necessary in React 19, +// but we currently support React >=17.x +// https://github.com/facebook/react/pull/26395 +const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM + ? useLayoutEffect + : useEffect; + +export default useLayoutEffectImpl; diff --git a/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts b/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts new file mode 100644 index 000000000..d29e99e02 --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/shared/warnOnlyOnce.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function warnOnlyOnce(message: string) { + if (!__DEV__) { + return; + } + let run = false; + return () => { + if (!run) { + console.warn(message); + } + run = true; + }; +} diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts new file mode 100644 index 000000000..afa65708d --- /dev/null +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -0,0 +1,212 @@ +/** + * @jest-environment node + */ + +// Jest environment should be at the very top of the file. overriding environment for this test +// to ensure that headless editor works within node environment +// https://jestjs.io/docs/configuration#testenvironment-string + +/* eslint-disable header/header */ + +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {EditorState, LexicalEditor, RangeSelection} from 'lexical'; + +import {$generateHtmlFromNodes} from '@lexical/html'; +import {JSDOM} from 'jsdom'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + COMMAND_PRIORITY_NORMAL, + CONTROLLED_TEXT_INSERTION_COMMAND, + ParagraphNode, +} from 'lexical'; + +import {createHeadlessEditor} from '../..'; + +describe('LexicalHeadlessEditor', () => { + let editor: LexicalEditor; + + async function update(updateFn: () => void) { + editor.update(updateFn); + await Promise.resolve(); + } + + function assertEditorState( + editorState: EditorState, + nodes: Record[], + ) { + const nodesFromState = Array.from(editorState._nodeMap.values()); + expect(nodesFromState).toEqual( + nodes.map((node) => expect.objectContaining(node)), + ); + } + + beforeEach(() => { + editor = createHeadlessEditor({ + namespace: '', + onError: (error) => { + throw error; + }, + }); + }); + + it('should be headless environment', async () => { + expect(typeof window === 'undefined').toBe(true); + expect(typeof document === 'undefined').toBe(true); + expect(typeof navigator === 'undefined').toBe(true); + }); + + it('can update editor', async () => { + await update(() => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('Hello').toggleFormat('bold'), + $createTextNode('world'), + ), + ); + }); + + assertEditorState(editor.getEditorState(), [ + { + __key: 'root', + }, + { + __type: 'paragraph', + }, + { + __format: 1, + __text: 'Hello', + __type: 'text', + }, + { + __format: 0, + __text: 'world', + __type: 'text', + }, + ]); + }); + + it('can set editor state from json', async () => { + editor.setEditorState( + editor.parseEditorState( + '{"root":{"children":[{"children":[{"detail":0,"format":1,"mode":"normal","style":"","text":"Hello","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}', + ), + ); + + assertEditorState(editor.getEditorState(), [ + { + __key: 'root', + }, + { + __type: 'paragraph', + }, + { + __format: 1, + __text: 'Hello', + __type: 'text', + }, + { + __format: 0, + __text: 'world', + __type: 'text', + }, + ]); + }); + + it('can register listeners', async () => { + const onUpdate = jest.fn(); + const onCommand = jest.fn(); + const onTransform = jest.fn(); + const onTextContent = jest.fn(); + + editor.registerUpdateListener(onUpdate); + editor.registerCommand( + CONTROLLED_TEXT_INSERTION_COMMAND, + onCommand, + COMMAND_PRIORITY_NORMAL, + ); + editor.registerNodeTransform(ParagraphNode, onTransform); + editor.registerTextContentListener(onTextContent); + + await update(() => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('Hello').toggleFormat('bold'), + $createTextNode('world'), + ), + ); + editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, 'foo'); + }); + + expect(onUpdate).toBeCalled(); + expect(onCommand).toBeCalledWith('foo', expect.anything()); + expect(onTransform).toBeCalledWith( + expect.objectContaining({__type: 'paragraph'}), + ); + expect(onTextContent).toBeCalledWith('Helloworld'); + }); + + it('can preserve selection for pending editor state (within update loop)', async () => { + await update(() => { + const textNode = $createTextNode('Hello world'); + $getRoot().append($createParagraphNode().append(textNode)); + textNode.select(1, 2); + }); + + await update(() => { + const selection = $getSelection() as RangeSelection; + expect(selection.anchor).toEqual( + expect.objectContaining({offset: 1, type: 'text'}), + ); + expect(selection.focus).toEqual( + expect.objectContaining({offset: 2, type: 'text'}), + ); + }); + }); + + function setupDom() { + const jsdom = new JSDOM(); + + const _window = global.window; + const _document = global.document; + + // @ts-expect-error + global.window = jsdom.window; + global.document = jsdom.window.document; + + return () => { + global.window = _window; + global.document = _document; + }; + } + + it('can generate html from the nodes when dom is set', async () => { + editor.setEditorState( + // "hello world" + editor.parseEditorState( + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + ), + ); + + const cleanup = setupDom(); + + const html = editor + .getEditorState() + .read(() => $generateHtmlFromNodes(editor, null)); + + cleanup(); + + expect(html).toBe( + '

    hello world

    ', + ); + }); +}); diff --git a/resources/js/wysiwyg/lexical/headless/index.ts b/resources/js/wysiwyg/lexical/headless/index.ts new file mode 100644 index 000000000..2b8eddb8e --- /dev/null +++ b/resources/js/wysiwyg/lexical/headless/index.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {CreateEditorArgs, LexicalEditor} from 'lexical'; + +import {createEditor} from 'lexical'; + +/** + * Generates a headless editor that allows lexical to be used without the need for a DOM, eg in Node.js. + * Throws an error when unsupported methods are used. + * @param editorConfig - The optional lexical editor configuration. + * @returns - The configured headless editor. + */ +export function createHeadlessEditor( + editorConfig?: CreateEditorArgs, +): LexicalEditor { + const editor = createEditor(editorConfig); + editor._headless = true; + + const unsupportedMethods = [ + 'registerDecoratorListener', + 'registerRootListener', + 'registerMutationListener', + 'getRootElement', + 'setRootElement', + 'getElementByKey', + 'focus', + 'blur', + ] as const; + + unsupportedMethods.forEach((method: typeof unsupportedMethods[number]) => { + editor[method] = () => { + throw new Error(`${method} is not supported in headless mode`); + }; + }); + + return editor; +} diff --git a/resources/js/wysiwyg/lexical/history/index.ts b/resources/js/wysiwyg/lexical/history/index.ts new file mode 100644 index 000000000..8c731d3aa --- /dev/null +++ b/resources/js/wysiwyg/lexical/history/index.ts @@ -0,0 +1,501 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {EditorState, LexicalEditor, LexicalNode, NodeKey} from 'lexical'; + +import {mergeRegister} from '@lexical/utils'; +import { + $isRangeSelection, + $isRootNode, + $isTextNode, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + CLEAR_EDITOR_COMMAND, + CLEAR_HISTORY_COMMAND, + COMMAND_PRIORITY_EDITOR, + REDO_COMMAND, + UNDO_COMMAND, +} from 'lexical'; + +type MergeAction = 0 | 1 | 2; +const HISTORY_MERGE = 0; +const HISTORY_PUSH = 1; +const DISCARD_HISTORY_CANDIDATE = 2; + +type ChangeType = 0 | 1 | 2 | 3 | 4; +const OTHER = 0; +const COMPOSING_CHARACTER = 1; +const INSERT_CHARACTER_AFTER_SELECTION = 2; +const DELETE_CHARACTER_BEFORE_SELECTION = 3; +const DELETE_CHARACTER_AFTER_SELECTION = 4; + +export type HistoryStateEntry = { + editor: LexicalEditor; + editorState: EditorState; +}; +export type HistoryState = { + current: null | HistoryStateEntry; + redoStack: Array; + undoStack: Array; +}; + +type IntentionallyMarkedAsDirtyElement = boolean; + +function getDirtyNodes( + editorState: EditorState, + dirtyLeaves: Set, + dirtyElements: Map, +): Array { + const nodeMap = editorState._nodeMap; + const nodes = []; + + for (const dirtyLeafKey of dirtyLeaves) { + const dirtyLeaf = nodeMap.get(dirtyLeafKey); + + if (dirtyLeaf !== undefined) { + nodes.push(dirtyLeaf); + } + } + + for (const [dirtyElementKey, intentionallyMarkedAsDirty] of dirtyElements) { + if (!intentionallyMarkedAsDirty) { + continue; + } + + const dirtyElement = nodeMap.get(dirtyElementKey); + + if (dirtyElement !== undefined && !$isRootNode(dirtyElement)) { + nodes.push(dirtyElement); + } + } + + return nodes; +} + +function getChangeType( + prevEditorState: null | EditorState, + nextEditorState: EditorState, + dirtyLeavesSet: Set, + dirtyElementsSet: Map, + isComposing: boolean, +): ChangeType { + if ( + prevEditorState === null || + (dirtyLeavesSet.size === 0 && dirtyElementsSet.size === 0 && !isComposing) + ) { + return OTHER; + } + + const nextSelection = nextEditorState._selection; + const prevSelection = prevEditorState._selection; + + if (isComposing) { + return COMPOSING_CHARACTER; + } + + if ( + !$isRangeSelection(nextSelection) || + !$isRangeSelection(prevSelection) || + !prevSelection.isCollapsed() || + !nextSelection.isCollapsed() + ) { + return OTHER; + } + + const dirtyNodes = getDirtyNodes( + nextEditorState, + dirtyLeavesSet, + dirtyElementsSet, + ); + + if (dirtyNodes.length === 0) { + return OTHER; + } + + // Catching the case when inserting new text node into an element (e.g. first char in paragraph/list), + // or after existing node. + if (dirtyNodes.length > 1) { + const nextNodeMap = nextEditorState._nodeMap; + const nextAnchorNode = nextNodeMap.get(nextSelection.anchor.key); + const prevAnchorNode = nextNodeMap.get(prevSelection.anchor.key); + + if ( + nextAnchorNode && + prevAnchorNode && + !prevEditorState._nodeMap.has(nextAnchorNode.__key) && + $isTextNode(nextAnchorNode) && + nextAnchorNode.__text.length === 1 && + nextSelection.anchor.offset === 1 + ) { + return INSERT_CHARACTER_AFTER_SELECTION; + } + + return OTHER; + } + + const nextDirtyNode = dirtyNodes[0]; + + const prevDirtyNode = prevEditorState._nodeMap.get(nextDirtyNode.__key); + + if ( + !$isTextNode(prevDirtyNode) || + !$isTextNode(nextDirtyNode) || + prevDirtyNode.__mode !== nextDirtyNode.__mode + ) { + return OTHER; + } + + const prevText = prevDirtyNode.__text; + const nextText = nextDirtyNode.__text; + + if (prevText === nextText) { + return OTHER; + } + + const nextAnchor = nextSelection.anchor; + const prevAnchor = prevSelection.anchor; + + if (nextAnchor.key !== prevAnchor.key || nextAnchor.type !== 'text') { + return OTHER; + } + + const nextAnchorOffset = nextAnchor.offset; + const prevAnchorOffset = prevAnchor.offset; + const textDiff = nextText.length - prevText.length; + + if (textDiff === 1 && prevAnchorOffset === nextAnchorOffset - 1) { + return INSERT_CHARACTER_AFTER_SELECTION; + } + + if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset + 1) { + return DELETE_CHARACTER_BEFORE_SELECTION; + } + + if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset) { + return DELETE_CHARACTER_AFTER_SELECTION; + } + + return OTHER; +} + +function isTextNodeUnchanged( + key: NodeKey, + prevEditorState: EditorState, + nextEditorState: EditorState, +): boolean { + const prevNode = prevEditorState._nodeMap.get(key); + const nextNode = nextEditorState._nodeMap.get(key); + + const prevSelection = prevEditorState._selection; + const nextSelection = nextEditorState._selection; + const isDeletingLine = + $isRangeSelection(prevSelection) && + $isRangeSelection(nextSelection) && + prevSelection.anchor.type === 'element' && + prevSelection.focus.type === 'element' && + nextSelection.anchor.type === 'text' && + nextSelection.focus.type === 'text'; + + if ( + !isDeletingLine && + $isTextNode(prevNode) && + $isTextNode(nextNode) && + prevNode.__parent === nextNode.__parent + ) { + // This has the assumption that object key order won't change if the + // content did not change, which should normally be safe given + // the manner in which nodes and exportJSON are typically implemented. + return ( + JSON.stringify(prevEditorState.read(() => prevNode.exportJSON())) === + JSON.stringify(nextEditorState.read(() => nextNode.exportJSON())) + ); + } + return false; +} + +function createMergeActionGetter( + editor: LexicalEditor, + delay: number, +): ( + prevEditorState: null | EditorState, + nextEditorState: EditorState, + currentHistoryEntry: null | HistoryStateEntry, + dirtyLeaves: Set, + dirtyElements: Map, + tags: Set, +) => MergeAction { + let prevChangeTime = Date.now(); + let prevChangeType = OTHER; + + return ( + prevEditorState, + nextEditorState, + currentHistoryEntry, + dirtyLeaves, + dirtyElements, + tags, + ) => { + const changeTime = Date.now(); + + // If applying changes from history stack there's no need + // to run history logic again, as history entries already calculated + if (tags.has('historic')) { + prevChangeType = OTHER; + prevChangeTime = changeTime; + return DISCARD_HISTORY_CANDIDATE; + } + + const changeType = getChangeType( + prevEditorState, + nextEditorState, + dirtyLeaves, + dirtyElements, + editor.isComposing(), + ); + + const mergeAction = (() => { + const isSameEditor = + currentHistoryEntry === null || currentHistoryEntry.editor === editor; + const shouldPushHistory = tags.has('history-push'); + const shouldMergeHistory = + !shouldPushHistory && isSameEditor && tags.has('history-merge'); + + if (shouldMergeHistory) { + return HISTORY_MERGE; + } + + if (prevEditorState === null) { + return HISTORY_PUSH; + } + + const selection = nextEditorState._selection; + const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0; + + if (!hasDirtyNodes) { + if (selection !== null) { + return HISTORY_MERGE; + } + + return DISCARD_HISTORY_CANDIDATE; + } + + if ( + shouldPushHistory === false && + changeType !== OTHER && + changeType === prevChangeType && + changeTime < prevChangeTime + delay && + isSameEditor + ) { + return HISTORY_MERGE; + } + + // A single node might have been marked as dirty, but not have changed + // due to some node transform reverting the change. + if (dirtyLeaves.size === 1) { + const dirtyLeafKey = Array.from(dirtyLeaves)[0]; + if ( + isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState) + ) { + return HISTORY_MERGE; + } + } + + return HISTORY_PUSH; + })(); + + prevChangeTime = changeTime; + prevChangeType = changeType; + + return mergeAction; + }; +} + +function redo(editor: LexicalEditor, historyState: HistoryState): void { + const redoStack = historyState.redoStack; + const undoStack = historyState.undoStack; + + if (redoStack.length !== 0) { + const current = historyState.current; + + if (current !== null) { + undoStack.push(current); + editor.dispatchCommand(CAN_UNDO_COMMAND, true); + } + + const historyStateEntry = redoStack.pop(); + + if (redoStack.length === 0) { + editor.dispatchCommand(CAN_REDO_COMMAND, false); + } + + historyState.current = historyStateEntry || null; + + if (historyStateEntry) { + historyStateEntry.editor.setEditorState(historyStateEntry.editorState, { + tag: 'historic', + }); + } + } +} + +function undo(editor: LexicalEditor, historyState: HistoryState): void { + const redoStack = historyState.redoStack; + const undoStack = historyState.undoStack; + const undoStackLength = undoStack.length; + + if (undoStackLength !== 0) { + const current = historyState.current; + const historyStateEntry = undoStack.pop(); + + if (current !== null) { + redoStack.push(current); + editor.dispatchCommand(CAN_REDO_COMMAND, true); + } + + if (undoStack.length === 0) { + editor.dispatchCommand(CAN_UNDO_COMMAND, false); + } + + historyState.current = historyStateEntry || null; + + if (historyStateEntry) { + historyStateEntry.editor.setEditorState(historyStateEntry.editorState, { + tag: 'historic', + }); + } + } +} + +function clearHistory(historyState: HistoryState) { + historyState.undoStack = []; + historyState.redoStack = []; + historyState.current = null; +} + +/** + * Registers necessary listeners to manage undo/redo history stack and related editor commands. + * It returns `unregister` callback that cleans up all listeners and should be called on editor unmount. + * @param editor - The lexical editor. + * @param historyState - The history state, containing the current state and the undo/redo stack. + * @param delay - The time (in milliseconds) the editor should delay generating a new history stack, + * instead of merging the current changes with the current stack. + * @returns The listeners cleanup callback function. + */ +export function registerHistory( + editor: LexicalEditor, + historyState: HistoryState, + delay: number, +): () => void { + const getMergeAction = createMergeActionGetter(editor, delay); + + const applyChange = ({ + editorState, + prevEditorState, + dirtyLeaves, + dirtyElements, + tags, + }: { + editorState: EditorState; + prevEditorState: EditorState; + dirtyElements: Map; + dirtyLeaves: Set; + tags: Set; + }): void => { + const current = historyState.current; + const redoStack = historyState.redoStack; + const undoStack = historyState.undoStack; + const currentEditorState = current === null ? null : current.editorState; + + if (current !== null && editorState === currentEditorState) { + return; + } + + const mergeAction = getMergeAction( + prevEditorState, + editorState, + current, + dirtyLeaves, + dirtyElements, + tags, + ); + + if (mergeAction === HISTORY_PUSH) { + if (redoStack.length !== 0) { + historyState.redoStack = []; + editor.dispatchCommand(CAN_REDO_COMMAND, false); + } + + if (current !== null) { + undoStack.push({ + ...current, + }); + editor.dispatchCommand(CAN_UNDO_COMMAND, true); + } + } else if (mergeAction === DISCARD_HISTORY_CANDIDATE) { + return; + } + + // Else we merge + historyState.current = { + editor, + editorState, + }; + }; + + const unregister = mergeRegister( + editor.registerCommand( + UNDO_COMMAND, + () => { + undo(editor, historyState); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + REDO_COMMAND, + () => { + redo(editor, historyState); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + CLEAR_EDITOR_COMMAND, + () => { + clearHistory(historyState); + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + CLEAR_HISTORY_COMMAND, + () => { + clearHistory(historyState); + editor.dispatchCommand(CAN_REDO_COMMAND, false); + editor.dispatchCommand(CAN_UNDO_COMMAND, false); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerUpdateListener(applyChange), + ); + + return unregister; +} + +/** + * Creates an empty history state. + * @returns - The empty history state, as an object. + */ +export function createEmptyHistoryState(): HistoryState { + return { + current: null, + redoStack: [], + undoStack: [], + }; +} diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts new file mode 100644 index 000000000..55d120bdd --- /dev/null +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -0,0 +1,212 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +//@ts-ignore-next-line +import type {RangeSelection} from 'lexical'; + +import {createHeadlessEditor} from '@lexical/headless'; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import {LinkNode} from '@lexical/link'; +import {ListItemNode, ListNode} from '@lexical/list'; +import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, +} from 'lexical'; + +describe('HTML', () => { + type Input = Array<{ + name: string; + html: string; + initializeEditorState: () => void; + }>; + + const HTML_SERIALIZE: Input = [ + { + html: '


    ', + initializeEditorState: () => { + $getRoot().append($createParagraphNode()); + }, + name: 'Empty editor state', + }, + ]; + for (const {name, html, initializeEditorState} of HTML_SERIALIZE) { + test(`[Lexical -> HTML]: ${name}`, () => { + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + LinkNode, + ], + }); + + editor.update(initializeEditorState, { + discrete: true, + }); + + expect( + editor.getEditorState().read(() => $generateHtmlFromNodes(editor)), + ).toBe(html); + }); + } + + test(`[Lexical -> HTML]: Use provided selection`, () => { + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + LinkNode, + ], + }); + + let selection: RangeSelection | null = null; + + editor.update( + () => { + const root = $getRoot(); + const p1 = $createParagraphNode(); + const text1 = $createTextNode('Hello'); + p1.append(text1); + const p2 = $createParagraphNode(); + const text2 = $createTextNode('World'); + p2.append(text2); + root.append(p1).append(p2); + // Root + // - ParagraphNode + // -- TextNode "Hello" + // - ParagraphNode + // -- TextNode "World" + p1.select(0, text1.getTextContentSize()); + selection = $createRangeSelection(); + selection.setTextNodeRange(text2, 0, text2, text2.getTextContentSize()); + }, + { + discrete: true, + }, + ); + + let html = ''; + + editor.update(() => { + html = $generateHtmlFromNodes(editor, selection); + }); + + expect(html).toBe('World'); + }); + + test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => { + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + LinkNode, + ], + }); + + editor.update( + () => { + const root = $getRoot(); + const p1 = $createParagraphNode(); + const text1 = $createTextNode('Hello'); + p1.append(text1); + const p2 = $createParagraphNode(); + const text2 = $createTextNode('World'); + p2.append(text2); + root.append(p1).append(p2); + // Root + // - ParagraphNode + // -- TextNode "Hello" + // - ParagraphNode + // -- TextNode "World" + p1.select(0, text1.getTextContentSize()); + }, + { + discrete: true, + }, + ); + + let html = ''; + + editor.update(() => { + html = $generateHtmlFromNodes(editor); + }); + + expect(html).toBe( + '

    Hello

    World

    ', + ); + }); + + test(`If alignment is set on the paragraph, don't overwrite from parent empty format`, () => { + const editor = createHeadlessEditor(); + const parser = new DOMParser(); + const rightAlignedParagraphInDiv = + '

    Hello world!

    '; + + editor.update( + () => { + const root = $getRoot(); + const dom = parser.parseFromString( + rightAlignedParagraphInDiv, + 'text/html', + ); + const nodes = $generateNodesFromDOM(editor, dom); + root.append(...nodes); + }, + {discrete: true}, + ); + + let html = ''; + + editor.update(() => { + html = $generateHtmlFromNodes(editor); + }); + + expect(html).toBe( + '

    Hello world!

    ', + ); + }); + + test(`If alignment is set on the paragraph, it should take precedence over its parent block alignment`, () => { + const editor = createHeadlessEditor(); + const parser = new DOMParser(); + const rightAlignedParagraphInDiv = + '

    Hello world!

    '; + + editor.update( + () => { + const root = $getRoot(); + const dom = parser.parseFromString( + rightAlignedParagraphInDiv, + 'text/html', + ); + const nodes = $generateNodesFromDOM(editor, dom); + root.append(...nodes); + }, + {discrete: true}, + ); + + let html = ''; + + editor.update(() => { + html = $generateHtmlFromNodes(editor); + }); + + expect(html).toBe( + '

    Hello world!

    ', + ); + }); +}); diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts new file mode 100644 index 000000000..2975315cc --- /dev/null +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -0,0 +1,376 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + BaseSelection, + DOMChildConversion, + DOMConversion, + DOMConversionFn, + ElementFormatType, + LexicalEditor, + LexicalNode, +} from 'lexical'; + +import {$sliceSelectedTextNodeContent} from '@lexical/selection'; +import {isBlockDomNode, isHTMLElement} from '@lexical/utils'; +import { + $cloneWithProperties, + $createLineBreakNode, + $createParagraphNode, + $getRoot, + $isBlockElementNode, + $isElementNode, + $isRootOrShadowRoot, + $isTextNode, + ArtificialNode__DO_NOT_USE, + ElementNode, + isInlineDomNode, +} from 'lexical'; + +/** + * How you parse your html string to get a document is left up to you. In the browser you can use the native + * DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom + * or an equivalent library and pass in the document here. + */ +export function $generateNodesFromDOM( + editor: LexicalEditor, + dom: Document, +): Array { + const elements = dom.body ? dom.body.childNodes : []; + let lexicalNodes: Array = []; + const allArtificialNodes: Array = []; + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (!IGNORE_TAGS.has(element.nodeName)) { + const lexicalNode = $createNodesFromDOM( + element, + editor, + allArtificialNodes, + false, + ); + if (lexicalNode !== null) { + lexicalNodes = lexicalNodes.concat(lexicalNode); + } + } + } + $unwrapArtificalNodes(allArtificialNodes); + + return lexicalNodes; +} + +export function $generateHtmlFromNodes( + editor: LexicalEditor, + selection?: BaseSelection | null, +): string { + if ( + typeof document === 'undefined' || + (typeof window === 'undefined' && typeof global.window === 'undefined') + ) { + throw new Error( + 'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom before calling this function.', + ); + } + + const container = document.createElement('div'); + const root = $getRoot(); + const topLevelChildren = root.getChildren(); + + for (let i = 0; i < topLevelChildren.length; i++) { + const topLevelNode = topLevelChildren[i]; + $appendNodesToHTML(editor, topLevelNode, container, selection); + } + + return container.innerHTML; +} + +function $appendNodesToHTML( + editor: LexicalEditor, + currentNode: LexicalNode, + parentElement: HTMLElement | DocumentFragment, + selection: BaseSelection | null = null, +): boolean { + let shouldInclude = + selection !== null ? currentNode.isSelected(selection) : true; + const shouldExclude = + $isElementNode(currentNode) && currentNode.excludeFromCopy('html'); + let target = currentNode; + + if (selection !== null) { + let clone = $cloneWithProperties(currentNode); + clone = + $isTextNode(clone) && selection !== null + ? $sliceSelectedTextNodeContent(selection, clone) + : clone; + target = clone; + } + const children = $isElementNode(target) ? target.getChildren() : []; + const registeredNode = editor._nodes.get(target.getType()); + let exportOutput; + + // Use HTMLConfig overrides, if available. + if (registeredNode && registeredNode.exportDOM !== undefined) { + exportOutput = registeredNode.exportDOM(editor, target); + } else { + exportOutput = target.exportDOM(editor); + } + + const {element, after} = exportOutput; + + if (!element) { + return false; + } + + const fragment = document.createDocumentFragment(); + + for (let i = 0; i < children.length; i++) { + const childNode = children[i]; + const shouldIncludeChild = $appendNodesToHTML( + editor, + childNode, + fragment, + selection, + ); + + if ( + !shouldInclude && + $isElementNode(currentNode) && + shouldIncludeChild && + currentNode.extractWithChild(childNode, selection, 'html') + ) { + shouldInclude = true; + } + } + + if (shouldInclude && !shouldExclude) { + if (isHTMLElement(element)) { + element.append(fragment); + } + parentElement.append(element); + + if (after) { + const newElement = after.call(target, element); + if (newElement) { + element.replaceWith(newElement); + } + } + } else { + parentElement.append(fragment); + } + + return shouldInclude; +} + +function getConversionFunction( + domNode: Node, + editor: LexicalEditor, +): DOMConversionFn | null { + const {nodeName} = domNode; + + const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase()); + + let currentConversion: DOMConversion | null = null; + + if (cachedConversions !== undefined) { + for (const cachedConversion of cachedConversions) { + const domConversion = cachedConversion(domNode); + if ( + domConversion !== null && + (currentConversion === null || + (currentConversion.priority || 0) < (domConversion.priority || 0)) + ) { + currentConversion = domConversion; + } + } + } + + return currentConversion !== null ? currentConversion.conversion : null; +} + +const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); + +function $createNodesFromDOM( + node: Node, + editor: LexicalEditor, + allArtificialNodes: Array, + hasBlockAncestorLexicalNode: boolean, + forChildMap: Map = new Map(), + parentLexicalNode?: LexicalNode | null | undefined, +): Array { + let lexicalNodes: Array = []; + + if (IGNORE_TAGS.has(node.nodeName)) { + return lexicalNodes; + } + + let currentLexicalNode = null; + const transformFunction = getConversionFunction(node, editor); + const transformOutput = transformFunction + ? transformFunction(node as HTMLElement) + : null; + let postTransform = null; + + if (transformOutput !== null) { + postTransform = transformOutput.after; + const transformNodes = transformOutput.node; + currentLexicalNode = Array.isArray(transformNodes) + ? transformNodes[transformNodes.length - 1] + : transformNodes; + + if (currentLexicalNode !== null) { + for (const [, forChildFunction] of forChildMap) { + currentLexicalNode = forChildFunction( + currentLexicalNode, + parentLexicalNode, + ); + + if (!currentLexicalNode) { + break; + } + } + + if (currentLexicalNode) { + lexicalNodes.push( + ...(Array.isArray(transformNodes) + ? transformNodes + : [currentLexicalNode]), + ); + } + } + + if (transformOutput.forChild != null) { + forChildMap.set(node.nodeName, transformOutput.forChild); + } + } + + // If the DOM node doesn't have a transformer, we don't know what + // to do with it but we still need to process any childNodes. + const children = node.childNodes; + let childLexicalNodes = []; + + const hasBlockAncestorLexicalNodeForChildren = + currentLexicalNode != null && $isRootOrShadowRoot(currentLexicalNode) + ? false + : (currentLexicalNode != null && + $isBlockElementNode(currentLexicalNode)) || + hasBlockAncestorLexicalNode; + + for (let i = 0; i < children.length; i++) { + childLexicalNodes.push( + ...$createNodesFromDOM( + children[i], + editor, + allArtificialNodes, + hasBlockAncestorLexicalNodeForChildren, + new Map(forChildMap), + currentLexicalNode, + ), + ); + } + + if (postTransform != null) { + childLexicalNodes = postTransform(childLexicalNodes); + } + + if (isBlockDomNode(node)) { + if (!hasBlockAncestorLexicalNodeForChildren) { + childLexicalNodes = wrapContinuousInlines( + node, + childLexicalNodes, + $createParagraphNode, + ); + } else { + childLexicalNodes = wrapContinuousInlines(node, childLexicalNodes, () => { + const artificialNode = new ArtificialNode__DO_NOT_USE(); + allArtificialNodes.push(artificialNode); + return artificialNode; + }); + } + } + + if (currentLexicalNode == null) { + if (childLexicalNodes.length > 0) { + // If it hasn't been converted to a LexicalNode, we hoist its children + // up to the same level as it. + lexicalNodes = lexicalNodes.concat(childLexicalNodes); + } else { + if (isBlockDomNode(node) && isDomNodeBetweenTwoInlineNodes(node)) { + // Empty block dom node that hasnt been converted, we replace it with a linebreak if its between inline nodes + lexicalNodes = lexicalNodes.concat($createLineBreakNode()); + } + } + } else { + if ($isElementNode(currentLexicalNode)) { + // If the current node is a ElementNode after conversion, + // we can append all the children to it. + currentLexicalNode.append(...childLexicalNodes); + } + } + + return lexicalNodes; +} + +function wrapContinuousInlines( + domNode: Node, + nodes: Array, + createWrapperFn: () => ElementNode, +): Array { + const textAlign = (domNode as HTMLElement).style + .textAlign as ElementFormatType; + const out: Array = []; + let continuousInlines: Array = []; + // wrap contiguous inline child nodes in para + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isBlockElementNode(node)) { + if (textAlign && !node.getFormat()) { + node.setFormat(textAlign); + } + out.push(node); + } else { + continuousInlines.push(node); + if ( + i === nodes.length - 1 || + (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) + ) { + const wrapper = createWrapperFn(); + wrapper.setFormat(textAlign); + wrapper.append(...continuousInlines); + out.push(wrapper); + continuousInlines = []; + } + } + } + return out; +} + +function $unwrapArtificalNodes( + allArtificialNodes: Array, +) { + for (const node of allArtificialNodes) { + if (node.getNextSibling() instanceof ArtificialNode__DO_NOT_USE) { + node.insertAfter($createLineBreakNode()); + } + } + // Replace artificial node with it's children + for (const node of allArtificialNodes) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + } +} + +function isDomNodeBetweenTwoInlineNodes(node: Node): boolean { + if (node.nextSibling == null || node.previousSibling == null) { + return false; + } + return ( + isInlineDomNode(node.nextSibling) && isInlineDomNode(node.previousSibling) + ); +} diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts new file mode 100644 index 000000000..8ef2aa051 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -0,0 +1,506 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createAutoLinkNode, + $isAutoLinkNode, + $toggleLink, + AutoLinkNode, + SerializedAutoLinkNode, +} from '@lexical/link'; +import { + $getRoot, + $selectAll, + ParagraphNode, + SerializedParagraphNode, + TextNode, +} from 'lexical/src'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + link: 'my-autolink-class', + text: { + bold: 'my-bold-class', + code: 'my-code-class', + hashtag: 'my-hashtag-class', + italic: 'my-italic-class', + strikethrough: 'my-strikethrough-class', + underline: 'my-underline-class', + underlineStrikethrough: 'my-underline-strikethrough-class', + }, + }, +}); + +describe('LexicalAutoAutoLinkNode tests', () => { + initializeUnitTest((testEnv) => { + test('AutoAutoLinkNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const actutoLinkNode = new AutoLinkNode('/'); + + expect(actutoLinkNode.__type).toBe('autolink'); + expect(actutoLinkNode.__url).toBe('/'); + expect(actutoLinkNode.__isUnlinked).toBe(false); + }); + + expect(() => new AutoLinkNode('')).toThrow(); + }); + + test('AutoAutoLinkNode.constructor with isUnlinked param set to true', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const actutoLinkNode = new AutoLinkNode('/', { + isUnlinked: true, + }); + + expect(actutoLinkNode.__type).toBe('autolink'); + expect(actutoLinkNode.__url).toBe('/'); + expect(actutoLinkNode.__isUnlinked).toBe(true); + }); + + expect(() => new AutoLinkNode('')).toThrow(); + }); + + /// + + test('LineBreakNode.clone()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('/'); + + const clone = AutoLinkNode.clone(autoLinkNode); + + expect(clone).not.toBe(autoLinkNode); + expect(clone).toStrictEqual(autoLinkNode); + }); + }); + + test('AutoLinkNode.getURL()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.getURL()).toBe('https://example.com/foo'); + }); + }); + + test('AutoLinkNode.setURL()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.getURL()).toBe('https://example.com/foo'); + + autoLinkNode.setURL('https://example.com/bar'); + + expect(autoLinkNode.getURL()).toBe('https://example.com/bar'); + }); + }); + + test('AutoLinkNode.getTarget()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + target: '_blank', + }); + + expect(autoLinkNode.getTarget()).toBe('_blank'); + }); + }); + + test('AutoLinkNode.setTarget()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + target: '_blank', + }); + + expect(autoLinkNode.getTarget()).toBe('_blank'); + + autoLinkNode.setTarget('_self'); + + expect(autoLinkNode.getTarget()).toBe('_self'); + }); + }); + + test('AutoLinkNode.getRel()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + }); + + expect(autoLinkNode.getRel()).toBe('noopener noreferrer'); + }); + }); + + test('AutoLinkNode.setRel()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener', + target: '_blank', + }); + + expect(autoLinkNode.getRel()).toBe('noopener'); + + autoLinkNode.setRel('noopener noreferrer'); + + expect(autoLinkNode.getRel()).toBe('noopener noreferrer'); + }); + }); + + test('AutoLinkNode.getTitle()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + title: 'Hello world', + }); + + expect(autoLinkNode.getTitle()).toBe('Hello world'); + }); + }); + + test('AutoLinkNode.setTitle()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + title: 'Hello world', + }); + + expect(autoLinkNode.getTitle()).toBe('Hello world'); + + autoLinkNode.setTitle('World hello'); + + expect(autoLinkNode.getTitle()).toBe('World hello'); + }); + }); + + test('AutoLinkNode.getIsUnlinked()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('/', { + isUnlinked: true, + }); + expect(autoLinkNode.getIsUnlinked()).toBe(true); + }); + }); + + test('AutoLinkNode.setIsUnlinked()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('/'); + expect(autoLinkNode.getIsUnlinked()).toBe(false); + autoLinkNode.setIsUnlinked(true); + expect(autoLinkNode.getIsUnlinked()).toBe(true); + }); + }); + + test('AutoLinkNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + expect( + autoLinkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe(''); + }); + }); + + test('AutoLinkNode.createDOM() for unlinked', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + isUnlinked: true, + }); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + `${autoLinkNode.getTextContent()}`, + ); + }); + }); + + test('AutoLinkNode.createDOM() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + expect( + autoLinkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => { + const {editor} = testEnv; + + await editor.update(() => { + // eslint-disable-next-line no-script-url + const autoLinkNode = new AutoLinkNode('javascript:alert(0)'); + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + const domElement = autoLinkNode.createDOM(editorConfig); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar'); + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const domElement = autoLinkNode.createDOM(editorConfig); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', { + rel: 'noopener', + target: '_self', + title: 'World hello', + }); + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const domElement = autoLinkNode.createDOM(editorConfig); + + expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newNode = new AutoLinkNode('https://example.com/bar'); + const result = newNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('AutoLinkNode.updateDOM() with isUnlinked "true"', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + isUnlinked: false, + }); + + const domElement = autoLinkNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( + '', + ); + + const newAutoLinkNode = new AutoLinkNode('https://example.com/bar', { + isUnlinked: true, + }); + const newDomElement = newAutoLinkNode.createDOM(editorConfig); + expect(newDomElement.outerHTML).toBe( + `${newAutoLinkNode.getTextContent()}`, + ); + + const result = newAutoLinkNode.updateDOM( + autoLinkNode, + domElement, + editorConfig, + ); + expect(result).toBe(true); + }); + }); + + test('AutoLinkNode.canInsertTextBefore()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + + expect(autoLinkNode.canInsertTextBefore()).toBe(false); + }); + }); + + test('AutoLinkNode.canInsertTextAfter()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + expect(autoLinkNode.canInsertTextAfter()).toBe(false); + }); + }); + + test('$createAutoLinkNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo'); + const createdAutoLinkNode = $createAutoLinkNode( + 'https://example.com/foo', + ); + + expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type); + expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent); + expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url); + expect(autoLinkNode.__isUnlinked).toEqual( + createdAutoLinkNode.__isUnlinked, + ); + expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key); + }); + }); + + test('$createAutoLinkNode() with target, rel, isUnlinked and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const autoLinkNode = new AutoLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const createdAutoLinkNode = $createAutoLinkNode( + 'https://example.com/foo', + { + isUnlinked: true, + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }, + ); + + expect(autoLinkNode.__type).toEqual(createdAutoLinkNode.__type); + expect(autoLinkNode.__parent).toEqual(createdAutoLinkNode.__parent); + expect(autoLinkNode.__url).toEqual(createdAutoLinkNode.__url); + expect(autoLinkNode.__target).toEqual(createdAutoLinkNode.__target); + expect(autoLinkNode.__rel).toEqual(createdAutoLinkNode.__rel); + expect(autoLinkNode.__title).toEqual(createdAutoLinkNode.__title); + expect(autoLinkNode.__key).not.toEqual(createdAutoLinkNode.__key); + expect(autoLinkNode.__isUnlinked).not.toEqual( + createdAutoLinkNode.__isUnlinked, + ); + }); + }); + + test('$isAutoLinkNode()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const autoLinkNode = new AutoLinkNode(''); + expect($isAutoLinkNode(autoLinkNode)).toBe(true); + }); + }); + + test('$toggleLink applies the title attribute when creating', async () => { + const {editor} = testEnv; + await editor.update(() => { + const p = new ParagraphNode(); + p.append(new TextNode('Some text')); + $getRoot().append(p); + }); + + await editor.update(() => { + $selectAll(); + $toggleLink('https://lexical.dev/', {title: 'Lexical Website'}); + }); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + const link = paragraph.children[0] as SerializedAutoLinkNode; + expect(link.title).toBe('Lexical Website'); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts new file mode 100644 index 000000000..3ad6cbad8 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts @@ -0,0 +1,413 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createLinkNode, + $isLinkNode, + $toggleLink, + LinkNode, + SerializedLinkNode, +} from '@lexical/link'; +import { + $getRoot, + $selectAll, + ParagraphNode, + SerializedParagraphNode, + TextNode, +} from 'lexical/src'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + link: 'my-link-class', + text: { + bold: 'my-bold-class', + code: 'my-code-class', + hashtag: 'my-hashtag-class', + italic: 'my-italic-class', + strikethrough: 'my-strikethrough-class', + underline: 'my-underline-class', + underlineStrikethrough: 'my-underline-strikethrough-class', + }, + }, +}); + +describe('LexicalLinkNode tests', () => { + initializeUnitTest((testEnv) => { + test('LinkNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('/'); + + expect(linkNode.__type).toBe('link'); + expect(linkNode.__url).toBe('/'); + }); + + expect(() => new LinkNode('')).toThrow(); + }); + + test('LineBreakNode.clone()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('/'); + + const linkNodeClone = LinkNode.clone(linkNode); + + expect(linkNodeClone).not.toBe(linkNode); + expect(linkNodeClone).toStrictEqual(linkNode); + }); + }); + + test('LinkNode.getURL()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + expect(linkNode.getURL()).toBe('https://example.com/foo'); + }); + }); + + test('LinkNode.setURL()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + expect(linkNode.getURL()).toBe('https://example.com/foo'); + + linkNode.setURL('https://example.com/bar'); + + expect(linkNode.getURL()).toBe('https://example.com/bar'); + }); + }); + + test('LinkNode.getTarget()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + target: '_blank', + }); + + expect(linkNode.getTarget()).toBe('_blank'); + }); + }); + + test('LinkNode.setTarget()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + target: '_blank', + }); + + expect(linkNode.getTarget()).toBe('_blank'); + + linkNode.setTarget('_self'); + + expect(linkNode.getTarget()).toBe('_self'); + }); + }); + + test('LinkNode.getRel()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + }); + + expect(linkNode.getRel()).toBe('noopener noreferrer'); + }); + }); + + test('LinkNode.setRel()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + rel: 'noopener', + target: '_blank', + }); + + expect(linkNode.getRel()).toBe('noopener'); + + linkNode.setRel('noopener noreferrer'); + + expect(linkNode.getRel()).toBe('noopener noreferrer'); + }); + }); + + test('LinkNode.getTitle()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + title: 'Hello world', + }); + + expect(linkNode.getTitle()).toBe('Hello world'); + }); + }); + + test('LinkNode.setTitle()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + title: 'Hello world', + }); + + expect(linkNode.getTitle()).toBe('Hello world'); + + linkNode.setTitle('World hello'); + + expect(linkNode.getTitle()).toBe('World hello'); + }); + }); + + test('LinkNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + expect( + linkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe(''); + }); + }); + + test('LinkNode.createDOM() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + expect( + linkNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe( + '', + ); + }); + }); + + test('LinkNode.createDOM() sanitizes javascript: URLs', async () => { + const {editor} = testEnv; + + await editor.update(() => { + // eslint-disable-next-line no-script-url + const linkNode = new LinkNode('javascript:alert(0)'); + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + }); + }); + + test('LinkNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + const domElement = linkNode.createDOM(editorConfig); + + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newLinkNode = new LinkNode('https://example.com/bar'); + const result = newLinkNode.updateDOM( + linkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('LinkNode.updateDOM() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const domElement = linkNode.createDOM(editorConfig); + + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newLinkNode = new LinkNode('https://example.com/bar', { + rel: 'noopener', + target: '_self', + title: 'World hello', + }); + const result = newLinkNode.updateDOM( + linkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('LinkNode.updateDOM() with undefined target, undefined rel and undefined title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const domElement = linkNode.createDOM(editorConfig); + + expect(linkNode.createDOM(editorConfig).outerHTML).toBe( + '', + ); + + const newLinkNode = new LinkNode('https://example.com/bar'); + const result = newLinkNode.updateDOM( + linkNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '', + ); + }); + }); + + test('LinkNode.canInsertTextBefore()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + expect(linkNode.canInsertTextBefore()).toBe(false); + }); + }); + + test('LinkNode.canInsertTextAfter()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + expect(linkNode.canInsertTextAfter()).toBe(false); + }); + }); + + test('$createLinkNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo'); + + const createdLinkNode = $createLinkNode('https://example.com/foo'); + + expect(linkNode.__type).toEqual(createdLinkNode.__type); + expect(linkNode.__parent).toEqual(createdLinkNode.__parent); + expect(linkNode.__url).toEqual(createdLinkNode.__url); + expect(linkNode.__key).not.toEqual(createdLinkNode.__key); + }); + }); + + test('$createLinkNode() with target, rel and title', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + const createdLinkNode = $createLinkNode('https://example.com/foo', { + rel: 'noopener noreferrer', + target: '_blank', + title: 'Hello world', + }); + + expect(linkNode.__type).toEqual(createdLinkNode.__type); + expect(linkNode.__parent).toEqual(createdLinkNode.__parent); + expect(linkNode.__url).toEqual(createdLinkNode.__url); + expect(linkNode.__target).toEqual(createdLinkNode.__target); + expect(linkNode.__rel).toEqual(createdLinkNode.__rel); + expect(linkNode.__title).toEqual(createdLinkNode.__title); + expect(linkNode.__key).not.toEqual(createdLinkNode.__key); + }); + }); + + test('$isLinkNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const linkNode = new LinkNode(''); + + expect($isLinkNode(linkNode)).toBe(true); + }); + }); + + test('$toggleLink applies the title attribute when creating', async () => { + const {editor} = testEnv; + await editor.update(() => { + const p = new ParagraphNode(); + p.append(new TextNode('Some text')); + $getRoot().append(p); + }); + + await editor.update(() => { + $selectAll(); + $toggleLink('https://lexical.dev/', {title: 'Lexical Website'}); + }); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + const link = paragraph.children[0] as SerializedLinkNode; + expect(link.title).toBe('Lexical Website'); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts new file mode 100644 index 000000000..fe2b97570 --- /dev/null +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -0,0 +1,610 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + BaseSelection, + DOMConversionMap, + DOMConversionOutput, + EditorConfig, + LexicalCommand, + LexicalNode, + NodeKey, + RangeSelection, + SerializedElementNode, +} from 'lexical'; + +import {addClassNamesToElement, isHTMLAnchorElement} from '@lexical/utils'; +import { + $applyNodeReplacement, + $getSelection, + $isElementNode, + $isRangeSelection, + createCommand, + ElementNode, + Spread, +} from 'lexical'; + +export type LinkAttributes = { + rel?: null | string; + target?: null | string; + title?: null | string; +}; + +export type AutoLinkAttributes = Partial< + Spread +>; + +export type SerializedLinkNode = Spread< + { + url: string; + }, + Spread +>; + +type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement; + +const SUPPORTED_URL_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'mailto:', + 'sms:', + 'tel:', +]); + +/** @noInheritDoc */ +export class LinkNode extends ElementNode { + /** @internal */ + __url: string; + /** @internal */ + __target: null | string; + /** @internal */ + __rel: null | string; + /** @internal */ + __title: null | string; + + static getType(): string { + return 'link'; + } + + static clone(node: LinkNode): LinkNode { + return new LinkNode( + node.__url, + {rel: node.__rel, target: node.__target, title: node.__title}, + node.__key, + ); + } + + constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) { + super(key); + const {target = null, rel = null, title = null} = attributes; + this.__url = url; + this.__target = target; + this.__rel = rel; + this.__title = title; + } + + createDOM(config: EditorConfig): LinkHTMLElementType { + const element = document.createElement('a'); + element.href = this.sanitizeUrl(this.__url); + if (this.__target !== null) { + element.target = this.__target; + } + if (this.__rel !== null) { + element.rel = this.__rel; + } + if (this.__title !== null) { + element.title = this.__title; + } + addClassNamesToElement(element, config.theme.link); + return element; + } + + updateDOM( + prevNode: LinkNode, + anchor: LinkHTMLElementType, + config: EditorConfig, + ): boolean { + if (anchor instanceof HTMLAnchorElement) { + const url = this.__url; + const target = this.__target; + const rel = this.__rel; + const title = this.__title; + if (url !== prevNode.__url) { + anchor.href = url; + } + + if (target !== prevNode.__target) { + if (target) { + anchor.target = target; + } else { + anchor.removeAttribute('target'); + } + } + + if (rel !== prevNode.__rel) { + if (rel) { + anchor.rel = rel; + } else { + anchor.removeAttribute('rel'); + } + } + + if (title !== prevNode.__title) { + if (title) { + anchor.title = title; + } else { + anchor.removeAttribute('title'); + } + } + } + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + a: (node: Node) => ({ + conversion: $convertAnchorElement, + priority: 1, + }), + }; + } + + static importJSON( + serializedNode: SerializedLinkNode | SerializedAutoLinkNode, + ): LinkNode { + const node = $createLinkNode(serializedNode.url, { + rel: serializedNode.rel, + target: serializedNode.target, + title: serializedNode.title, + }); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + sanitizeUrl(url: string): string { + try { + const parsedUrl = new URL(url); + // eslint-disable-next-line no-script-url + if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) { + return 'about:blank'; + } + } catch { + return url; + } + return url; + } + + exportJSON(): SerializedLinkNode | SerializedAutoLinkNode { + return { + ...super.exportJSON(), + rel: this.getRel(), + target: this.getTarget(), + title: this.getTitle(), + type: 'link', + url: this.getURL(), + version: 1, + }; + } + + getURL(): string { + return this.getLatest().__url; + } + + setURL(url: string): void { + const writable = this.getWritable(); + writable.__url = url; + } + + getTarget(): null | string { + return this.getLatest().__target; + } + + setTarget(target: null | string): void { + const writable = this.getWritable(); + writable.__target = target; + } + + getRel(): null | string { + return this.getLatest().__rel; + } + + setRel(rel: null | string): void { + const writable = this.getWritable(); + writable.__rel = rel; + } + + getTitle(): null | string { + return this.getLatest().__title; + } + + setTitle(title: null | string): void { + const writable = this.getWritable(); + writable.__title = title; + } + + insertNewAfter( + _: RangeSelection, + restoreSelection = true, + ): null | ElementNode { + const linkNode = $createLinkNode(this.__url, { + rel: this.__rel, + target: this.__target, + title: this.__title, + }); + this.insertAfter(linkNode, restoreSelection); + return linkNode; + } + + canInsertTextBefore(): false { + return false; + } + + canInsertTextAfter(): false { + return false; + } + + canBeEmpty(): false { + return false; + } + + isInline(): true { + return true; + } + + extractWithChild( + child: LexicalNode, + selection: BaseSelection, + destination: 'clone' | 'html', + ): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + + return ( + this.isParentOf(anchorNode) && + this.isParentOf(focusNode) && + selection.getTextContent().length > 0 + ); + } + + isEmailURI(): boolean { + return this.__url.startsWith('mailto:'); + } + + isWebSiteURI(): boolean { + return ( + this.__url.startsWith('https://') || this.__url.startsWith('http://') + ); + } +} + +function $convertAnchorElement(domNode: Node): DOMConversionOutput { + let node = null; + if (isHTMLAnchorElement(domNode)) { + const content = domNode.textContent; + if ((content !== null && content !== '') || domNode.children.length > 0) { + node = $createLinkNode(domNode.getAttribute('href') || '', { + rel: domNode.getAttribute('rel'), + target: domNode.getAttribute('target'), + title: domNode.getAttribute('title'), + }); + } + } + return {node}; +} + +/** + * Takes a URL and creates a LinkNode. + * @param url - The URL the LinkNode should direct to. + * @param attributes - Optional HTML a tag attributes \\{ target, rel, title \\} + * @returns The LinkNode. + */ +export function $createLinkNode( + url: string, + attributes?: LinkAttributes, +): LinkNode { + return $applyNodeReplacement(new LinkNode(url, attributes)); +} + +/** + * Determines if node is a LinkNode. + * @param node - The node to be checked. + * @returns true if node is a LinkNode, false otherwise. + */ +export function $isLinkNode( + node: LexicalNode | null | undefined, +): node is LinkNode { + return node instanceof LinkNode; +} + +export type SerializedAutoLinkNode = Spread< + { + isUnlinked: boolean; + }, + SerializedLinkNode +>; + +// Custom node type to override `canInsertTextAfter` that will +// allow typing within the link +export class AutoLinkNode extends LinkNode { + /** @internal */ + /** Indicates whether the autolink was ever unlinked. **/ + __isUnlinked: boolean; + + constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) { + super(url, attributes, key); + this.__isUnlinked = + attributes.isUnlinked !== undefined && attributes.isUnlinked !== null + ? attributes.isUnlinked + : false; + } + + static getType(): string { + return 'autolink'; + } + + static clone(node: AutoLinkNode): AutoLinkNode { + return new AutoLinkNode( + node.__url, + { + isUnlinked: node.__isUnlinked, + rel: node.__rel, + target: node.__target, + title: node.__title, + }, + node.__key, + ); + } + + getIsUnlinked(): boolean { + return this.__isUnlinked; + } + + setIsUnlinked(value: boolean) { + const self = this.getWritable(); + self.__isUnlinked = value; + return self; + } + + createDOM(config: EditorConfig): LinkHTMLElementType { + if (this.__isUnlinked) { + return document.createElement('span'); + } else { + return super.createDOM(config); + } + } + + updateDOM( + prevNode: AutoLinkNode, + anchor: LinkHTMLElementType, + config: EditorConfig, + ): boolean { + return ( + super.updateDOM(prevNode, anchor, config) || + prevNode.__isUnlinked !== this.__isUnlinked + ); + } + + static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode { + const node = $createAutoLinkNode(serializedNode.url, { + isUnlinked: serializedNode.isUnlinked, + rel: serializedNode.rel, + target: serializedNode.target, + title: serializedNode.title, + }); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + static importDOM(): null { + // TODO: Should link node should handle the import over autolink? + return null; + } + + exportJSON(): SerializedAutoLinkNode { + return { + ...super.exportJSON(), + isUnlinked: this.__isUnlinked, + type: 'autolink', + version: 1, + }; + } + + insertNewAfter( + selection: RangeSelection, + restoreSelection = true, + ): null | ElementNode { + const element = this.getParentOrThrow().insertNewAfter( + selection, + restoreSelection, + ); + if ($isElementNode(element)) { + const linkNode = $createAutoLinkNode(this.__url, { + isUnlinked: this.__isUnlinked, + rel: this.__rel, + target: this.__target, + title: this.__title, + }); + element.append(linkNode); + return linkNode; + } + return null; + } +} + +/** + * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated + * during typing, which is especially useful when a button to generate a LinkNode is not practical. + * @param url - The URL the LinkNode should direct to. + * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\} + * @returns The LinkNode. + */ +export function $createAutoLinkNode( + url: string, + attributes?: AutoLinkAttributes, +): AutoLinkNode { + return $applyNodeReplacement(new AutoLinkNode(url, attributes)); +} + +/** + * Determines if node is an AutoLinkNode. + * @param node - The node to be checked. + * @returns true if node is an AutoLinkNode, false otherwise. + */ +export function $isAutoLinkNode( + node: LexicalNode | null | undefined, +): node is AutoLinkNode { + return node instanceof AutoLinkNode; +} + +export const TOGGLE_LINK_COMMAND: LexicalCommand< + string | ({url: string} & LinkAttributes) | null +> = createCommand('TOGGLE_LINK_COMMAND'); + +/** + * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null, + * but saves any children and brings them up to the parent node. + * @param url - The URL the link directs to. + * @param attributes - Optional HTML a tag attributes. \\{ target, rel, title \\} + */ +export function $toggleLink( + url: null | string, + attributes: LinkAttributes = {}, +): void { + const {target, title} = attributes; + const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel; + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + const nodes = selection.extract(); + + if (url === null) { + // Remove LinkNodes + nodes.forEach((node) => { + const parent = node.getParent(); + + if (!$isAutoLinkNode(parent) && $isLinkNode(parent)) { + const children = parent.getChildren(); + + for (let i = 0; i < children.length; i++) { + parent.insertBefore(children[i]); + } + + parent.remove(); + } + }); + } else { + // Add or merge LinkNodes + if (nodes.length === 1) { + const firstNode = nodes[0]; + // if the first node is a LinkNode or if its + // parent is a LinkNode, we update the URL, target and rel. + const linkNode = $getAncestor(firstNode, $isLinkNode); + if (linkNode !== null) { + linkNode.setURL(url); + if (target !== undefined) { + linkNode.setTarget(target); + } + if (rel !== null) { + linkNode.setRel(rel); + } + if (title !== undefined) { + linkNode.setTitle(title); + } + return; + } + } + + let prevParent: ElementNode | LinkNode | null = null; + let linkNode: LinkNode | null = null; + + nodes.forEach((node) => { + const parent = node.getParent(); + + if ( + parent === linkNode || + parent === null || + ($isElementNode(node) && !node.isInline()) + ) { + return; + } + + if ($isLinkNode(parent)) { + linkNode = parent; + parent.setURL(url); + if (target !== undefined) { + parent.setTarget(target); + } + if (rel !== null) { + linkNode.setRel(rel); + } + if (title !== undefined) { + linkNode.setTitle(title); + } + return; + } + + if (!parent.is(prevParent)) { + prevParent = parent; + linkNode = $createLinkNode(url, {rel, target, title}); + + if ($isLinkNode(parent)) { + if (node.getPreviousSibling() === null) { + parent.insertBefore(linkNode); + } else { + parent.insertAfter(linkNode); + } + } else { + node.insertBefore(linkNode); + } + } + + if ($isLinkNode(node)) { + if (node.is(linkNode)) { + return; + } + if (linkNode !== null) { + const children = node.getChildren(); + + for (let i = 0; i < children.length; i++) { + linkNode.append(children[i]); + } + } + + node.remove(); + return; + } + + if (linkNode !== null) { + linkNode.append(node); + } + }); + } +} +/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */ +export const toggleLink = $toggleLink; + +function $getAncestor( + node: LexicalNode, + predicate: (ancestor: LexicalNode) => ancestor is NodeType, +) { + let parent = node; + while (parent !== null && parent.getParent() !== null && !predicate(parent)) { + parent = parent.getParentOrThrow(); + } + return predicate(parent) ? parent : null; +} diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts new file mode 100644 index 000000000..7d12b5bd3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -0,0 +1,552 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {ListNode, ListType} from './'; +import type { + BaseSelection, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + EditorThemeClasses, + LexicalNode, + NodeKey, + ParagraphNode, + RangeSelection, + SerializedElementNode, + Spread, +} from 'lexical'; + +import { + addClassNamesToElement, + removeClassNamesFromElement, +} from '@lexical/utils'; +import { + $applyNodeReplacement, + $createParagraphNode, + $isElementNode, + $isParagraphNode, + $isRangeSelection, + ElementNode, + LexicalEditor, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import normalizeClassNames from 'lexical/shared/normalizeClassNames'; + +import {$createListNode, $isListNode} from './'; +import {$handleIndent, $handleOutdent, mergeLists} from './formatList'; +import {isNestedListNode} from './utils'; + +export type SerializedListItemNode = Spread< + { + checked: boolean | undefined; + value: number; + }, + SerializedElementNode +>; + +/** @noInheritDoc */ +export class ListItemNode extends ElementNode { + /** @internal */ + __value: number; + /** @internal */ + __checked?: boolean; + + static getType(): string { + return 'listitem'; + } + + static clone(node: ListItemNode): ListItemNode { + return new ListItemNode(node.__value, node.__checked, node.__key); + } + + constructor(value?: number, checked?: boolean, key?: NodeKey) { + super(key); + this.__value = value === undefined ? 1 : value; + this.__checked = checked; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('li'); + const parent = this.getParent(); + if ($isListNode(parent) && parent.getListType() === 'check') { + updateListItemChecked(element, this, null, parent); + } + element.value = this.__value; + $setListItemThemeClassNames(element, config.theme, this); + return element; + } + + updateDOM( + prevNode: ListItemNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + const parent = this.getParent(); + if ($isListNode(parent) && parent.getListType() === 'check') { + updateListItemChecked(dom, this, prevNode, parent); + } + // @ts-expect-error - this is always HTMLListItemElement + dom.value = this.__value; + $setListItemThemeClassNames(dom, config.theme, this); + + return false; + } + + static transform(): (node: LexicalNode) => void { + return (node: LexicalNode) => { + invariant($isListItemNode(node), 'node is not a ListItemNode'); + if (node.__checked == null) { + return; + } + const parent = node.getParent(); + if ($isListNode(parent)) { + if (parent.getListType() !== 'check' && node.getChecked() != null) { + node.setChecked(undefined); + } + } + }; + } + + static importDOM(): DOMConversionMap | null { + return { + li: () => ({ + conversion: $convertListItemElement, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedListItemNode): ListItemNode { + const node = $createListItemNode(); + node.setChecked(serializedNode.checked); + node.setValue(serializedNode.value); + node.setFormat(serializedNode.format); + node.setDirection(serializedNode.direction); + return node; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config); + element.style.textAlign = this.getFormatType(); + return { + element, + }; + } + + exportJSON(): SerializedListItemNode { + return { + ...super.exportJSON(), + checked: this.getChecked(), + type: 'listitem', + value: this.getValue(), + version: 1, + }; + } + + append(...nodes: LexicalNode[]): this { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if ($isElementNode(node) && this.canMergeWith(node)) { + const children = node.getChildren(); + this.append(...children); + node.remove(); + } else { + super.append(node); + } + } + + return this; + } + + replace( + replaceWithNode: N, + includeChildren?: boolean, + ): N { + if ($isListItemNode(replaceWithNode)) { + return super.replace(replaceWithNode); + } + this.setIndent(0); + const list = this.getParentOrThrow(); + if (!$isListNode(list)) { + return replaceWithNode; + } + if (list.__first === this.getKey()) { + list.insertBefore(replaceWithNode); + } else if (list.__last === this.getKey()) { + list.insertAfter(replaceWithNode); + } else { + // Split the list + const newList = $createListNode(list.getListType()); + let nextSibling = this.getNextSibling(); + while (nextSibling) { + const nodeToAppend = nextSibling; + nextSibling = nextSibling.getNextSibling(); + newList.append(nodeToAppend); + } + list.insertAfter(replaceWithNode); + replaceWithNode.insertAfter(newList); + } + if (includeChildren) { + invariant( + $isElementNode(replaceWithNode), + 'includeChildren should only be true for ElementNodes', + ); + this.getChildren().forEach((child: LexicalNode) => { + replaceWithNode.append(child); + }); + } + this.remove(); + if (list.getChildrenSize() === 0) { + list.remove(); + } + return replaceWithNode; + } + + insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode { + const listNode = this.getParentOrThrow(); + + if (!$isListNode(listNode)) { + invariant( + false, + 'insertAfter: list node is not parent of list item node', + ); + } + + if ($isListItemNode(node)) { + return super.insertAfter(node, restoreSelection); + } + + const siblings = this.getNextSiblings(); + + // Split the lists and insert the node in between them + listNode.insertAfter(node, restoreSelection); + + if (siblings.length !== 0) { + const newListNode = $createListNode(listNode.getListType()); + + siblings.forEach((sibling) => newListNode.append(sibling)); + + node.insertAfter(newListNode, restoreSelection); + } + + return node; + } + + remove(preserveEmptyParent?: boolean): void { + const prevSibling = this.getPreviousSibling(); + const nextSibling = this.getNextSibling(); + super.remove(preserveEmptyParent); + + if ( + prevSibling && + nextSibling && + isNestedListNode(prevSibling) && + isNestedListNode(nextSibling) + ) { + mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild()); + nextSibling.remove(); + } + } + + insertNewAfter( + _: RangeSelection, + restoreSelection = true, + ): ListItemNode | ParagraphNode { + const newElement = $createListItemNode( + this.__checked == null ? undefined : false, + ); + this.insertAfter(newElement, restoreSelection); + + return newElement; + } + + collapseAtStart(selection: RangeSelection): true { + const paragraph = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => paragraph.append(child)); + const listNode = this.getParentOrThrow(); + const listNodeParent = listNode.getParentOrThrow(); + const isIndented = $isListItemNode(listNodeParent); + + if (listNode.getChildrenSize() === 1) { + if (isIndented) { + // if the list node is nested, we just want to remove it, + // effectively unindenting it. + listNode.remove(); + listNodeParent.select(); + } else { + listNode.insertBefore(paragraph); + listNode.remove(); + // If we have selection on the list item, we'll need to move it + // to the paragraph + const anchor = selection.anchor; + const focus = selection.focus; + const key = paragraph.getKey(); + + if (anchor.type === 'element' && anchor.getNode().is(this)) { + anchor.set(key, anchor.offset, 'element'); + } + + if (focus.type === 'element' && focus.getNode().is(this)) { + focus.set(key, focus.offset, 'element'); + } + } + } else { + listNode.insertBefore(paragraph); + this.remove(); + } + + return true; + } + + getValue(): number { + const self = this.getLatest(); + + return self.__value; + } + + setValue(value: number): void { + const self = this.getWritable(); + self.__value = value; + } + + getChecked(): boolean | undefined { + const self = this.getLatest(); + + let listType: ListType | undefined; + + const parent = this.getParent(); + if ($isListNode(parent)) { + listType = parent.getListType(); + } + + return listType === 'check' ? Boolean(self.__checked) : undefined; + } + + setChecked(checked?: boolean): void { + const self = this.getWritable(); + self.__checked = checked; + } + + toggleChecked(): void { + this.setChecked(!this.__checked); + } + + getIndent(): number { + // If we don't have a parent, we are likely serializing + const parent = this.getParent(); + if (parent === null) { + return this.getLatest().__indent; + } + // ListItemNode should always have a ListNode for a parent. + let listNodeParent = parent.getParentOrThrow(); + let indentLevel = 0; + while ($isListItemNode(listNodeParent)) { + listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow(); + indentLevel++; + } + + return indentLevel; + } + + setIndent(indent: number): this { + invariant(typeof indent === 'number', 'Invalid indent value.'); + indent = Math.floor(indent); + invariant(indent >= 0, 'Indent value must be non-negative.'); + let currentIndent = this.getIndent(); + while (currentIndent !== indent) { + if (currentIndent < indent) { + $handleIndent(this); + currentIndent++; + } else { + $handleOutdent(this); + currentIndent--; + } + } + + return this; + } + + /** @deprecated @internal */ + canInsertAfter(node: LexicalNode): boolean { + return $isListItemNode(node); + } + + /** @deprecated @internal */ + canReplaceWith(replacement: LexicalNode): boolean { + return $isListItemNode(replacement); + } + + canMergeWith(node: LexicalNode): boolean { + return $isParagraphNode(node) || $isListItemNode(node); + } + + extractWithChild(child: LexicalNode, selection: BaseSelection): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + + return ( + this.isParentOf(anchorNode) && + this.isParentOf(focusNode) && + this.getTextContent().length === selection.getTextContent().length + ); + } + + isParentRequired(): true { + return true; + } + + createParentElementNode(): ElementNode { + return $createListNode('bullet'); + } + + canMergeWhenEmpty(): true { + return true; + } +} + +function $setListItemThemeClassNames( + dom: HTMLElement, + editorThemeClasses: EditorThemeClasses, + node: ListItemNode, +): void { + const classesToAdd = []; + const classesToRemove = []; + const listTheme = editorThemeClasses.list; + const listItemClassName = listTheme ? listTheme.listitem : undefined; + let nestedListItemClassName; + + if (listTheme && listTheme.nested) { + nestedListItemClassName = listTheme.nested.listitem; + } + + if (listItemClassName !== undefined) { + classesToAdd.push(...normalizeClassNames(listItemClassName)); + } + + if (listTheme) { + const parentNode = node.getParent(); + const isCheckList = + $isListNode(parentNode) && parentNode.getListType() === 'check'; + const checked = node.getChecked(); + + if (!isCheckList || checked) { + classesToRemove.push(listTheme.listitemUnchecked); + } + + if (!isCheckList || !checked) { + classesToRemove.push(listTheme.listitemChecked); + } + + if (isCheckList) { + classesToAdd.push( + checked ? listTheme.listitemChecked : listTheme.listitemUnchecked, + ); + } + } + + if (nestedListItemClassName !== undefined) { + const nestedListItemClasses = normalizeClassNames(nestedListItemClassName); + + if (node.getChildren().some((child) => $isListNode(child))) { + classesToAdd.push(...nestedListItemClasses); + } else { + classesToRemove.push(...nestedListItemClasses); + } + } + + if (classesToRemove.length > 0) { + removeClassNamesFromElement(dom, ...classesToRemove); + } + + if (classesToAdd.length > 0) { + addClassNamesToElement(dom, ...classesToAdd); + } +} + +function updateListItemChecked( + dom: HTMLElement, + listItemNode: ListItemNode, + prevListItemNode: ListItemNode | null, + listNode: ListNode, +): void { + // Only add attributes for leaf list items + if ($isListNode(listItemNode.getFirstChild())) { + dom.removeAttribute('role'); + dom.removeAttribute('tabIndex'); + dom.removeAttribute('aria-checked'); + } else { + dom.setAttribute('role', 'checkbox'); + dom.setAttribute('tabIndex', '-1'); + + if ( + !prevListItemNode || + listItemNode.__checked !== prevListItemNode.__checked + ) { + dom.setAttribute( + 'aria-checked', + listItemNode.getChecked() ? 'true' : 'false', + ); + } + } +} + +function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput { + const isGitHubCheckList = domNode.classList.contains('task-list-item'); + if (isGitHubCheckList) { + for (const child of domNode.children) { + if (child.tagName === 'INPUT') { + return $convertCheckboxInput(child); + } + } + } + + const ariaCheckedAttr = domNode.getAttribute('aria-checked'); + const checked = + ariaCheckedAttr === 'true' + ? true + : ariaCheckedAttr === 'false' + ? false + : undefined; + return {node: $createListItemNode(checked)}; +} + +function $convertCheckboxInput(domNode: Element): DOMConversionOutput { + const isCheckboxInput = domNode.getAttribute('type') === 'checkbox'; + if (!isCheckboxInput) { + return {node: null}; + } + const checked = domNode.hasAttribute('checked'); + return {node: $createListItemNode(checked)}; +} + +/** + * Creates a new List Item node, passing true/false will convert it to a checkbox input. + * @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively. + * @returns The new List Item. + */ +export function $createListItemNode(checked?: boolean): ListItemNode { + return $applyNodeReplacement(new ListItemNode(undefined, checked)); +} + +/** + * Checks to see if the node is a ListItemNode. + * @param node - The node to be checked. + * @returns true if the node is a ListItemNode, false otherwise. + */ +export function $isListItemNode( + node: LexicalNode | null | undefined, +): node is ListItemNode { + return node instanceof ListItemNode; +} diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts new file mode 100644 index 000000000..e22fbf771 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -0,0 +1,367 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + addClassNamesToElement, + isHTMLElement, + removeClassNamesFromElement, +} from '@lexical/utils'; +import { + $applyNodeReplacement, + $createTextNode, + $isElementNode, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + EditorThemeClasses, + ElementNode, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedElementNode, + Spread, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import normalizeClassNames from 'lexical/shared/normalizeClassNames'; + +import {$createListItemNode, $isListItemNode, ListItemNode} from '.'; +import { + mergeNextSiblingListIfSameType, + updateChildrenListItemValue, +} from './formatList'; +import {$getListDepth, $wrapInListItem} from './utils'; + +export type SerializedListNode = Spread< + { + listType: ListType; + start: number; + tag: ListNodeTagType; + }, + SerializedElementNode +>; + +export type ListType = 'number' | 'bullet' | 'check'; + +export type ListNodeTagType = 'ul' | 'ol'; + +/** @noInheritDoc */ +export class ListNode extends ElementNode { + /** @internal */ + __tag: ListNodeTagType; + /** @internal */ + __start: number; + /** @internal */ + __listType: ListType; + + static getType(): string { + return 'list'; + } + + static clone(node: ListNode): ListNode { + const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag]; + + return new ListNode(listType, node.__start, node.__key); + } + + constructor(listType: ListType, start: number, key?: NodeKey) { + super(key); + const _listType = TAG_TO_LIST_TYPE[listType] || listType; + this.__listType = _listType; + this.__tag = _listType === 'number' ? 'ol' : 'ul'; + this.__start = start; + } + + getTag(): ListNodeTagType { + return this.__tag; + } + + setListType(type: ListType): void { + const writable = this.getWritable(); + writable.__listType = type; + writable.__tag = type === 'number' ? 'ol' : 'ul'; + } + + getListType(): ListType { + return this.__listType; + } + + getStart(): number { + return this.__start; + } + + // View + + createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement { + const tag = this.__tag; + const dom = document.createElement(tag); + + if (this.__start !== 1) { + dom.setAttribute('start', String(this.__start)); + } + // @ts-expect-error Internal field. + dom.__lexicalListType = this.__listType; + $setListThemeClassNames(dom, config.theme, this); + + return dom; + } + + updateDOM( + prevNode: ListNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + if (prevNode.__tag !== this.__tag) { + return true; + } + + $setListThemeClassNames(dom, config.theme, this); + + return false; + } + + static transform(): (node: LexicalNode) => void { + return (node: LexicalNode) => { + invariant($isListNode(node), 'node is not a ListNode'); + mergeNextSiblingListIfSameType(node); + updateChildrenListItemValue(node); + }; + } + + static importDOM(): DOMConversionMap | null { + return { + ol: () => ({ + conversion: $convertListNode, + priority: 0, + }), + ul: () => ({ + conversion: $convertListNode, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedListNode): ListNode { + const node = $createListNode(serializedNode.listType, serializedNode.start); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + if (element && isHTMLElement(element)) { + if (this.__start !== 1) { + element.setAttribute('start', String(this.__start)); + } + if (this.__listType === 'check') { + element.setAttribute('__lexicalListType', 'check'); + } + } + return { + element, + }; + } + + exportJSON(): SerializedListNode { + return { + ...super.exportJSON(), + listType: this.getListType(), + start: this.getStart(), + tag: this.getTag(), + type: 'list', + version: 1, + }; + } + + canBeEmpty(): false { + return false; + } + + canIndent(): false { + return false; + } + + append(...nodesToAppend: LexicalNode[]): this { + for (let i = 0; i < nodesToAppend.length; i++) { + const currentNode = nodesToAppend[i]; + + if ($isListItemNode(currentNode)) { + super.append(currentNode); + } else { + const listItemNode = $createListItemNode(); + + if ($isListNode(currentNode)) { + listItemNode.append(currentNode); + } else if ($isElementNode(currentNode)) { + const textNode = $createTextNode(currentNode.getTextContent()); + listItemNode.append(textNode); + } else { + listItemNode.append(currentNode); + } + super.append(listItemNode); + } + } + return this; + } + + extractWithChild(child: LexicalNode): boolean { + return $isListItemNode(child); + } +} + +function $setListThemeClassNames( + dom: HTMLElement, + editorThemeClasses: EditorThemeClasses, + node: ListNode, +): void { + const classesToAdd = []; + const classesToRemove = []; + const listTheme = editorThemeClasses.list; + + if (listTheme !== undefined) { + const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || []; + const listDepth = $getListDepth(node) - 1; + const normalizedListDepth = listDepth % listLevelsClassNames.length; + const listLevelClassName = listLevelsClassNames[normalizedListDepth]; + const listClassName = listTheme[node.__tag]; + let nestedListClassName; + const nestedListTheme = listTheme.nested; + const checklistClassName = listTheme.checklist; + + if (nestedListTheme !== undefined && nestedListTheme.list) { + nestedListClassName = nestedListTheme.list; + } + + if (listClassName !== undefined) { + classesToAdd.push(listClassName); + } + + if (checklistClassName !== undefined && node.__listType === 'check') { + classesToAdd.push(checklistClassName); + } + + if (listLevelClassName !== undefined) { + classesToAdd.push(...normalizeClassNames(listLevelClassName)); + for (let i = 0; i < listLevelsClassNames.length; i++) { + if (i !== normalizedListDepth) { + classesToRemove.push(node.__tag + i); + } + } + } + + if (nestedListClassName !== undefined) { + const nestedListItemClasses = normalizeClassNames(nestedListClassName); + + if (listDepth > 1) { + classesToAdd.push(...nestedListItemClasses); + } else { + classesToRemove.push(...nestedListItemClasses); + } + } + } + + if (classesToRemove.length > 0) { + removeClassNamesFromElement(dom, ...classesToRemove); + } + + if (classesToAdd.length > 0) { + addClassNamesToElement(dom, ...classesToAdd); + } +} + +/* + * This function normalizes the children of a ListNode after the conversion from HTML, + * ensuring that they are all ListItemNodes and contain either a single nested ListNode + * or some other inline content. + */ +function $normalizeChildren(nodes: Array): Array { + const normalizedListItems: Array = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isListItemNode(node)) { + normalizedListItems.push(node); + const children = node.getChildren(); + if (children.length > 1) { + children.forEach((child) => { + if ($isListNode(child)) { + normalizedListItems.push($wrapInListItem(child)); + } + }); + } + } else { + normalizedListItems.push($wrapInListItem(node)); + } + } + return normalizedListItems; +} + +function isDomChecklist(domNode: HTMLElement) { + if ( + domNode.getAttribute('__lexicallisttype') === 'check' || + // is github checklist + domNode.classList.contains('contains-task-list') + ) { + return true; + } + // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting. + for (const child of domNode.childNodes) { + if (isHTMLElement(child) && child.hasAttribute('aria-checked')) { + return true; + } + } + return false; +} + +function $convertListNode(domNode: HTMLElement): DOMConversionOutput { + const nodeName = domNode.nodeName.toLowerCase(); + let node = null; + if (nodeName === 'ol') { + // @ts-ignore + const start = domNode.start; + node = $createListNode('number', start); + } else if (nodeName === 'ul') { + if (isDomChecklist(domNode)) { + node = $createListNode('check'); + } else { + node = $createListNode('bullet'); + } + } + + return { + after: $normalizeChildren, + node, + }; +} + +const TAG_TO_LIST_TYPE: Record = { + ol: 'number', + ul: 'bullet', +}; + +/** + * Creates a ListNode of listType. + * @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'. + * @param start - Where an ordered list starts its count, start = 1 if left undefined. + * @returns The new ListNode + */ +export function $createListNode(listType: ListType, start = 1): ListNode { + return $applyNodeReplacement(new ListNode(listType, start)); +} + +/** + * Checks to see if the node is a ListNode. + * @param node - The node to be checked. + * @returns true if the node is a ListNode, false otherwise. + */ +export function $isListNode( + node: LexicalNode | null | undefined, +): node is ListNode { + return node instanceof ListNode; +} diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts new file mode 100644 index 000000000..d36b8f1cb --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -0,0 +1,1365 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createRangeSelection, + $getRoot, + TextNode, +} from 'lexical'; +import { + expectHtmlToBeEqual, + html, + initializeUnitTest, +} from 'lexical/src/__tests__/utils'; + +import { + $createListItemNode, + $isListItemNode, + ListItemNode, + ListNode, +} from '../..'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + list: { + listitem: 'my-listItem-item-class', + nested: { + listitem: 'my-nested-list-listItem-class', + }, + }, + }, +}); + +describe('LexicalListItemNode tests', () => { + initializeUnitTest((testEnv) => { + test('ListItemNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect(listItemNode.getType()).toBe('listitem'); + + expect(listItemNode.getTextContent()).toBe(''); + }); + + expect(() => new ListItemNode()).toThrow(); + }); + + test('ListItemNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expectHtmlToBeEqual( + listItemNode.createDOM(editorConfig).outerHTML, + html` +
  • + `, + ); + + expectHtmlToBeEqual( + listItemNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + html` +
  • + `, + ); + }); + }); + + describe('ListItemNode.updateDOM()', () => { + test('base', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + const domElement = listItemNode.createDOM(editorConfig); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + const newListItemNode = new ListItemNode(); + + const result = newListItemNode.updateDOM( + listItemNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + }); + }); + + test('nested list', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const parentListNode = new ListNode('bullet', 1); + const parentlistItemNode = new ListItemNode(); + + parentListNode.append(parentlistItemNode); + const domElement = parentlistItemNode.createDOM(editorConfig); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + const nestedListNode = new ListNode('bullet', 1); + nestedListNode.append(new ListItemNode()); + parentlistItemNode.append(nestedListNode); + const result = parentlistItemNode.updateDOM( + parentlistItemNode, + domElement, + editorConfig, + ); + + expect(result).toBe(false); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + }); + }); + }); + + describe('ListItemNode.replace()', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; + + beforeEach(async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); + + listItemNode1.append(new TextNode('one')); + listItemNode2 = new ListItemNode(); + + listItemNode2.append(new TextNode('two')); + listItemNode3 = new ListItemNode(); + + listItemNode3.append(new TextNode('three')); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('another list item node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const newListItemNode = new ListItemNode(); + + newListItemNode.append(new TextNode('bar')); + listItemNode1.replace(newListItemNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + bar +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('first list item with a non list item node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + return; + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +


    +
      +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('last list item with a non list item node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode3.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    +


    +
    + `, + ); + }); + + test('middle list item with a non list item node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode2.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +


    +
      +
    • + three +
    • +
    +
    + `, + ); + }); + + test('the only list item with a non list item node', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +
    + `, + ); + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +


    +
    + `, + ); + }); + }); + + describe('ListItemNode.remove()', () => { + // - A + // - x + // - B + test('siblings are not nested', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + A +
    • +
    • + x +
    • +
    • + B +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + A +
    • +
    • + B +
    • +
    +
    + `, + ); + }); + + // - A + // - x + // - B + test('the previous sibling is nested', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • + B +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + B +
    • +
    +
    + `, + ); + }); + + // - A + // - x + // - B + test('the next sibling is nested', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + A +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + A +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - A + // - x + // - B + test('both siblings are nested', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - A1 + // - A2 + // - x + // - B + test('the previous sibling is nested deeper than the next sibling', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedlistItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedlistItem); + B_nestedlistItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      • + B +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - A + // - x + // - B1 + // - B2 + test('the next sibling is nested deeper than the previous sibling', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A +
      • +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + }); + + // - A1 + // - A2 + // - x + // - B1 + // - B2 + test('both siblings are deeply nested', async () => { + const {editor} = testEnv; + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + + await editor.update(() => x.remove()); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    +
    + `, + ); + }); + }); + + describe('ListItemNode.insertNewAfter(): non-empty list items', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; + + beforeEach(async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); + + listItemNode2 = new ListItemNode(); + + listItemNode3 = new ListItemNode(); + + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + listItemNode1.append(new TextNode('one')); + listItemNode2.append(new TextNode('two')); + listItemNode3.append(new TextNode('three')); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('first list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +

    • +
    • + two +
    • +
    • + three +
    • +
    +
    + `, + ); + }); + + test('last list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +

    • +
    +
    + `, + ); + }); + + test('middle list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    • + three +
    • +

    • +
    +
    + `, + ); + }); + + test('the only list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +
    + `, + ); + + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + testEnv.outerHTML, + html` +
    +
      +
    • + one +
    • +

    • +
    +
    + `, + ); + }); + }); + + test('$createListItemNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + const createdListItemNode = $createListItemNode(); + + expect(listItemNode.__type).toEqual(createdListItemNode.__type); + expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); + expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + }); + }); + + test('$isListItemNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect($isListItemNode(listItemNode)).toBe(true); + }); + }); + + describe('ListItemNode.setIndent()', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + + beforeEach(async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); + + listItemNode2 = new ListItemNode(); + + root.append(listNode); + listNode.append(listItemNode1, listItemNode2); + listItemNode1.append(new TextNode('one')); + listItemNode2.append(new TextNode('two')); + }); + }); + it('indents and outdents list item', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode1.setIndent(3); + }); + + await editor.update(() => { + expect(listItemNode1.getIndent()).toBe(3); + }); + + expectHtmlToBeEqual( + editor.getRootElement()!.innerHTML, + html` +
      +
    • +
        +
      • +
          +
        • +
            +
          • + one +
          • +
          +
        • +
        +
      • +
      +
    • +
    • + two +
    • +
    + `, + ); + + await editor.update(() => { + listItemNode1.setIndent(0); + }); + + await editor.update(() => { + expect(listItemNode1.getIndent()).toBe(0); + }); + + expectHtmlToBeEqual( + editor.getRootElement()!.innerHTML, + html` +
      +
    • + one +
    • +
    • + two +
    • +
    + `, + ); + }); + + it('handles fractional indent values', async () => { + const {editor} = testEnv; + + await editor.update(() => { + listItemNode1.setIndent(0.5); + }); + + await editor.update(() => { + expect(listItemNode1.getIndent()).toBe(0); + }); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts new file mode 100644 index 000000000..6abcbbd4c --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts @@ -0,0 +1,317 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {ParagraphNode, TextNode} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +import { + $createListItemNode, + $createListNode, + $isListItemNode, + $isListNode, + ListItemNode, + ListNode, +} from '../..'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + list: { + ol: 'my-ol-list-class', + olDepth: [ + 'my-ol-list-class-1', + 'my-ol-list-class-2', + 'my-ol-list-class-3', + 'my-ol-list-class-4', + 'my-ol-list-class-5', + 'my-ol-list-class-6', + 'my-ol-list-class-7', + ], + ul: 'my-ul-list-class', + ulDepth: [ + 'my-ul-list-class-1', + 'my-ul-list-class-2', + 'my-ul-list-class-3', + 'my-ul-list-class-4', + 'my-ul-list-class-5', + 'my-ul-list-class-6', + 'my-ul-list-class-7', + ], + }, + }, +}); + +describe('LexicalListNode tests', () => { + initializeUnitTest((testEnv) => { + test('ListNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + expect(listNode.getType()).toBe('list'); + expect(listNode.getTag()).toBe('ul'); + expect(listNode.getTextContent()).toBe(''); + }); + + // @ts-expect-error + expect(() => $createListNode()).toThrow(); + }); + + test('ListNode.getTag()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const ulListNode = $createListNode('bullet', 1); + expect(ulListNode.getTag()).toBe('ul'); + const olListNode = $createListNode('number', 1); + expect(olListNode.getTag()).toBe('ol'); + const checkListNode = $createListNode('check', 1); + expect(checkListNode.getTag()).toBe('ul'); + }); + }); + + test('ListNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + expect(listNode.createDOM(editorConfig).outerHTML).toBe( + '
      ', + ); + expect( + listNode.createDOM({ + namespace: '', + theme: { + list: {}, + }, + }).outerHTML, + ).toBe('
        '); + expect( + listNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
          '); + }); + }); + + test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode1 = $createListNode('bullet'); + const listNode2 = $createListNode('bullet'); + const listNode3 = $createListNode('bullet'); + const listNode4 = $createListNode('bullet'); + const listNode5 = $createListNode('bullet'); + const listNode6 = $createListNode('bullet'); + const listNode7 = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + const listItem4 = $createListItemNode(); + + listNode1.append(listItem1); + listItem1.append(listNode2); + listNode2.append(listItem2); + listItem2.append(listNode3); + listNode3.append(listItem3); + listItem3.append(listNode4); + listNode4.append(listItem4); + listNode4.append(listNode5); + listNode5.append(listNode6); + listNode6.append(listNode7); + + expect(listNode1.createDOM(editorConfig).outerHTML).toBe( + '
            ', + ); + expect( + listNode1.createDOM({ + namespace: '', + theme: { + list: {}, + }, + }).outerHTML, + ).toBe('
              '); + expect( + listNode1.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
                '); + expect(listNode2.createDOM(editorConfig).outerHTML).toBe( + '
                  ', + ); + expect(listNode3.createDOM(editorConfig).outerHTML).toBe( + '
                    ', + ); + expect(listNode4.createDOM(editorConfig).outerHTML).toBe( + '
                      ', + ); + expect(listNode5.createDOM(editorConfig).outerHTML).toBe( + '
                        ', + ); + expect(listNode6.createDOM(editorConfig).outerHTML).toBe( + '
                          ', + ); + expect(listNode7.createDOM(editorConfig).outerHTML).toBe( + '
                            ', + ); + expect( + listNode5.createDOM({ + namespace: '', + theme: { + list: { + ...editorConfig.theme.list, + ulDepth: [ + 'my-ul-list-class-1', + 'my-ul-list-class-2', + 'my-ul-list-class-3', + ], + }, + }, + }).outerHTML, + ).toBe('
                              '); + }); + }); + + test('ListNode.updateDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + const domElement = listNode.createDOM(editorConfig); + + expect(domElement.outerHTML).toBe( + '
                                ', + ); + + const newListNode = $createListNode('number', 1); + const result = newListNode.updateDOM( + listNode, + domElement, + editorConfig, + ); + + expect(result).toBe(true); + expect(domElement.outerHTML).toBe( + '
                                  ', + ); + }); + }); + + test('ListNode.append() should properly transform a ListItemNode', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = new ListNode('bullet', 1); + const listItemNode = new ListItemNode(); + const textNode = new TextNode('Hello'); + + listItemNode.append(textNode); + const nodesToAppend = [listItemNode]; + + expect(listNode.append(...nodesToAppend)).toBe(listNode); + expect(listNode.getFirstChild()).toBe(listItemNode); + expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); + }); + }); + + test('ListNode.append() should properly transform a ListNode', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = new ListNode('bullet', 1); + const nestedListNode = new ListNode('bullet', 1); + const listItemNode = new ListItemNode(); + const textNode = new TextNode('Hello'); + + listItemNode.append(textNode); + nestedListNode.append(listItemNode); + + const nodesToAppend = [nestedListNode]; + + expect(listNode.append(...nodesToAppend)).toBe(listNode); + expect($isListItemNode(listNode.getFirstChild())).toBe(true); + expect(listNode.getFirstChild()!.getFirstChild()).toBe( + nestedListNode, + ); + }); + }); + + test('ListNode.append() should properly transform a ParagraphNode', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = new ListNode('bullet', 1); + const paragraph = new ParagraphNode(); + const textNode = new TextNode('Hello'); + paragraph.append(textNode); + const nodesToAppend = [paragraph]; + + expect(listNode.append(...nodesToAppend)).toBe(listNode); + expect($isListItemNode(listNode.getFirstChild())).toBe(true); + expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); + }); + }); + + test('$createListNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + const createdListNode = $createListNode('bullet'); + + expect(listNode.__type).toEqual(createdListNode.__type); + expect(listNode.__parent).toEqual(createdListNode.__parent); + expect(listNode.__tag).toEqual(createdListNode.__tag); + expect(listNode.__key).not.toEqual(createdListNode.__key); + }); + }); + + test('$isListNode()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + + expect($isListNode(listNode)).toBe(true); + }); + }); + + test('$createListNode() with tag name (backward compatibility)', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const numberList = $createListNode('number', 1); + const bulletList = $createListNode('bullet', 1); + expect(numberList.__listType).toBe('number'); + expect(bulletList.__listType).toBe('bullet'); + }); + }); + + test('ListNode.clone() without list type (backward compatibility)', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const olNode = ListNode.clone({ + __key: '1', + __start: 1, + __tag: 'ol', + } as unknown as ListNode); + const ulNode = ListNode.clone({ + __key: '1', + __start: 1, + __tag: 'ul', + } as unknown as ListNode); + expect(olNode.__listType).toBe('number'); + expect(ulNode.__listType).toBe('bullet'); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts new file mode 100644 index 000000000..1fa327379 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts @@ -0,0 +1,335 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {$createParagraphNode, $getRoot} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +import {$createListItemNode, $createListNode} from '../..'; +import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils'; + +describe('Lexical List Utils tests', () => { + initializeUnitTest((testEnv) => { + test('getListDepth should return the 1-based depth of a list with one levels', async () => { + const editor = testEnv.editor; + + editor.update(() => { + // Root + // |- ListNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + + root.append(topListNode); + + const result = $getListDepth(topListNode); + + expect(result).toEqual(1); + }); + }); + + test('getListDepth should return the 1-based depth of a list with two levels', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + topListNode.append(listItem2); + topListNode.append(secondLevelListNode); + + secondLevelListNode.append(listItem3); + + const result = $getListDepth(secondLevelListNode); + + expect(result).toEqual(2); + }); + }); + + test('getListDepth should return the 1-based depth of a list with five levels', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + const listNode2 = $createListNode('bullet'); + const listNode3 = $createListNode('bullet'); + const listNode4 = $createListNode('bullet'); + const listNode5 = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + const listItem4 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + + listItem1.append(listNode2); + listNode2.append(listItem2); + listItem2.append(listNode3); + listNode3.append(listItem3); + listItem3.append(listNode4); + listNode4.append(listItem4); + listItem4.append(listNode5); + + const result = $getListDepth(listNode5); + + expect(result).toEqual(5); + }); + }); + + test('getTopListNode should return the top list node when the list is a direct child of the RootNode', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + topListNode.append(listItem2); + topListNode.append(secondLevelListNode); + secondLevelListNode.append(listItem3); + + const result = $getTopListNode(listItem3); + expect(result.getKey()).toEqual(topListNode.getKey()); + }); + }); + + test('getTopListNode should return the top list node when the list is not a direct child of the RootNode', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ParagraphNode + // |- ListNode + // |- ListItemNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + const root = $getRoot(); + + const paragraphNode = $createParagraphNode(); + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + root.append(paragraphNode); + paragraphNode.append(topListNode); + topListNode.append(listItem1); + topListNode.append(listItem2); + topListNode.append(secondLevelListNode); + secondLevelListNode.append(listItem3); + + const result = $getTopListNode(listItem3); + expect(result.getKey()).toEqual(topListNode.getKey()); + }); + }); + + test('getTopListNode should return the top list node when the list item is deeply nested.', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ParagraphNode + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListItemNode + const root = $getRoot(); + + const paragraphNode = $createParagraphNode(); + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const thirdLevelListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + const listItem4 = $createListItemNode(); + root.append(paragraphNode); + paragraphNode.append(topListNode); + topListNode.append(listItem1); + listItem1.append(secondLevelListNode); + secondLevelListNode.append(listItem2); + listItem2.append(thirdLevelListNode); + thirdLevelListNode.append(listItem3); + topListNode.append(listItem4); + + const result = $getTopListNode(listItem4); + expect(result.getKey()).toEqual(topListNode.getKey()); + }); + }); + + test('isLastItemInList should return true if the listItem is the last in a nested list.', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const thirdLevelListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + listItem1.append(secondLevelListNode); + secondLevelListNode.append(listItem2); + listItem2.append(thirdLevelListNode); + thirdLevelListNode.append(listItem3); + + const result = $isLastItemInList(listItem3); + + expect(result).toEqual(true); + }); + }); + + test('isLastItemInList should return true if the listItem is the last in a non-nested list.', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListItemNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + topListNode.append(listItem2); + + const result = $isLastItemInList(listItem2); + + expect(result).toEqual(true); + }); + }); + + test('isLastItemInList should return false if the listItem is not the last in a nested list.', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + // |- ListNode + // |- ListItemNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + const secondLevelListNode = $createListNode('bullet'); + const thirdLevelListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + listItem1.append(secondLevelListNode); + secondLevelListNode.append(listItem2); + listItem2.append(thirdLevelListNode); + thirdLevelListNode.append(listItem3); + + const result = $isLastItemInList(listItem2); + + expect(result).toEqual(false); + }); + }); + + test('isLastItemInList should return true if the listItem is not the last in a non-nested list.', async () => { + const editor = testEnv.editor; + + await editor.update(() => { + // Root + // |- ListNode + // |- ListItemNode + // |- ListItemNode + const root = $getRoot(); + + const topListNode = $createListNode('bullet'); + + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + + root.append(topListNode); + + topListNode.append(listItem1); + topListNode.append(listItem2); + + const result = $isLastItemInList(listItem1); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/utils.ts b/resources/js/wysiwyg/lexical/list/__tests__/utils.ts new file mode 100644 index 000000000..aa95a7a01 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/__tests__/utils.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {expect} from '@playwright/test'; +import prettier from 'prettier'; + +// This tag function is just used to trigger prettier auto-formatting. +// (https://prettier.io/blog/2020/08/24/2.1.0.html#api) +export function html( + partials: TemplateStringsArray, + ...params: string[] +): string { + let output = ''; + for (let i = 0; i < partials.length; i++) { + output += partials[i]; + if (i < partials.length - 1) { + output += params[i]; + } + } + return output; +} + +export function expectHtmlToBeEqual(expected: string, actual: string): void { + expect(prettifyHtml(expected)).toBe(prettifyHtml(actual)); +} + +export function prettifyHtml(s: string): string { + return prettier.format(s.replace(/\n/g, ''), {parser: 'html'}); +} diff --git a/resources/js/wysiwyg/lexical/list/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts new file mode 100644 index 000000000..b9ca01169 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/formatList.ts @@ -0,0 +1,530 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$getNearestNodeOfType} from '@lexical/utils'; +import { + $createParagraphNode, + $getSelection, + $isElementNode, + $isLeafNode, + $isParagraphNode, + $isRangeSelection, + $isRootOrShadowRoot, + ElementNode, + LexicalEditor, + LexicalNode, + NodeKey, + ParagraphNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import { + $createListItemNode, + $createListNode, + $isListItemNode, + $isListNode, + ListItemNode, + ListNode, +} from './'; +import {ListType} from './LexicalListNode'; +import { + $getAllListItems, + $getTopListNode, + $removeHighestEmptyListParent, + isNestedListNode, +} from './utils'; + +function $isSelectingEmptyListItem( + anchorNode: ListItemNode | LexicalNode, + nodes: Array, +): boolean { + return ( + $isListItemNode(anchorNode) && + (nodes.length === 0 || + (nodes.length === 1 && + anchorNode.is(nodes[0]) && + anchorNode.getChildrenSize() === 0)) + ); +} + +/** + * Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of + * the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode. + * Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children. + * If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode, + * unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with + * a new ListNode, or create a new ListNode at the nearest root/shadow root. + * @param editor - The lexical editor. + * @param listType - The type of list, "number" | "bullet" | "check". + */ +export function insertList(editor: LexicalEditor, listType: ListType): void { + editor.update(() => { + const selection = $getSelection(); + + if (selection !== null) { + const nodes = selection.getNodes(); + if ($isRangeSelection(selection)) { + const anchorAndFocus = selection.getStartEndPoints(); + invariant( + anchorAndFocus !== null, + 'insertList: anchor should be defined', + ); + const [anchor] = anchorAndFocus; + const anchorNode = anchor.getNode(); + const anchorNodeParent = anchorNode.getParent(); + + if ($isSelectingEmptyListItem(anchorNode, nodes)) { + const list = $createListNode(listType); + + if ($isRootOrShadowRoot(anchorNodeParent)) { + anchorNode.replace(list); + const listItem = $createListItemNode(); + if ($isElementNode(anchorNode)) { + listItem.setFormat(anchorNode.getFormatType()); + listItem.setIndent(anchorNode.getIndent()); + } + list.append(listItem); + } else if ($isListItemNode(anchorNode)) { + const parent = anchorNode.getParentOrThrow(); + append(list, parent.getChildren()); + parent.replace(list); + } + + return; + } + } + + const handled = new Set(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if ( + $isElementNode(node) && + node.isEmpty() && + !$isListItemNode(node) && + !handled.has(node.getKey()) + ) { + $createListOrMerge(node, listType); + continue; + } + + if ($isLeafNode(node)) { + let parent = node.getParent(); + while (parent != null) { + const parentKey = parent.getKey(); + + if ($isListNode(parent)) { + if (!handled.has(parentKey)) { + const newListNode = $createListNode(listType); + append(newListNode, parent.getChildren()); + parent.replace(newListNode); + handled.add(parentKey); + } + + break; + } else { + const nextParent = parent.getParent(); + + if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) { + handled.add(parentKey); + $createListOrMerge(parent, listType); + break; + } + + parent = nextParent; + } + } + } + } + } + }); +} + +function append(node: ElementNode, nodesToAppend: Array) { + node.splice(node.getChildrenSize(), 0, nodesToAppend); +} + +function $createListOrMerge(node: ElementNode, listType: ListType): ListNode { + if ($isListNode(node)) { + return node; + } + + const previousSibling = node.getPreviousSibling(); + const nextSibling = node.getNextSibling(); + const listItem = $createListItemNode(); + listItem.setFormat(node.getFormatType()); + listItem.setIndent(node.getIndent()); + append(listItem, node.getChildren()); + + if ( + $isListNode(previousSibling) && + listType === previousSibling.getListType() + ) { + previousSibling.append(listItem); + node.remove(); + // if the same type of list is on both sides, merge them. + + if ($isListNode(nextSibling) && listType === nextSibling.getListType()) { + append(previousSibling, nextSibling.getChildren()); + nextSibling.remove(); + } + return previousSibling; + } else if ( + $isListNode(nextSibling) && + listType === nextSibling.getListType() + ) { + nextSibling.getFirstChildOrThrow().insertBefore(listItem); + node.remove(); + return nextSibling; + } else { + const list = $createListNode(listType); + list.append(listItem); + node.replace(list); + return list; + } +} + +/** + * A recursive function that goes through each list and their children, including nested lists, + * appending list2 children after list1 children and updating ListItemNode values. + * @param list1 - The first list to be merged. + * @param list2 - The second list to be merged. + */ +export function mergeLists(list1: ListNode, list2: ListNode): void { + const listItem1 = list1.getLastChild(); + const listItem2 = list2.getFirstChild(); + + if ( + listItem1 && + listItem2 && + isNestedListNode(listItem1) && + isNestedListNode(listItem2) + ) { + mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild()); + listItem2.remove(); + } + + const toMerge = list2.getChildren(); + if (toMerge.length > 0) { + list1.append(...toMerge); + } + + list2.remove(); +} + +/** + * Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode + * it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode, + * removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node + * inside a ListItemNode will be appended to the new ParagraphNodes. + * @param editor - The lexical editor. + */ +export function removeList(editor: LexicalEditor): void { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + const listNodes = new Set(); + const nodes = selection.getNodes(); + const anchorNode = selection.anchor.getNode(); + + if ($isSelectingEmptyListItem(anchorNode, nodes)) { + listNodes.add($getTopListNode(anchorNode)); + } else { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if ($isLeafNode(node)) { + const listItemNode = $getNearestNodeOfType(node, ListItemNode); + + if (listItemNode != null) { + listNodes.add($getTopListNode(listItemNode)); + } + } + } + } + + for (const listNode of listNodes) { + let insertionPoint: ListNode | ParagraphNode = listNode; + + const listItems = $getAllListItems(listNode); + + for (const listItemNode of listItems) { + const paragraph = $createParagraphNode(); + + append(paragraph, listItemNode.getChildren()); + + insertionPoint.insertAfter(paragraph); + insertionPoint = paragraph; + + // When the anchor and focus fall on the textNode + // we don't have to change the selection because the textNode will be appended to + // the newly generated paragraph. + // When selection is in empty nested list item, selection is actually on the listItemNode. + // When the corresponding listItemNode is deleted and replaced by the newly generated paragraph + // we should manually set the selection's focus and anchor to the newly generated paragraph. + if (listItemNode.__key === selection.anchor.key) { + selection.anchor.set(paragraph.getKey(), 0, 'element'); + } + if (listItemNode.__key === selection.focus.key) { + selection.focus.set(paragraph.getKey(), 0, 'element'); + } + + listItemNode.remove(); + } + listNode.remove(); + } + } + }); +} + +/** + * Takes the value of a child ListItemNode and makes it the value the ListItemNode + * should be if it isn't already. Also ensures that checked is undefined if the + * parent does not have a list type of 'check'. + * @param list - The list whose children are updated. + */ +export function updateChildrenListItemValue(list: ListNode): void { + const isNotChecklist = list.getListType() !== 'check'; + let value = list.getStart(); + for (const child of list.getChildren()) { + if ($isListItemNode(child)) { + if (child.getValue() !== value) { + child.setValue(value); + } + if (isNotChecklist && child.getLatest().__checked != null) { + child.setChecked(undefined); + } + if (!$isListNode(child.getFirstChild())) { + value++; + } + } + } +} + +/** + * Merge the next sibling list if same type. + *
                                    will merge with
                                      , but NOT
                                        with
                                          . + * @param list - The list whose next sibling should be potentially merged + */ +export function mergeNextSiblingListIfSameType(list: ListNode): void { + const nextSibling = list.getNextSibling(); + if ( + $isListNode(nextSibling) && + list.getListType() === nextSibling.getListType() + ) { + mergeLists(list, nextSibling); + } +} + +/** + * Adds an empty ListNode/ListItemNode chain at listItemNode, so as to + * create an indent effect. Won't indent ListItemNodes that have a ListNode as + * a child, but does merge sibling ListItemNodes if one has a nested ListNode. + * @param listItemNode - The ListItemNode to be indented. + */ +export function $handleIndent(listItemNode: ListItemNode): void { + // go through each node and decide where to move it. + const removed = new Set(); + + if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) { + return; + } + + const parent = listItemNode.getParent(); + + // We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards + const nextSibling = + listItemNode.getNextSibling() as ListItemNode; + const previousSibling = + listItemNode.getPreviousSibling() as ListItemNode; + // if there are nested lists on either side, merge them all together. + + if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) { + const innerList = previousSibling.getFirstChild(); + + if ($isListNode(innerList)) { + innerList.append(listItemNode); + const nextInnerList = nextSibling.getFirstChild(); + + if ($isListNode(nextInnerList)) { + const children = nextInnerList.getChildren(); + append(innerList, children); + nextSibling.remove(); + removed.add(nextSibling.getKey()); + } + } + } else if (isNestedListNode(nextSibling)) { + // if the ListItemNode is next to a nested ListNode, merge them + const innerList = nextSibling.getFirstChild(); + + if ($isListNode(innerList)) { + const firstChild = innerList.getFirstChild(); + + if (firstChild !== null) { + firstChild.insertBefore(listItemNode); + } + } + } else if (isNestedListNode(previousSibling)) { + const innerList = previousSibling.getFirstChild(); + + if ($isListNode(innerList)) { + innerList.append(listItemNode); + } + } else { + // otherwise, we need to create a new nested ListNode + + if ($isListNode(parent)) { + const newListItem = $createListItemNode(); + const newList = $createListNode(parent.getListType()); + newListItem.append(newList); + newList.append(listItemNode); + + if (previousSibling) { + previousSibling.insertAfter(newListItem); + } else if (nextSibling) { + nextSibling.insertBefore(newListItem); + } else { + parent.append(newListItem); + } + } + } +} + +/** + * Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode + * has a great grandparent node of type ListNode, which is where the ListItemNode will reside + * within as a child. + * @param listItemNode - The ListItemNode to remove the indent (outdent). + */ +export function $handleOutdent(listItemNode: ListItemNode): void { + // go through each node and decide where to move it. + + if (isNestedListNode(listItemNode)) { + return; + } + const parentList = listItemNode.getParent(); + const grandparentListItem = parentList ? parentList.getParent() : undefined; + const greatGrandparentList = grandparentListItem + ? grandparentListItem.getParent() + : undefined; + // If it doesn't have these ancestors, it's not indented. + + if ( + $isListNode(greatGrandparentList) && + $isListItemNode(grandparentListItem) && + $isListNode(parentList) + ) { + // if it's the first child in it's parent list, insert it into the + // great grandparent list before the grandparent + const firstChild = parentList ? parentList.getFirstChild() : undefined; + const lastChild = parentList ? parentList.getLastChild() : undefined; + + if (listItemNode.is(firstChild)) { + grandparentListItem.insertBefore(listItemNode); + + if (parentList.isEmpty()) { + grandparentListItem.remove(); + } + // if it's the last child in it's parent list, insert it into the + // great grandparent list after the grandparent. + } else if (listItemNode.is(lastChild)) { + grandparentListItem.insertAfter(listItemNode); + + if (parentList.isEmpty()) { + grandparentListItem.remove(); + } + } else { + // otherwise, we need to split the siblings into two new nested lists + const listType = parentList.getListType(); + const previousSiblingsListItem = $createListItemNode(); + const previousSiblingsList = $createListNode(listType); + previousSiblingsListItem.append(previousSiblingsList); + listItemNode + .getPreviousSiblings() + .forEach((sibling) => previousSiblingsList.append(sibling)); + const nextSiblingsListItem = $createListItemNode(); + const nextSiblingsList = $createListNode(listType); + nextSiblingsListItem.append(nextSiblingsList); + append(nextSiblingsList, listItemNode.getNextSiblings()); + // put the sibling nested lists on either side of the grandparent list item in the great grandparent. + grandparentListItem.insertBefore(previousSiblingsListItem); + grandparentListItem.insertAfter(nextSiblingsListItem); + // replace the grandparent list item (now between the siblings) with the outdented list item. + grandparentListItem.replace(listItemNode); + } + } +} + +/** + * Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode + * or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode + * (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is + * nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode. + * Throws an invariant if the selection is not a child of a ListNode. + * @returns true if a ParagraphNode was inserted succesfully, false if there is no selection + * or the selection does not contain a ListItemNode or the node already holds text. + */ +export function $handleListInsertParagraph(): boolean { + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return false; + } + // Only run this code on empty list items + const anchor = selection.anchor.getNode(); + + if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) { + return false; + } + const topListNode = $getTopListNode(anchor); + const parent = anchor.getParent(); + + invariant( + $isListNode(parent), + 'A ListItemNode must have a ListNode for a parent.', + ); + + const grandparent = parent.getParent(); + + let replacementNode; + + if ($isRootOrShadowRoot(grandparent)) { + replacementNode = $createParagraphNode(); + topListNode.insertAfter(replacementNode); + } else if ($isListItemNode(grandparent)) { + replacementNode = $createListItemNode(); + grandparent.insertAfter(replacementNode); + } else { + return false; + } + replacementNode.select(); + + const nextSiblings = anchor.getNextSiblings(); + + if (nextSiblings.length > 0) { + const newList = $createListNode(parent.getListType()); + + if ($isParagraphNode(replacementNode)) { + replacementNode.insertAfter(newList); + } else { + const newListItem = $createListItemNode(); + newListItem.append(newList); + replacementNode.insertAfter(newListItem); + } + nextSiblings.forEach((sibling) => { + sibling.remove(); + newList.append(sibling); + }); + } + + // Don't leave hanging nested empty lists + $removeHighestEmptyListParent(anchor); + + return true; +} diff --git a/resources/js/wysiwyg/lexical/list/index.ts b/resources/js/wysiwyg/lexical/list/index.ts new file mode 100644 index 000000000..157fe79de --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/index.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {SerializedListItemNode} from './LexicalListItemNode'; +import type {ListType, SerializedListNode} from './LexicalListNode'; +import type {LexicalCommand} from 'lexical'; + +import {createCommand} from 'lexical'; + +import {$handleListInsertParagraph, insertList, removeList} from './formatList'; +import { + $createListItemNode, + $isListItemNode, + ListItemNode, +} from './LexicalListItemNode'; +import {$createListNode, $isListNode, ListNode} from './LexicalListNode'; +import {$getListDepth} from './utils'; + +export { + $createListItemNode, + $createListNode, + $getListDepth, + $handleListInsertParagraph, + $isListItemNode, + $isListNode, + insertList, + ListItemNode, + ListNode, + ListType, + removeList, + SerializedListItemNode, + SerializedListNode, +}; + +export const INSERT_UNORDERED_LIST_COMMAND: LexicalCommand = + createCommand('INSERT_UNORDERED_LIST_COMMAND'); +export const INSERT_ORDERED_LIST_COMMAND: LexicalCommand = createCommand( + 'INSERT_ORDERED_LIST_COMMAND', +); +export const INSERT_CHECK_LIST_COMMAND: LexicalCommand = createCommand( + 'INSERT_CHECK_LIST_COMMAND', +); +export const REMOVE_LIST_COMMAND: LexicalCommand = createCommand( + 'REMOVE_LIST_COMMAND', +); diff --git a/resources/js/wysiwyg/lexical/list/utils.ts b/resources/js/wysiwyg/lexical/list/utils.ts new file mode 100644 index 000000000..c451a4508 --- /dev/null +++ b/resources/js/wysiwyg/lexical/list/utils.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalNode, Spread} from 'lexical'; + +import {$findMatchingParent} from '@lexical/utils'; +import invariant from 'lexical/shared/invariant'; + +import { + $createListItemNode, + $isListItemNode, + $isListNode, + ListItemNode, + ListNode, +} from './'; + +/** + * Checks the depth of listNode from the root node. + * @param listNode - The ListNode to be checked. + * @returns The depth of the ListNode. + */ +export function $getListDepth(listNode: ListNode): number { + let depth = 1; + let parent = listNode.getParent(); + + while (parent != null) { + if ($isListItemNode(parent)) { + const parentList = parent.getParent(); + + if ($isListNode(parentList)) { + depth++; + parent = parentList.getParent(); + continue; + } + invariant(false, 'A ListItemNode must have a ListNode for a parent.'); + } + + return depth; + } + + return depth; +} + +/** + * Finds the nearest ancestral ListNode and returns it, throws an invariant if listItem is not a ListItemNode. + * @param listItem - The node to be checked. + * @returns The ListNode found. + */ +export function $getTopListNode(listItem: LexicalNode): ListNode { + let list = listItem.getParent(); + + if (!$isListNode(list)) { + invariant(false, 'A ListItemNode must have a ListNode for a parent.'); + } + + let parent: ListNode | null = list; + + while (parent !== null) { + parent = parent.getParent(); + + if ($isListNode(parent)) { + list = parent; + } + } + + return list; +} + +/** + * Checks if listItem has no child ListNodes and has no ListItemNode ancestors with siblings. + * @param listItem - the ListItemNode to be checked. + * @returns true if listItem has no child ListNode and no ListItemNode ancestors with siblings, false otherwise. + */ +export function $isLastItemInList(listItem: ListItemNode): boolean { + let isLast = true; + const firstChild = listItem.getFirstChild(); + + if ($isListNode(firstChild)) { + return false; + } + let parent: ListItemNode | null = listItem; + + while (parent !== null) { + if ($isListItemNode(parent)) { + if (parent.getNextSiblings().length > 0) { + isLast = false; + } + } + + parent = parent.getParent(); + } + + return isLast; +} + +/** + * A recursive Depth-First Search (Postorder Traversal) that finds all of a node's children + * that are of type ListItemNode and returns them in an array. + * @param node - The ListNode to start the search. + * @returns An array containing all nodes of type ListItemNode found. + */ +// This should probably be $getAllChildrenOfType +export function $getAllListItems(node: ListNode): Array { + let listItemNodes: Array = []; + const listChildren: Array = node + .getChildren() + .filter($isListItemNode); + + for (let i = 0; i < listChildren.length; i++) { + const listItemNode = listChildren[i]; + const firstChild = listItemNode.getFirstChild(); + + if ($isListNode(firstChild)) { + listItemNodes = listItemNodes.concat($getAllListItems(firstChild)); + } else { + listItemNodes.push(listItemNode); + } + } + + return listItemNodes; +} + +const NestedListNodeBrand: unique symbol = Symbol.for( + '@lexical/NestedListNodeBrand', +); + +/** + * Checks to see if the passed node is a ListItemNode and has a ListNode as a child. + * @param node - The node to be checked. + * @returns true if the node is a ListItemNode and has a ListNode child, false otherwise. + */ +export function isNestedListNode( + node: LexicalNode | null | undefined, +): node is Spread< + {getFirstChild(): ListNode; [NestedListNodeBrand]: never}, + ListItemNode +> { + return $isListItemNode(node) && $isListNode(node.getFirstChild()); +} + +/** + * Traverses up the tree and returns the first ListItemNode found. + * @param node - Node to start the search. + * @returns The first ListItemNode found, or null if none exist. + */ +export function $findNearestListItemNode( + node: LexicalNode, +): ListItemNode | null { + const matchingParent = $findMatchingParent(node, (parent) => + $isListItemNode(parent), + ); + return matchingParent as ListItemNode | null; +} + +/** + * Takes a deeply nested ListNode or ListItemNode and traverses up the branch to delete the first + * ancestral ListNode (which could be the root ListNode) or ListItemNode with siblings, essentially + * bringing the deeply nested node up the branch once. Would remove sublist if it has siblings. + * Should not break ListItem -> List -> ListItem chain as empty List/ItemNodes should be removed on .remove(). + * @param sublist - The nested ListNode or ListItemNode to be brought up the branch. + */ +export function $removeHighestEmptyListParent( + sublist: ListItemNode | ListNode, +) { + // Nodes may be repeatedly indented, to create deeply nested lists that each + // contain just one bullet. + // Our goal is to remove these (empty) deeply nested lists. The easiest + // way to do that is crawl back up the tree until we find a node that has siblings + // (e.g. is actually part of the list contents) and delete that, or delete + // the root of the list (if no list nodes have siblings.) + let emptyListPtr = sublist; + + while ( + emptyListPtr.getNextSibling() == null && + emptyListPtr.getPreviousSibling() == null + ) { + const parent = emptyListPtr.getParent(); + + if ( + parent == null || + !($isListItemNode(emptyListPtr) || $isListNode(emptyListPtr)) + ) { + break; + } + + emptyListPtr = parent; + } + + emptyListPtr.remove(); +} + +/** + * Wraps a node into a ListItemNode. + * @param node - The node to be wrapped into a ListItemNode + * @returns The ListItemNode which the passed node is wrapped in. + */ +export function $wrapInListItem(node: LexicalNode): ListItemNode { + const listItemWrapper = $createListItemNode(); + return listItemWrapper.append(node); +} diff --git a/resources/js/wysiwyg/lexical/readme.md b/resources/js/wysiwyg/lexical/readme.md new file mode 100644 index 000000000..31db8fab1 --- /dev/null +++ b/resources/js/wysiwyg/lexical/readme.md @@ -0,0 +1,12 @@ +# Lexical Editor Framework + +This is a fork and import of [the Lexical editor](https://lexical.dev/) at the version of v0.17.1 for direct use and modification in BookStack. This was done due to fighting many of the opinionated defaults in Lexical during editor development. + +Only components used, or intended to be used, were copied in at this point. + +#### License + +The original work built upon in this directory and below is under the copyright of Meta Platforms, Inc. and affiliates. +The original license can be seen in the [ORIGINAL-LEXICAL-LICENSE](./ORIGINAL-LEXICAL-LICENSE) file. + +Files may have since been modified with modifications being under the license and copyright of the BookStack project as a whole. \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts new file mode 100644 index 000000000..057999ba0 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts @@ -0,0 +1,202 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createHeadingNode, + $isHeadingNode, + HeadingNode, +} from '@lexical/rich-text'; +import { + $createTextNode, + $getRoot, + $getSelection, + ParagraphNode, + RangeSelection, +} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + heading: { + h1: 'my-h1-class', + h2: 'my-h2-class', + h3: 'my-h3-class', + h4: 'my-h4-class', + h5: 'my-h5-class', + h6: 'my-h6-class', + }, + }, +}); + +describe('LexicalHeadingNode tests', () => { + initializeUnitTest((testEnv) => { + test('HeadingNode.constructor', async () => { + const {editor} = testEnv; + await editor.update(() => { + const headingNode = new HeadingNode('h1'); + expect(headingNode.getType()).toBe('heading'); + expect(headingNode.getTag()).toBe('h1'); + expect(headingNode.getTextContent()).toBe(''); + }); + expect(() => new HeadingNode('h1')).toThrow(); + }); + + test('HeadingNode.createDOM()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const headingNode = new HeadingNode('h1'); + expect(headingNode.createDOM(editorConfig).outerHTML).toBe( + '

                                          ', + ); + expect( + headingNode.createDOM({ + namespace: '', + theme: { + heading: {}, + }, + }).outerHTML, + ).toBe('

                                          '); + expect( + headingNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('

                                          '); + }); + }); + + test('HeadingNode.updateDOM()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const headingNode = new HeadingNode('h1'); + const domElement = headingNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe('

                                          '); + const newHeadingNode = new HeadingNode('h2'); + const result = newHeadingNode.updateDOM(headingNode, domElement); + expect(result).toBe(false); + expect(domElement.outerHTML).toBe('

                                          '); + }); + }); + + test('HeadingNode.insertNewAfter() empty', async () => { + const {editor} = testEnv; + let headingNode: HeadingNode; + await editor.update(() => { + const root = $getRoot(); + headingNode = new HeadingNode('h1'); + root.append(headingNode); + }); + expect(testEnv.outerHTML).toBe( + '


                                          ', + ); + await editor.update(() => { + const selection = $getSelection() as RangeSelection; + const result = headingNode.insertNewAfter(selection); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(headingNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + '



                                          ', + ); + }); + + test('HeadingNode.insertNewAfter() middle', async () => { + const {editor} = testEnv; + let headingNode: HeadingNode; + await editor.update(() => { + const root = $getRoot(); + headingNode = new HeadingNode('h1'); + const headingTextNode = $createTextNode('hello world'); + root.append(headingNode.append(headingTextNode)); + headingTextNode.select(5, 5); + }); + expect(testEnv.outerHTML).toBe( + '

                                          hello world

                                          ', + ); + await editor.update(() => { + const selection = $getSelection() as RangeSelection; + const result = headingNode.insertNewAfter(selection); + expect(result).toBeInstanceOf(HeadingNode); + expect(result.getDirection()).toEqual(headingNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + '

                                          hello world


                                          ', + ); + }); + + test('HeadingNode.insertNewAfter() end', async () => { + const {editor} = testEnv; + let headingNode: HeadingNode; + await editor.update(() => { + const root = $getRoot(); + headingNode = new HeadingNode('h1'); + const headingTextNode1 = $createTextNode('hello'); + const headingTextNode2 = $createTextNode(' world'); + headingTextNode2.setFormat('bold'); + root.append(headingNode.append(headingTextNode1, headingTextNode2)); + headingTextNode2.selectEnd(); + }); + expect(testEnv.outerHTML).toBe( + '

                                          hello world

                                          ', + ); + await editor.update(() => { + const selection = $getSelection() as RangeSelection; + const result = headingNode.insertNewAfter(selection); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(headingNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + '

                                          hello world


                                          ', + ); + }); + + test('$createHeadingNode()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const headingNode = new HeadingNode('h1'); + const createdHeadingNode = $createHeadingNode('h1'); + expect(headingNode.__type).toEqual(createdHeadingNode.__type); + expect(headingNode.__parent).toEqual(createdHeadingNode.__parent); + expect(headingNode.__key).not.toEqual(createdHeadingNode.__key); + }); + }); + + test('$isHeadingNode()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const headingNode = new HeadingNode('h1'); + expect($isHeadingNode(headingNode)).toBe(true); + }); + }); + + test('creates a h2 with text and can insert a new paragraph after', async () => { + const {editor} = testEnv; + let headingNode: HeadingNode; + const text = 'hello world'; + await editor.update(() => { + const root = $getRoot(); + headingNode = new HeadingNode('h2'); + root.append(headingNode); + const textNode = $createTextNode(text); + headingNode.append(textNode); + }); + expect(testEnv.outerHTML).toBe( + `

                                          ${text}

                                          `, + ); + await editor.update(() => { + const result = headingNode.insertNewAfter(); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(headingNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + `

                                          ${text}


                                          `, + ); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts new file mode 100644 index 000000000..e64c41880 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createQuoteNode, QuoteNode} from '@lexical/rich-text'; +import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + quote: 'my-quote-class', + }, +}); + +describe('LexicalQuoteNode tests', () => { + initializeUnitTest((testEnv) => { + test('QuoteNode.constructor', async () => { + const {editor} = testEnv; + await editor.update(() => { + const quoteNode = $createQuoteNode(); + expect(quoteNode.getType()).toBe('quote'); + expect(quoteNode.getTextContent()).toBe(''); + }); + expect(() => $createQuoteNode()).toThrow(); + }); + + test('QuoteNode.createDOM()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const quoteNode = $createQuoteNode(); + expect(quoteNode.createDOM(editorConfig).outerHTML).toBe( + '
                                          ', + ); + expect( + quoteNode.createDOM({ + namespace: '', + theme: {}, + }).outerHTML, + ).toBe('
                                          '); + }); + }); + + test('QuoteNode.updateDOM()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const quoteNode = $createQuoteNode(); + const domElement = quoteNode.createDOM(editorConfig); + expect(domElement.outerHTML).toBe( + '
                                          ', + ); + const newQuoteNode = $createQuoteNode(); + const result = newQuoteNode.updateDOM(quoteNode, domElement); + expect(result).toBe(false); + expect(domElement.outerHTML).toBe( + '
                                          ', + ); + }); + }); + + test('QuoteNode.insertNewAfter()', async () => { + const {editor} = testEnv; + let quoteNode: QuoteNode; + await editor.update(() => { + const root = $getRoot(); + quoteNode = $createQuoteNode(); + root.append(quoteNode); + }); + expect(testEnv.outerHTML).toBe( + '

                                          ', + ); + await editor.update(() => { + const result = quoteNode.insertNewAfter($createRangeSelection()); + expect(result).toBeInstanceOf(ParagraphNode); + expect(result.getDirection()).toEqual(quoteNode.getDirection()); + }); + expect(testEnv.outerHTML).toBe( + '


                                          ', + ); + }); + + test('$createQuoteNode()', async () => { + const {editor} = testEnv; + await editor.update(() => { + const quoteNode = $createQuoteNode(); + const createdQuoteNode = $createQuoteNode(); + expect(quoteNode.__type).toEqual(createdQuoteNode.__type); + expect(quoteNode.__parent).toEqual(createdQuoteNode.__parent); + expect(quoteNode.__key).not.toEqual(createdQuoteNode.__key); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts new file mode 100644 index 000000000..fd9162566 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -0,0 +1,1067 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + CommandPayloadType, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + ElementFormatType, + LexicalCommand, + LexicalEditor, + LexicalNode, + NodeKey, + ParagraphNode, + PasteCommandType, + RangeSelection, + SerializedElementNode, + Spread, + TextFormatType, +} from 'lexical'; + +import { + $insertDataTransferForRichText, + copyToClipboard, +} from '@lexical/clipboard'; +import { + $moveCharacter, + $shouldOverrideDefaultCharacterSelection, +} from '@lexical/selection'; +import { + $findMatchingParent, + $getNearestBlockElementAncestorOrThrow, + addClassNamesToElement, + isHTMLElement, + mergeRegister, + objectKlassEquals, +} from '@lexical/utils'; +import { + $applyNodeReplacement, + $createParagraphNode, + $createRangeSelection, + $createTabNode, + $getAdjacentNode, + $getNearestNodeFromDOMNode, + $getRoot, + $getSelection, + $insertNodes, + $isDecoratorNode, + $isElementNode, + $isNodeSelection, + $isRangeSelection, + $isRootNode, + $isTextNode, + $normalizeSelection__EXPERIMENTAL, + $selectAll, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_EDITOR, + CONTROLLED_TEXT_INSERTION_COMMAND, + COPY_COMMAND, + createCommand, + CUT_COMMAND, + DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + ElementNode, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + INDENT_CONTENT_COMMAND, + INSERT_LINE_BREAK_COMMAND, + INSERT_PARAGRAPH_COMMAND, + INSERT_TAB_COMMAND, + isSelectionCapturedInDecoratorInput, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + OUTDENT_CONTENT_COMMAND, + PASTE_COMMAND, + REMOVE_TEXT_COMMAND, + SELECT_ALL_COMMAND, +} from 'lexical'; +import caretFromPoint from 'lexical/shared/caretFromPoint'; +import { + CAN_USE_BEFORE_INPUT, + IS_APPLE_WEBKIT, + IS_IOS, + IS_SAFARI, +} from 'lexical/shared/environment'; + +export type SerializedHeadingNode = Spread< + { + tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + }, + SerializedElementNode +>; + +export const DRAG_DROP_PASTE: LexicalCommand> = createCommand( + 'DRAG_DROP_PASTE_FILE', +); + +export type SerializedQuoteNode = SerializedElementNode; + +/** @noInheritDoc */ +export class QuoteNode extends ElementNode { + static getType(): string { + return 'quote'; + } + + static clone(node: QuoteNode): QuoteNode { + return new QuoteNode(node.__key); + } + + constructor(key?: NodeKey) { + super(key); + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('blockquote'); + addClassNamesToElement(element, config.theme.quote); + return element; + } + updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + blockquote: (node: Node) => ({ + conversion: $convertBlockquoteElement, + priority: 0, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element && isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + + const formatType = this.getFormatType(); + element.style.textAlign = formatType; + + const direction = this.getDirection(); + if (direction) { + element.dir = direction; + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { + const node = $createQuoteNode(); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'quote', + }; + } + + // Mutation + + insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode { + const newBlock = $createParagraphNode(); + const direction = this.getDirection(); + newBlock.setDirection(direction); + this.insertAfter(newBlock, restoreSelection); + return newBlock; + } + + collapseAtStart(): true { + const paragraph = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => paragraph.append(child)); + this.replace(paragraph); + return true; + } + + canMergeWhenEmpty(): true { + return true; + } +} + +export function $createQuoteNode(): QuoteNode { + return $applyNodeReplacement(new QuoteNode()); +} + +export function $isQuoteNode( + node: LexicalNode | null | undefined, +): node is QuoteNode { + return node instanceof QuoteNode; +} + +export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +/** @noInheritDoc */ +export class HeadingNode extends ElementNode { + /** @internal */ + __tag: HeadingTagType; + + static getType(): string { + return 'heading'; + } + + static clone(node: HeadingNode): HeadingNode { + return new HeadingNode(node.__tag, node.__key); + } + + constructor(tag: HeadingTagType, key?: NodeKey) { + super(key); + this.__tag = tag; + } + + getTag(): HeadingTagType { + return this.__tag; + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const tag = this.__tag; + const element = document.createElement(tag); + const theme = config.theme; + const classNames = theme.heading; + if (classNames !== undefined) { + const className = classNames[tag]; + addClassNamesToElement(element, className); + } + return element; + } + + updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + h1: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h2: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h3: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h4: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h5: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h6: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + p: (node: Node) => { + // domNode is a

                                          since we matched it by nodeName + const paragraph = node as HTMLParagraphElement; + const firstChild = paragraph.firstChild; + if (firstChild !== null && isGoogleDocsTitle(firstChild)) { + return { + conversion: () => ({node: null}), + priority: 3, + }; + } + return null; + }, + span: (node: Node) => { + if (isGoogleDocsTitle(node)) { + return { + conversion: (domNode: Node) => { + return { + node: $createHeadingNode('h1'), + }; + }, + priority: 3, + }; + } + return null; + }, + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element && isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + + const formatType = this.getFormatType(); + element.style.textAlign = formatType; + + const direction = this.getDirection(); + if (direction) { + element.dir = direction; + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { + const node = $createHeadingNode(serializedNode.tag); + node.setFormat(serializedNode.format); + node.setIndent(serializedNode.indent); + node.setDirection(serializedNode.direction); + return node; + } + + exportJSON(): SerializedHeadingNode { + return { + ...super.exportJSON(), + tag: this.getTag(), + type: 'heading', + version: 1, + }; + } + + // Mutation + insertNewAfter( + selection?: RangeSelection, + restoreSelection = true, + ): ParagraphNode | HeadingNode { + const anchorOffet = selection ? selection.anchor.offset : 0; + const lastDesc = this.getLastDescendant(); + const isAtEnd = + !lastDesc || + (selection && + selection.anchor.key === lastDesc.getKey() && + anchorOffet === lastDesc.getTextContentSize()); + const newElement = + isAtEnd || !selection + ? $createParagraphNode() + : $createHeadingNode(this.getTag()); + const direction = this.getDirection(); + newElement.setDirection(direction); + this.insertAfter(newElement, restoreSelection); + if (anchorOffet === 0 && !this.isEmpty() && selection) { + const paragraph = $createParagraphNode(); + paragraph.select(); + this.replace(paragraph, true); + } + return newElement; + } + + collapseAtStart(): true { + const newElement = !this.isEmpty() + ? $createHeadingNode(this.getTag()) + : $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => newElement.append(child)); + this.replace(newElement); + return true; + } + + extractWithChild(): boolean { + return true; + } +} + +function isGoogleDocsTitle(domNode: Node): boolean { + if (domNode.nodeName.toLowerCase() === 'span') { + return (domNode as HTMLSpanElement).style.fontSize === '26pt'; + } + return false; +} + +function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { + const nodeName = element.nodeName.toLowerCase(); + let node = null; + if ( + nodeName === 'h1' || + nodeName === 'h2' || + nodeName === 'h3' || + nodeName === 'h4' || + nodeName === 'h5' || + nodeName === 'h6' + ) { + node = $createHeadingNode(nodeName); + if (element.style !== null) { + node.setFormat(element.style.textAlign as ElementFormatType); + } + } + return {node}; +} + +function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { + const node = $createQuoteNode(); + if (element.style !== null) { + node.setFormat(element.style.textAlign as ElementFormatType); + } + return {node}; +} + +export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode { + return $applyNodeReplacement(new HeadingNode(headingTag)); +} + +export function $isHeadingNode( + node: LexicalNode | null | undefined, +): node is HeadingNode { + return node instanceof HeadingNode; +} + +function onPasteForRichText( + event: CommandPayloadType, + editor: LexicalEditor, +): void { + event.preventDefault(); + editor.update( + () => { + const selection = $getSelection(); + const clipboardData = + objectKlassEquals(event, InputEvent) || + objectKlassEquals(event, KeyboardEvent) + ? null + : (event as ClipboardEvent).clipboardData; + if (clipboardData != null && selection !== null) { + $insertDataTransferForRichText(clipboardData, selection, editor); + } + }, + { + tag: 'paste', + }, + ); +} + +async function onCutForRichText( + event: CommandPayloadType, + editor: LexicalEditor, +): Promise { + await copyToClipboard( + editor, + objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null, + ); + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.removeText(); + } else if ($isNodeSelection(selection)) { + selection.getNodes().forEach((node) => node.remove()); + } + }); +} + +// Clipboard may contain files that we aren't allowed to read. While the event is arguably useless, +// in certain occasions, we want to know whether it was a file transfer, as opposed to text. We +// control this with the first boolean flag. +export function eventFiles( + event: DragEvent | PasteCommandType, +): [boolean, Array, boolean] { + let dataTransfer: null | DataTransfer = null; + if (objectKlassEquals(event, DragEvent)) { + dataTransfer = (event as DragEvent).dataTransfer; + } else if (objectKlassEquals(event, ClipboardEvent)) { + dataTransfer = (event as ClipboardEvent).clipboardData; + } + + if (dataTransfer === null) { + return [false, [], false]; + } + + const types = dataTransfer.types; + const hasFiles = types.includes('Files'); + const hasContent = + types.includes('text/html') || types.includes('text/plain'); + return [hasFiles, Array.from(dataTransfer.files), hasContent]; +} + +function $handleIndentAndOutdent( + indentOrOutdent: (block: ElementNode) => void, +): boolean { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + const alreadyHandled = new Set(); + const nodes = selection.getNodes(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const key = node.getKey(); + if (alreadyHandled.has(key)) { + continue; + } + const parentBlock = $findMatchingParent( + node, + (parentNode): parentNode is ElementNode => + $isElementNode(parentNode) && !parentNode.isInline(), + ); + if (parentBlock === null) { + continue; + } + const parentKey = parentBlock.getKey(); + if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) { + alreadyHandled.add(parentKey); + indentOrOutdent(parentBlock); + } + } + return alreadyHandled.size > 0; +} + +function $isTargetWithinDecorator(target: HTMLElement): boolean { + const node = $getNearestNodeFromDOMNode(target); + return $isDecoratorNode(node); +} + +function $isSelectionAtEndOfRoot(selection: RangeSelection) { + const focus = selection.focus; + return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize(); +} + +export function registerRichText(editor: LexicalEditor): () => void { + const removeListener = mergeRegister( + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + const selection = $getSelection(); + if ($isNodeSelection(selection)) { + selection.clear(); + return true; + } + return false; + }, + 0, + ), + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (isBackward) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.deleteCharacter(isBackward); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_WORD_COMMAND, + (isBackward) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.deleteWord(isBackward); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DELETE_LINE_COMMAND, + (isBackward) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.deleteLine(isBackward); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + CONTROLLED_TEXT_INSERTION_COMMAND, + (eventOrText) => { + const selection = $getSelection(); + + if (typeof eventOrText === 'string') { + if (selection !== null) { + selection.insertText(eventOrText); + } + } else { + if (selection === null) { + return false; + } + + const dataTransfer = eventOrText.dataTransfer; + if (dataTransfer != null) { + $insertDataTransferForRichText(dataTransfer, selection, editor); + } else if ($isRangeSelection(selection)) { + const data = eventOrText.data; + if (data) { + selection.insertText(data); + } + return true; + } + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + REMOVE_TEXT_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.removeText(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + FORMAT_TEXT_COMMAND, + (format) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.formatText(format); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + FORMAT_ELEMENT_COMMAND, + (format) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) { + return false; + } + const nodes = selection.getNodes(); + for (const node of nodes) { + const element = $findMatchingParent( + node, + (parentNode): parentNode is ElementNode => + $isElementNode(parentNode) && !parentNode.isInline(), + ); + if (element !== null) { + element.setFormat(format); + } + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + INSERT_LINE_BREAK_COMMAND, + (selectStart) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.insertLineBreak(selectStart); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + selection.insertParagraph(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + INSERT_TAB_COMMAND, + () => { + $insertNodes([$createTabNode()]); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + INDENT_CONTENT_COMMAND, + () => { + return $handleIndentAndOutdent((block) => { + const indent = block.getIndent(); + block.setIndent(indent + 1); + }); + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + OUTDENT_CONTENT_COMMAND, + () => { + return $handleIndentAndOutdent((block) => { + const indent = block.getIndent(); + if (indent > 0) { + block.setIndent(indent - 1); + } + }); + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (event) => { + const selection = $getSelection(); + if ( + $isNodeSelection(selection) && + !$isTargetWithinDecorator(event.target as HTMLElement) + ) { + // If selection is on a node, let's try and move selection + // back to being a range selection. + const nodes = selection.getNodes(); + if (nodes.length > 0) { + nodes[0].selectPrevious(); + return true; + } + } else if ($isRangeSelection(selection)) { + const possibleNode = $getAdjacentNode(selection.focus, true); + if ( + !event.shiftKey && + $isDecoratorNode(possibleNode) && + !possibleNode.isIsolated() && + !possibleNode.isInline() + ) { + possibleNode.selectPrevious(); + event.preventDefault(); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (event) => { + const selection = $getSelection(); + if ($isNodeSelection(selection)) { + // If selection is on a node, let's try and move selection + // back to being a range selection. + const nodes = selection.getNodes(); + if (nodes.length > 0) { + nodes[0].selectNext(0, 0); + return true; + } + } else if ($isRangeSelection(selection)) { + if ($isSelectionAtEndOfRoot(selection)) { + event.preventDefault(); + return true; + } + const possibleNode = $getAdjacentNode(selection.focus, false); + if ( + !event.shiftKey && + $isDecoratorNode(possibleNode) && + !possibleNode.isIsolated() && + !possibleNode.isInline() + ) { + possibleNode.selectNext(); + event.preventDefault(); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + (event) => { + const selection = $getSelection(); + if ($isNodeSelection(selection)) { + // If selection is on a node, let's try and move selection + // back to being a range selection. + const nodes = selection.getNodes(); + if (nodes.length > 0) { + event.preventDefault(); + nodes[0].selectPrevious(); + return true; + } + } + if (!$isRangeSelection(selection)) { + return false; + } + if ($shouldOverrideDefaultCharacterSelection(selection, true)) { + const isHoldingShift = event.shiftKey; + event.preventDefault(); + $moveCharacter(selection, isHoldingShift, true); + return true; + } + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (event) => { + const selection = $getSelection(); + if ( + $isNodeSelection(selection) && + !$isTargetWithinDecorator(event.target as HTMLElement) + ) { + // If selection is on a node, let's try and move selection + // back to being a range selection. + const nodes = selection.getNodes(); + if (nodes.length > 0) { + event.preventDefault(); + nodes[0].selectNext(0, 0); + return true; + } + } + if (!$isRangeSelection(selection)) { + return false; + } + const isHoldingShift = event.shiftKey; + if ($shouldOverrideDefaultCharacterSelection(selection, false)) { + event.preventDefault(); + $moveCharacter(selection, isHoldingShift, false); + return true; + } + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + (event) => { + if ($isTargetWithinDecorator(event.target as HTMLElement)) { + return false; + } + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + event.preventDefault(); + const {anchor} = selection; + const anchorNode = anchor.getNode(); + + if ( + selection.isCollapsed() && + anchor.offset === 0 && + !$isRootNode(anchorNode) + ) { + const element = $getNearestBlockElementAncestorOrThrow(anchorNode); + if (element.getIndent() > 0) { + return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + } + } + return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true); + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + (event) => { + if ($isTargetWithinDecorator(event.target as HTMLElement)) { + return false; + } + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + event.preventDefault(); + return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false); + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + if (event !== null) { + // If we have beforeinput, then we can avoid blocking + // the default behavior. This ensures that the iOS can + // intercept that we're actually inserting a paragraph, + // and autocomplete, autocapitalize etc work as intended. + // This can also cause a strange performance issue in + // Safari, where there is a noticeable pause due to + // preventing the key down of enter. + if ( + (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) && + CAN_USE_BEFORE_INPUT + ) { + return false; + } + event.preventDefault(); + if (event.shiftKey) { + return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); + } + } + return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + editor.blur(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + const [, files] = eventFiles(event); + if (files.length > 0) { + const x = event.clientX; + const y = event.clientY; + const eventRange = caretFromPoint(x, y); + if (eventRange !== null) { + const {offset: domOffset, node: domNode} = eventRange; + const node = $getNearestNodeFromDOMNode(domNode); + if (node !== null) { + const selection = $createRangeSelection(); + if ($isTextNode(node)) { + selection.anchor.set(node.getKey(), domOffset, 'text'); + selection.focus.set(node.getKey(), domOffset, 'text'); + } else { + const parentKey = node.getParentOrThrow().getKey(); + const offset = node.getIndexWithinParent() + 1; + selection.anchor.set(parentKey, offset, 'element'); + selection.focus.set(parentKey, offset, 'element'); + } + const normalizedSelection = + $normalizeSelection__EXPERIMENTAL(selection); + $setSelection(normalizedSelection); + } + editor.dispatchCommand(DRAG_DROP_PASTE, files); + } + event.preventDefault(); + return true; + } + + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + return true; + } + + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + const [isFileTransfer] = eventFiles(event); + const selection = $getSelection(); + if (isFileTransfer && !$isRangeSelection(selection)) { + return false; + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + const [isFileTransfer] = eventFiles(event); + const selection = $getSelection(); + if (isFileTransfer && !$isRangeSelection(selection)) { + return false; + } + const x = event.clientX; + const y = event.clientY; + const eventRange = caretFromPoint(x, y); + if (eventRange !== null) { + const node = $getNearestNodeFromDOMNode(eventRange.node); + if ($isDecoratorNode(node)) { + // Show browser caret as the user is dragging the media across the screen. Won't work + // for DecoratorNode nor it's relevant. + event.preventDefault(); + } + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + SELECT_ALL_COMMAND, + () => { + $selectAll(); + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + COPY_COMMAND, + (event) => { + copyToClipboard( + editor, + objectKlassEquals(event, ClipboardEvent) + ? (event as ClipboardEvent) + : null, + ); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + CUT_COMMAND, + (event) => { + onCutForRichText(event, editor); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + PASTE_COMMAND, + (event) => { + const [, files, hasTextContent] = eventFiles(event); + if (files.length > 0 && !hasTextContent) { + editor.dispatchCommand(DRAG_DROP_PASTE, files); + return true; + } + + // if inputs then paste within the input ignore creating a new node on paste event + if (isSelectionCapturedInDecoratorInput(event.target as Node)) { + return false; + } + + const selection = $getSelection(); + if (selection !== null) { + onPasteForRichText(event, editor); + return true; + } + + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); + return removeListener; +} diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx new file mode 100644 index 000000000..e60867831 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx @@ -0,0 +1,3082 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createLinkNode} from '@lexical/link'; +import {$createListItemNode, $createListNode} from '@lexical/list'; +import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {$createHeadingNode} from '@lexical/rich-text'; +import { + $addNodeStyle, + $getSelectionStyleValueForProperty, + $patchStyleText, + $setBlocksType, +} from '@lexical/selection'; +import {$createTableNodeWithDimensions} from '@lexical/table'; +import { + $createLineBreakNode, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isTextNode, + $setSelection, + DecoratorNode, + ElementNode, + LexicalEditor, + LexicalNode, + ParagraphNode, + PointType, + type RangeSelection, + TextNode, +} from 'lexical'; +import { + $assertRangeSelection, + $createTestDecoratorNode, + $createTestElementNode, + createTestEditor, + initializeClipboard, + invariant, + TestComposer, +} from 'lexical/src/__tests__/utils'; +import {createRoot, Root} from 'react-dom/client'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +import { + $setAnchorPoint, + $setFocusPoint, + applySelectionInputs, + convertToSegmentedNode, + convertToTokenNode, + deleteBackward, + deleteWordBackward, + deleteWordForward, + formatBold, + formatItalic, + formatStrikeThrough, + formatUnderline, + getNodeFromPath, + insertParagraph, + insertSegmentedNode, + insertText, + insertTokenNode, + moveBackward, + moveEnd, + moveNativeSelection, + pastePlain, + printWhitespace, + redo, + setNativeSelectionWithPaths, + undo, +} from '../utils'; + +interface ExpectedSelection { + anchorPath: number[]; + anchorOffset: number; + focusPath: number[]; + focusOffset: number; +} + +initializeClipboard(); + +jest.mock('lexical/shared/environment', () => { + const originalModule = jest.requireActual('lexical/shared/environment'); + + return {...originalModule, IS_FIREFOX: true}; +}); + +Range.prototype.getBoundingClientRect = function (): DOMRect { + const rect = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + return { + ...rect, + toJSON() { + return rect; + }, + }; +}; + +describe('LexicalSelection tests', () => { + let container: HTMLElement; + let reactRoot: Root; + let editor: LexicalEditor | null = null; + + beforeEach(async () => { + container = document.createElement('div'); + document.body.appendChild(container); + reactRoot = createRoot(container); + await init(); + }); + + afterEach(async () => { + // Ensure we are clearing out any React state and running effects with + // act + await ReactTestUtils.act(async () => { + reactRoot.unmount(); + await Promise.resolve().then(); + }); + document.body.removeChild(container); + }); + + async function init() { + function TestBase() { + function TestPlugin() { + [editor] = useLexicalComposerContext(); + + return null; + } + + return ( + + + } + placeholder={null} + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + ); + } + + await ReactTestUtils.act(async () => { + reactRoot.render(); + await Promise.resolve().then(); + }); + + await Promise.resolve().then(); + // Focus first element + setNativeSelectionWithPaths( + editor!.getRootElement()!, + [0, 0], + 0, + [0, 0], + 0, + ); + } + + async function update(fn: () => void) { + await ReactTestUtils.act(async () => { + await editor!.update(fn); + }); + } + + test('Expect initial output to be a block with no text.', () => { + expect(container!.innerHTML).toBe( + '


                                          ', + ); + }); + + function assertSelection( + rootElement: HTMLElement, + expectedSelection: ExpectedSelection, + ) { + const actualSelection = window.getSelection()!; + + expect(actualSelection.anchorNode).toBe( + getNodeFromPath(expectedSelection.anchorPath, rootElement), + ); + expect(actualSelection.anchorOffset).toBe(expectedSelection.anchorOffset); + expect(actualSelection.focusNode).toBe( + getNodeFromPath(expectedSelection.focusPath, rootElement), + ); + expect(actualSelection.focusOffset).toBe(expectedSelection.focusOffset); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const GRAPHEME_SCENARIOS = [ + { + description: 'grapheme cluster', + // Hangul grapheme cluster. + // https://www.compart.com/en/unicode/U+AC01 + grapheme: '\u1100\u1161\u11A8', + }, + { + description: 'extended grapheme cluster', + // Tamil 'ni' grapheme cluster. + // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters + grapheme: '\u0BA8\u0BBF', + }, + { + description: 'tailored grapheme cluster', + // Devangari 'kshi' tailored grapheme cluster. + // http://unicode.org/reports/tr29/#Table_Sample_Grapheme_Clusters + grapheme: '\u0915\u094D\u0937\u093F', + }, + { + description: 'Emoji sequence combined using zero-width joiners', + // https://emojipedia.org/family-woman-woman-girl-boy/ + grapheme: + '\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66', + }, + { + description: 'Emoji sequence with skin-tone modifier', + // https://emojipedia.org/clapping-hands-medium-skin-tone/ + grapheme: '\uD83D\uDC4F\uD83C\uDFFD', + }, + ]; + + const suite = [ + { + expectedHTML: + '

                                          Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing', + }, + { + expectedHTML: + '

                                          ' + + 'Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + formatBold(), + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing in bold', + }, + { + expectedHTML: + '

                                          ' + + 'Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + formatItalic(), + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing in italic', + }, + { + expectedHTML: + '

                                          ' + + 'Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + formatItalic(), + formatBold(), + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing in italic + bold', + }, + { + expectedHTML: + '

                                          ' + + 'Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + formatUnderline(), + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing in underline', + }, + { + expectedHTML: + '

                                          ' + + 'Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + formatStrikeThrough(), + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing in strikethrough', + }, + { + expectedHTML: + '

                                          ' + + 'Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + formatUnderline(), + formatStrikeThrough(), + insertText('H'), + insertText('e'), + insertText('l'), + insertText('l'), + insertText('o'), + ], + name: 'Simple typing in underline + strikethrough', + }, + { + expectedHTML: + '

                                          1246

                                          ', + expectedSelection: { + anchorOffset: 4, + anchorPath: [0, 0, 0], + focusOffset: 4, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText('1'), + insertText('2'), + insertText('3'), + deleteBackward(1), + insertText('4'), + insertText('5'), + deleteBackward(1), + insertText('6'), + ], + name: 'Deletion', + }, + { + expectedHTML: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + expectedSelection: { + anchorOffset: 16, + anchorPath: [0, 0, 0], + focusOffset: 16, + focusPath: [0, 0, 0], + }, + inputs: [insertTokenNode('Dominic Gannaway')], + name: 'Creation of an token node', + }, + { + expectedHTML: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + expectedSelection: { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0], + }, + inputs: [ + insertText('Dominic Gannaway'), + moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16), + convertToTokenNode(), + ], + name: 'Convert text to an token node', + }, + { + expectedHTML: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + expectedSelection: { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0], + }, + inputs: [insertSegmentedNode('Dominic Gannaway')], + name: 'Creation of a segmented node', + }, + { + expectedHTML: + '

                                          ' + + 'Dominic Gannaway' + + '

                                          ', + expectedSelection: { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0], + }, + inputs: [ + insertText('Dominic Gannaway'), + moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 16), + convertToSegmentedNode(), + ], + name: 'Convert text to a segmented node', + }, + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [2], + }, + inputs: [ + insertParagraph(), + insertText('Hello world'), + insertParagraph(), + moveNativeSelection([0], 0, [2], 0), + formatBold(), + ], + name: 'Format selection that starts and ends on element and retain selection', + }, + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello' + + '

                                          ' + + '

                                          ' + + 'world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [3], + }, + inputs: [ + insertParagraph(), + insertText('Hello'), + insertParagraph(), + insertText('world'), + insertParagraph(), + moveNativeSelection([0], 0, [3], 0), + formatBold(), + ], + name: 'Format multiline text selection that starts and ends on element and retain selection', + }, + { + expectedHTML: + '
                                          ' + + '

                                          ' + + 'He' + + 'llo' + + '

                                          ' + + '

                                          ' + + 'wo' + + 'rld' + + '

                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0, 1, 0], + focusOffset: 2, + focusPath: [1, 0, 0], + }, + inputs: [ + insertText('Hello'), + insertParagraph(), + insertText('world'), + moveNativeSelection([0, 0, 0], 2, [1, 0, 0], 2), + formatBold(), + ], + name: 'Format multiline text selection that starts and ends within text', + }, + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello ' + + 'world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [1, 1, 0], + focusOffset: 0, + focusPath: [2], + }, + inputs: [ + insertParagraph(), + insertText('Hello world'), + insertParagraph(), + moveNativeSelection([1, 0, 0], 6, [2], 0), + formatBold(), + ], + name: 'Format selection that starts on text and ends on element and retain selection', + }, + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello' + + ' world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 5, + focusPath: [1, 0, 0], + }, + inputs: [ + insertParagraph(), + insertText('Hello world'), + insertParagraph(), + moveNativeSelection([0], 0, [1, 0, 0], 5), + formatBold(), + ], + name: 'Format selection that starts on element and ends on text and retain selection', + }, + + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 2, + anchorPath: [1, 0, 0], + focusOffset: 0, + focusPath: [2], + }, + inputs: [ + insertParagraph(), + insertTokenNode('Hello'), + insertText(' world'), + insertParagraph(), + moveNativeSelection([1, 0, 0], 2, [2], 0), + formatBold(), + ], + name: 'Format selection that starts on middle of token node should format complete node', + }, + + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 2, + focusPath: [1, 1, 0], + }, + inputs: [ + insertParagraph(), + insertText('Hello '), + insertTokenNode('world'), + insertParagraph(), + moveNativeSelection([0], 0, [1, 1, 0], 2), + formatBold(), + ], + name: 'Format selection that ends on middle of token node should format complete node', + }, + + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 2, + anchorPath: [1, 0, 0], + focusOffset: 3, + focusPath: [1, 0, 0], + }, + inputs: [ + insertParagraph(), + insertTokenNode('Hello'), + insertText(' world'), + insertParagraph(), + moveNativeSelection([1, 0, 0], 2, [1, 0, 0], 3), + formatBold(), + ], + name: 'Format token node if it is the single one selected', + }, + + { + expectedHTML: + '
                                          ' + + '


                                          ' + + '

                                          ' + + 'Hello beautiful world' + + '

                                          ' + + '


                                          ' + + '
                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [2], + }, + inputs: [ + insertParagraph(), + insertText('Hello '), + insertTokenNode('beautiful'), + insertText(' world'), + insertParagraph(), + moveNativeSelection([0], 0, [2], 0), + formatBold(), + ], + name: 'Format selection that contains a token node in the middle should format the token node', + }, + + // Tests need fixing: + // ...GRAPHEME_SCENARIOS.flatMap(({description, grapheme}) => [ + // { + // name: `Delete backward eliminates entire ${description} (${grapheme})`, + // inputs: [insertText(grapheme + grapheme), deleteBackward(1)], + // expectedHTML: `

                                          ${grapheme}

                                          `, + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: grapheme.length, + // focusPath: [0, 0, 0], + // focusOffset: grapheme.length, + // }, + // setup: emptySetup, + // }, + // { + // name: `Delete forward eliminates entire ${description} (${grapheme})`, + // inputs: [ + // insertText(grapheme + grapheme), + // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), + // deleteForward(), + // ], + // expectedHTML: `

                                          ${grapheme}

                                          `, + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 0, + // focusPath: [0, 0, 0], + // focusOffset: 0, + // }, + // setup: emptySetup, + // }, + // { + // name: `Move backward skips over grapheme cluster (${grapheme})`, + // inputs: [insertText(grapheme + grapheme), moveBackward(1)], + // expectedHTML: `

                                          ${grapheme}${grapheme}

                                          `, + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: grapheme.length, + // focusPath: [0, 0, 0], + // focusOffset: grapheme.length, + // }, + // setup: emptySetup, + // }, + // { + // name: `Move forward skips over grapheme cluster (${grapheme})`, + // inputs: [ + // insertText(grapheme + grapheme), + // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), + // moveForward(), + // ], + // expectedHTML: `

                                          ${grapheme}${grapheme}

                                          `, + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: grapheme.length, + // focusPath: [0, 0, 0], + // focusOffset: grapheme.length, + // }, + // setup: emptySetup, + // }, + // ]), + // { + // name: 'Jump to beginning and insert', + // inputs: [ + // insertText('1'), + // insertText('1'), + // insertText('2'), + // insertText('3'), + // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), + // insertText('a'), + // insertText('b'), + // insertText('c'), + // deleteForward(), + // ], + // expectedHTML: + // '

                                          abc123

                                          ', + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 3, + // focusPath: [0, 0, 0], + // focusOffset: 3, + // }, + // }, + // { + // name: 'Select and replace', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // insertText('lexical'), + // ], + // expectedHTML: + // '

                                          Hello lexical!

                                          ', + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 13, + // focusPath: [0, 0, 0], + // focusOffset: 13, + // }, + // }, + // { + // name: 'Select and bold', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // formatBold(), + // ], + // expectedHTML: + // '

                                          Hello ' + + // 'draft!

                                          ', + // expectedSelection: { + // anchorPath: [0, 1, 0], + // anchorOffset: 0, + // focusPath: [0, 1, 0], + // focusOffset: 5, + // }, + // }, + // { + // name: 'Select and italic', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // formatItalic(), + // ], + // expectedHTML: + // '

                                          Hello ' + + // 'draft!

                                          ', + // expectedSelection: { + // anchorPath: [0, 1, 0], + // anchorOffset: 0, + // focusPath: [0, 1, 0], + // focusOffset: 5, + // }, + // }, + // { + // name: 'Select and bold + italic', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // formatBold(), + // formatItalic(), + // ], + // expectedHTML: + // '

                                          Hello ' + + // 'draft!

                                          ', + // expectedSelection: { + // anchorPath: [0, 1, 0], + // anchorOffset: 0, + // focusPath: [0, 1, 0], + // focusOffset: 5, + // }, + // }, + // { + // name: 'Select and underline', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // formatUnderline(), + // ], + // expectedHTML: + // '

                                          Hello ' + + // 'draft!

                                          ', + // expectedSelection: { + // anchorPath: [0, 1, 0], + // anchorOffset: 0, + // focusPath: [0, 1, 0], + // focusOffset: 5, + // }, + // }, + // { + // name: 'Select and strikethrough', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // formatStrikeThrough(), + // ], + // expectedHTML: + // '

                                          Hello ' + + // 'draft!

                                          ', + // expectedSelection: { + // anchorPath: [0, 1, 0], + // anchorOffset: 0, + // focusPath: [0, 1, 0], + // focusOffset: 5, + // }, + // }, + // { + // name: 'Select and underline + strikethrough', + // inputs: [ + // insertText('Hello draft!'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), + // formatUnderline(), + // formatStrikeThrough(), + // ], + // expectedHTML: + // '

                                          Hello ' + + // 'draft!

                                          ', + // expectedSelection: { + // anchorPath: [0, 1, 0], + // anchorOffset: 0, + // focusPath: [0, 1, 0], + // focusOffset: 5, + // }, + // }, + // { + // name: 'Select and replace all', + // inputs: [ + // insertText('This is broken.'), + // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 15), + // insertText('This works!'), + // ], + // expectedHTML: + // '

                                          This works!

                                          ', + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 11, + // focusPath: [0, 0, 0], + // focusOffset: 11, + // }, + // }, + // { + // name: 'Select and delete', + // inputs: [ + // insertText('A lion.'), + // moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6), + // deleteForward(), + // insertText('duck'), + // moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6), + // ], + // expectedHTML: + // '

                                          A duck.

                                          ', + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 2, + // focusPath: [0, 0, 0], + // focusOffset: 6, + // }, + // }, + // { + // name: 'Inserting a paragraph', + // inputs: [insertParagraph()], + // expectedHTML: + // '


                                          ' + + // '


                                          ', + // expectedSelection: { + // anchorPath: [1, 0, 0], + // anchorOffset: 0, + // focusPath: [1, 0, 0], + // focusOffset: 0, + // }, + // }, + // { + // name: 'Inserting a paragraph and then removing it', + // inputs: [insertParagraph(), deleteBackward(1)], + // expectedHTML: + // '


                                          ', + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 0, + // focusPath: [0, 0, 0], + // focusOffset: 0, + // }, + // }, + // { + // name: 'Inserting a paragraph part way through text', + // inputs: [ + // insertText('Hello world'), + // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6), + // insertParagraph(), + // ], + // expectedHTML: + // '

                                          Hello

                                          ' + + // '

                                          world

                                          ', + // expectedSelection: { + // anchorPath: [1, 0, 0], + // anchorOffset: 0, + // focusPath: [1, 0, 0], + // focusOffset: 0, + // }, + // }, + // { + // name: 'Inserting two paragraphs and then deleting via selection', + // inputs: [ + // insertText('123'), + // insertParagraph(), + // insertText('456'), + // moveNativeSelection([0, 0, 0], 0, [1, 0, 0], 3), + // deleteBackward(1), + // ], + // expectedHTML: + // '


                                          ', + // expectedSelection: { + // anchorPath: [0, 0, 0], + // anchorOffset: 0, + // focusPath: [0, 0, 0], + // focusOffset: 0, + // }, + // }, + ...[ + { + whitespaceCharacter: ' ', + whitespaceName: 'space', + }, + { + whitespaceCharacter: '\u00a0', + whitespaceName: 'non-breaking space', + }, + { + whitespaceCharacter: '\u2000', + whitespaceName: 'en quad', + }, + { + whitespaceCharacter: '\u2001', + whitespaceName: 'em quad', + }, + { + whitespaceCharacter: '\u2002', + whitespaceName: 'en space', + }, + { + whitespaceCharacter: '\u2003', + whitespaceName: 'em space', + }, + { + whitespaceCharacter: '\u2004', + whitespaceName: 'three-per-em space', + }, + { + whitespaceCharacter: '\u2005', + whitespaceName: 'four-per-em space', + }, + { + whitespaceCharacter: '\u2006', + whitespaceName: 'six-per-em space', + }, + { + whitespaceCharacter: '\u2007', + whitespaceName: 'figure space', + }, + { + whitespaceCharacter: '\u2008', + whitespaceName: 'punctuation space', + }, + { + whitespaceCharacter: '\u2009', + whitespaceName: 'thin space', + }, + { + whitespaceCharacter: '\u200A', + whitespaceName: 'hair space', + }, + ].flatMap(({whitespaceCharacter, whitespaceName}) => [ + { + expectedHTML: `

                                          Hello${printWhitespace( + whitespaceCharacter, + )}

                                          `, + expectedSelection: { + anchorOffset: 6, + anchorPath: [0, 0, 0], + focusOffset: 6, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText(`Hello${whitespaceCharacter}world`), + deleteWordBackward(1), + ], + name: `Type two words separated by a ${whitespaceName}, delete word backward from end`, + }, + { + expectedHTML: `

                                          ${printWhitespace( + whitespaceCharacter, + )}world

                                          `, + expectedSelection: { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText(`Hello${whitespaceCharacter}world`), + moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), + deleteWordForward(1), + ], + name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning`, + }, + { + expectedHTML: + '

                                          Hello

                                          ', + expectedSelection: { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText(`Hello${whitespaceCharacter}world`), + moveNativeSelection([0, 0, 0], 5, [0, 0, 0], 5), + deleteWordForward(1), + ], + name: `Type two words separated by a ${whitespaceName}, delete word forward from beginning of preceding whitespace`, + }, + { + expectedHTML: + '

                                          world

                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText(`Hello${whitespaceCharacter}world`), + moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6), + deleteWordBackward(1), + ], + name: `Type two words separated by a ${whitespaceName}, delete word backward from end of trailing whitespace`, + }, + { + expectedHTML: + '

                                          Hello world

                                          ', + expectedSelection: { + anchorOffset: 11, + anchorPath: [0, 0, 0], + focusOffset: 11, + focusPath: [0, 0, 0], + }, + inputs: [insertText('Hello world'), deleteWordBackward(1), undo(1)], + name: `Type a word, delete it and undo the deletion`, + }, + { + expectedHTML: + '

                                          Hello

                                          ', + expectedSelection: { + anchorOffset: 6, + anchorPath: [0, 0, 0], + focusOffset: 6, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText('Hello world'), + deleteWordBackward(1), + undo(1), + redo(1), + ], + name: `Type a word, delete it and undo the deletion`, + }, + { + expectedHTML: + '

                                          ' + + 'this is weird test

                                          ', + expectedSelection: { + anchorOffset: 0, + anchorPath: [0, 0, 0], + focusOffset: 0, + focusPath: [0, 0, 0], + }, + inputs: [ + insertText('this is weird test'), + moveNativeSelection([0, 0, 0], 14, [0, 0, 0], 14), + moveBackward(14), + ], + name: 'Type a sentence, move the caret to the middle and move with the arrows to the start', + }, + { + expectedHTML: + '

                                          ' + + 'Hello ' + + 'Bob' + + '

                                          ', + expectedSelection: { + anchorOffset: 3, + anchorPath: [0, 1, 0], + focusOffset: 3, + focusPath: [0, 1, 0], + }, + inputs: [ + insertText('Hello '), + insertTokenNode('Bob'), + moveBackward(1), + moveBackward(1), + moveEnd(), + ], + name: 'Type a text and token text, move the caret to the end of the first text', + }, + { + expectedHTML: + '

                                          ABD\tEFG

                                          ', + expectedSelection: { + anchorOffset: 3, + anchorPath: [0, 0, 0], + focusOffset: 3, + focusPath: [0, 0, 0], + }, + inputs: [ + pastePlain('ABD\tEFG'), + moveBackward(5), + insertText('C'), + moveBackward(1), + deleteWordForward(1), + ], + name: 'Paste text, move selection and delete word forward', + }, + ]), + ]; + + suite.forEach((testUnit, i) => { + const name = testUnit.name || 'Test case'; + + test(name + ` (#${i + 1})`, async () => { + await applySelectionInputs(testUnit.inputs, update, editor!); + + // Validate HTML matches + expect(container.innerHTML).toBe(testUnit.expectedHTML); + + // Validate selection matches + const rootElement = editor!.getRootElement()!; + const expectedSelection = testUnit.expectedSelection; + + assertSelection(rootElement, expectedSelection); + }); + }); + + test('insert text one selected node element selection', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + + const elementNode = $createTestElementNode(); + const text = $createTextNode('foo'); + + paragraph.append(elementNode); + elementNode.append(text); + + const selection = $createRangeSelection(); + selection.anchor.set(text.__key, 0, 'text'); + selection.focus.set(paragraph.__key, 1, 'element'); + + selection.insertText(''); + + expect(root.getTextContent()).toBe(''); + }); + }); + }); + + test('getNodes resolves nested block nodes', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + + const elementNode = $createTestElementNode(); + const text = $createTextNode(); + + paragraph.append(elementNode); + elementNode.append(text); + + const selectedNodes = $getSelection()!.getNodes(); + + expect(selectedNodes.length).toBe(1); + expect(selectedNodes[0].getKey()).toBe(text.getKey()); + }); + }); + }); + + describe('Block selection moves when new nodes are inserted', () => { + const baseCases: { + name: string; + anchorOffset: number; + focusOffset: number; + fn: ( + paragraph: ElementNode, + text: TextNode, + ) => { + expectedAnchor: LexicalNode; + expectedAnchorOffset: number; + expectedFocus: LexicalNode; + expectedFocusOffset: number; + }; + fnBefore?: (paragraph: ElementNode, text: TextNode) => void; + invertSelection?: true; + only?: true; + }[] = [ + // Collapsed selection on end; add/remove/replace beginning + { + anchorOffset: 2, + fn: (paragraph, text) => { + const newText = $createTextNode('2'); + text.insertBefore(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 3, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + focusOffset: 2, + name: 'insertBefore - Collapsed selection on end; add beginning', + }, + { + anchorOffset: 2, + fn: (paragraph, text) => { + const newText = $createTextNode('2'); + text.insertAfter(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 3, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + focusOffset: 2, + name: 'insertAfter - Collapsed selection on end; add beginning', + }, + { + anchorOffset: 2, + fn: (paragraph, text) => { + text.splitText(1); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 3, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + focusOffset: 2, + name: 'splitText - Collapsed selection on end; add beginning', + }, + { + anchorOffset: 1, + fn: (paragraph, text) => { + text.remove(); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 0, + }; + }, + focusOffset: 1, + name: 'remove - Collapsed selection on end; add beginning', + }, + { + anchorOffset: 1, + fn: (paragraph, text) => { + const newText = $createTextNode('replacement'); + text.replace(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 1, + expectedFocus: paragraph, + expectedFocusOffset: 1, + }; + }, + focusOffset: 1, + name: 'replace - Collapsed selection on end; replace beginning', + }, + // All selected; add/remove/replace on beginning + { + anchorOffset: 0, + fn: (paragraph, text) => { + const newText = $createTextNode('2'); + text.insertBefore(newText); + + return { + expectedAnchor: text, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + focusOffset: 2, + name: 'insertBefore - All selected; add on beginning', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText) => { + const [, text] = originalText.splitText(1); + + return { + expectedAnchor: text, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + focusOffset: 2, + name: 'splitNodes - All selected; add on beginning', + }, + { + anchorOffset: 0, + fn: (paragraph, text) => { + text.remove(); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 0, + }; + }, + focusOffset: 1, + name: 'remove - All selected; remove on beginning', + }, + { + anchorOffset: 0, + fn: (paragraph, text) => { + const newText = $createTextNode('replacement'); + text.replace(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 1, + }; + }, + focusOffset: 1, + name: 'replace - All selected; replace on beginning', + }, + // Selection beginning; add/remove/replace on end + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + const lastChild = paragraph.getLastChild()!; + const newText = $createTextNode('2'); + lastChild.insertBefore(newText); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: originalText1, + expectedFocusOffset: 0, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 1, + name: 'insertBefore - Selection beginning; add on end', + }, + { + anchorOffset: 0, + fn: (paragraph, text) => { + const lastChild = paragraph.getLastChild()!; + const newText = $createTextNode('2'); + lastChild.insertAfter(newText); + + return { + expectedAnchor: text, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 1, + }; + }, + focusOffset: 1, + name: 'insertAfter - Selection beginning; add on end', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + const [, text] = originalText1.splitText(1); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: text, + expectedFocusOffset: 0, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 1, + name: 'splitText - Selection beginning; add on end', + }, + { + anchorOffset: 0, + fn: (paragraph, text) => { + const lastChild = paragraph.getLastChild()!; + lastChild.remove(); + + return { + expectedAnchor: text, + expectedAnchorOffset: 0, + expectedFocus: text, + expectedFocusOffset: 3, + }; + }, + focusOffset: 1, + name: 'remove - Selection beginning; remove on end', + }, + { + anchorOffset: 0, + fn: (paragraph, text) => { + const newText = $createTextNode('replacement'); + const lastChild = paragraph.getLastChild()!; + lastChild.replace(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 1, + }; + }, + focusOffset: 1, + name: 'replace - Selection beginning; replace on end', + }, + // All selected; add/remove/replace in end offset [1, 2] -> [1, N, 2] + { + anchorOffset: 0, + fn: (paragraph, text) => { + const lastChild = paragraph.getLastChild()!; + const newText = $createTextNode('2'); + lastChild.insertBefore(newText); + + return { + expectedAnchor: text, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 2, + }; + }, + focusOffset: 1, + name: 'insertBefore - All selected; add in end offset', + }, + { + anchorOffset: 0, + fn: (paragraph, text) => { + const newText = $createTextNode('2'); + text.insertAfter(newText); + + return { + expectedAnchor: text, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 2, + }; + }, + focusOffset: 1, + name: 'insertAfter - All selected; add in end offset', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + const [, text] = originalText1.splitText(1); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: text, + expectedFocusOffset: 0, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 1, + name: 'splitText - All selected; add in end offset', + }, + { + anchorOffset: 1, + fn: (paragraph, originalText1) => { + const lastChild = paragraph.getLastChild()!; + lastChild.remove(); + + return { + expectedAnchor: originalText1, + expectedAnchorOffset: 0, + expectedFocus: originalText1, + expectedFocusOffset: 3, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'remove - All selected; remove in end offset', + }, + { + anchorOffset: 1, + fn: (paragraph, originalText1) => { + const newText = $createTextNode('replacement'); + const lastChild = paragraph.getLastChild()!; + lastChild.replace(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 1, + expectedFocus: paragraph, + expectedFocusOffset: 2, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'replace - All selected; replace in end offset', + }, + // All selected; add/remove/replace in middle [1, 2, 3] -> [1, 2, N, 3] + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + const lastChild = paragraph.getLastChild()!; + const newText = $createTextNode('2'); + lastChild.insertBefore(newText); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'insertBefore - All selected; add in middle', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + const newText = $createTextNode('2'); + originalText1.insertAfter(newText); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'insertAfter - All selected; add in middle', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + originalText1.splitText(1); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 3, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'splitText - All selected; add in middle', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = originalText1.getPreviousSibling()!; + originalText1.remove(); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 1, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'remove - All selected; remove in middle', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const newText = $createTextNode('replacement'); + originalText1.replace(newText); + + return { + expectedAnchor: paragraph, + expectedAnchorOffset: 0, + expectedFocus: paragraph, + expectedFocusOffset: 2, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + originalText1.insertBefore(originalText2); + }, + focusOffset: 2, + name: 'replace - All selected; replace in middle', + }, + // Edge cases + { + anchorOffset: 3, + fn: (paragraph, originalText1) => { + const originalText2 = paragraph.getLastChild()!; + const newText = $createTextNode('new'); + originalText1.insertBefore(newText); + + return { + expectedAnchor: originalText2, + expectedAnchorOffset: 'bar'.length, + expectedFocus: originalText2, + expectedFocusOffset: 'bar'.length, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + paragraph.append(originalText2); + }, + focusOffset: 3, + name: "Selection resolves to the end of text node when it's at the end (1)", + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + const originalText2 = paragraph.getLastChild()!; + const newText = $createTextNode('new'); + originalText1.insertBefore(newText); + + return { + expectedAnchor: originalText1, + expectedAnchorOffset: 0, + expectedFocus: originalText2, + expectedFocusOffset: 'bar'.length, + }; + }, + fnBefore: (paragraph, originalText1) => { + const originalText2 = $createTextNode('bar'); + paragraph.append(originalText2); + }, + focusOffset: 3, + name: "Selection resolves to the end of text node when it's at the end (2)", + }, + { + anchorOffset: 1, + fn: (paragraph, originalText1) => { + originalText1.getNextSibling()!.remove(); + + return { + expectedAnchor: originalText1, + expectedAnchorOffset: 3, + expectedFocus: originalText1, + expectedFocusOffset: 3, + }; + }, + focusOffset: 1, + name: 'remove - Remove with collapsed selection at offset #4221', + }, + { + anchorOffset: 0, + fn: (paragraph, originalText1) => { + originalText1.getNextSibling()!.remove(); + + return { + expectedAnchor: originalText1, + expectedAnchorOffset: 0, + expectedFocus: originalText1, + expectedFocusOffset: 3, + }; + }, + focusOffset: 1, + name: 'remove - Remove with non-collapsed selection at offset', + }, + ]; + baseCases + .flatMap((testCase) => { + // Test inverse selection + const inverse = { + ...testCase, + anchorOffset: testCase.focusOffset, + focusOffset: testCase.anchorOffset, + invertSelection: true, + name: testCase.name + ' (inverse selection)', + }; + return [testCase, inverse]; + }) + .forEach( + ({ + name, + fn, + fnBefore = () => { + return; + }, + anchorOffset, + focusOffset, + invertSelection, + only, + }) => { + // eslint-disable-next-line no-only-tests/no-only-tests + const test_ = only === true ? test.only : test; + test_(name, async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + const textNode = $createTextNode('foo'); + // Note: line break can't be selected by the DOM + const linebreak = $createLineBreakNode(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchor = selection.anchor; + const focus = selection.focus; + + paragraph.append(textNode, linebreak); + + fnBefore(paragraph, textNode); + + anchor.set(paragraph.getKey(), anchorOffset, 'element'); + focus.set(paragraph.getKey(), focusOffset, 'element'); + + const { + expectedAnchor, + expectedAnchorOffset, + expectedFocus, + expectedFocusOffset, + } = fn(paragraph, textNode); + + if (invertSelection !== true) { + expect(selection.anchor.key).toBe(expectedAnchor.__key); + expect(selection.anchor.offset).toBe(expectedAnchorOffset); + expect(selection.focus.key).toBe(expectedFocus.__key); + expect(selection.focus.offset).toBe(expectedFocusOffset); + } else { + expect(selection.anchor.key).toBe(expectedFocus.__key); + expect(selection.anchor.offset).toBe(expectedFocusOffset); + expect(selection.focus.key).toBe(expectedAnchor.__key); + expect(selection.focus.offset).toBe(expectedAnchorOffset); + } + }); + }); + }); + }, + ); + }); + + describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => { + test('', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const listNode = $createListNode('bullet'); + const listItemNode = $createListItemNode(); + const paragraph = $createParagraphNode(); + + root.append(listNode); + + listNode.append(listItemNode); + listItemNode.select(); + listNode.insertAfter(paragraph); + listItemNode.remove(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor.getNode().__type).toBe('paragraph'); + expect(selection.focus.getNode().__type).toBe('paragraph'); + }); + }); + }); + }); + + describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => { + test('', async () => { + await ReactTestUtils.act(async () => { + let paragraphNodeKey: string; + await editor!.update(() => { + const root = $getRoot(); + + const paragraphNode = $createParagraphNode(); + paragraphNodeKey = paragraphNode.__key; + const listNode = $createListNode('number'); + const listItemNode1 = $createListItemNode(); + const textNode1 = $createTextNode('foo'); + const listItemNode2 = $createListItemNode(); + const listNode2 = $createListNode('number'); + const listItemNode2x1 = $createListItemNode(); + + listNode.append(listItemNode1, listItemNode2); + listItemNode1.append(textNode1); + listItemNode2.append(listNode2); + listNode2.append(listItemNode2x1); + root.append(paragraphNode, listNode); + + listItemNode2.select(); + + listNode.remove(); + }); + await editor!.getEditorState().read(() => { + const selection = $assertRangeSelection($getSelection()); + expect(selection.anchor.key).toBe(paragraphNodeKey); + expect(selection.focus.key).toBe(paragraphNodeKey); + }); + }); + }); + }); + + describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => { + test('', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + // Arrange + // Root + // |- Paragraph + // |- Link + // |- Text + // |- LineBreak + // |- Text + // |- Text + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const link = $createLinkNode('bullet'); + const textOne = $createTextNode('Hello'); + const br = $createLineBreakNode(); + const textTwo = $createTextNode('world'); + const textThree = $createTextNode(' '); + + root.append(paragraph); + link.append(textOne); + link.append(br); + link.append(textTwo); + + paragraph.append(link); + paragraph.append(textThree); + + textThree.select(); + // Act + textThree.remove(); + // Assert + const expectedKey = link.getKey(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const {anchor, focus} = selection; + + expect(anchor.getNode().getKey()).toBe(expectedKey); + expect(focus.getNode().getKey()).toBe(expectedKey); + expect(anchor.offset).toBe(3); + expect(focus.offset).toBe(3); + }); + }); + }); + }); + + test('isBackward', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + const paragraphKey = paragraph.getKey(); + const textNode = $createTextNode('foo'); + const textNodeKey = textNode.getKey(); + // Note: line break can't be selected by the DOM + const linebreak = $createLineBreakNode(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchor = selection.anchor; + const focus = selection.focus; + paragraph.append(textNode, linebreak); + anchor.set(textNodeKey, 0, 'text'); + focus.set(textNodeKey, 0, 'text'); + + expect(selection.isBackward()).toBe(false); + + anchor.set(paragraphKey, 1, 'element'); + focus.set(paragraphKey, 1, 'element'); + + expect(selection.isBackward()).toBe(false); + + anchor.set(paragraphKey, 0, 'element'); + focus.set(paragraphKey, 1, 'element'); + + expect(selection.isBackward()).toBe(false); + + anchor.set(paragraphKey, 1, 'element'); + focus.set(paragraphKey, 0, 'element'); + + expect(selection.isBackward()).toBe(true); + }); + }); + }); + + describe('Decorator text content for selection', () => { + const baseCases: { + name: string; + fn: (opts: { + textNode1: TextNode; + textNode2: TextNode; + decorator: DecoratorNode; + paragraph: ParagraphNode; + anchor: PointType; + focus: PointType; + }) => string; + invertSelection?: true; + }[] = [ + { + fn: ({textNode1, anchor, focus}) => { + anchor.set(textNode1.getKey(), 1, 'text'); + focus.set(textNode1.getKey(), 1, 'text'); + + return ''; + }, + name: 'Not included if cursor right before it', + }, + { + fn: ({textNode2, anchor, focus}) => { + anchor.set(textNode2.getKey(), 0, 'text'); + focus.set(textNode2.getKey(), 0, 'text'); + + return ''; + }, + name: 'Not included if cursor right after it', + }, + { + fn: ({textNode1, textNode2, decorator, anchor, focus}) => { + anchor.set(textNode1.getKey(), 1, 'text'); + focus.set(textNode2.getKey(), 0, 'text'); + + return decorator.getTextContent(); + }, + name: 'Included if decorator is selected within text', + }, + { + fn: ({textNode1, textNode2, decorator, anchor, focus}) => { + anchor.set(textNode1.getKey(), 0, 'text'); + focus.set(textNode2.getKey(), 0, 'text'); + + return textNode1.getTextContent() + decorator.getTextContent(); + }, + name: 'Included if decorator is selected with another node before it', + }, + { + fn: ({textNode1, textNode2, decorator, anchor, focus}) => { + anchor.set(textNode1.getKey(), 1, 'text'); + focus.set(textNode2.getKey(), 1, 'text'); + + return decorator.getTextContent() + textNode2.getTextContent(); + }, + name: 'Included if decorator is selected with another node after it', + }, + { + fn: ({paragraph, textNode1, textNode2, decorator, anchor, focus}) => { + textNode1.remove(); + textNode2.remove(); + anchor.set(paragraph.getKey(), 0, 'element'); + focus.set(paragraph.getKey(), 1, 'element'); + + return decorator.getTextContent(); + }, + name: 'Included if decorator is selected as the only node', + }, + ]; + baseCases + .flatMap((testCase) => { + const inverse = { + ...testCase, + invertSelection: true, + name: testCase.name + ' (inverse selection)', + }; + + return [testCase, inverse]; + }) + .forEach(({name, fn, invertSelection}) => { + it(name, async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + const textNode1 = $createTextNode('1'); + const textNode2 = $createTextNode('2'); + const decorator = $createTestDecoratorNode(); + + paragraph.append(textNode1, decorator, textNode2); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const expectedTextContent = fn({ + anchor: invertSelection ? selection.focus : selection.anchor, + decorator, + focus: invertSelection ? selection.anchor : selection.focus, + paragraph, + textNode1, + textNode2, + }); + + expect(selection.getTextContent()).toBe(expectedTextContent); + }); + }); + }); + }); + }); + + describe('insertParagraph', () => { + test('three text nodes at offset 0 on third node', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('Hello '); + const text2 = $createTextNode('awesome'); + + text2.toggleFormat('bold'); + + const text3 = $createTextNode(' world'); + + paragraph.append(text, text2, text3); + root.append(paragraph); + + $setAnchorPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertParagraph(); + }); + + expect(element.innerHTML).toBe( + '

                                          Hello awesome

                                          world

                                          ', + ); + }); + + test('four text nodes at offset 0 on third node', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('Hello '); + const text2 = $createTextNode('awesome '); + + text2.toggleFormat('bold'); + + const text3 = $createTextNode('beautiful'); + const text4 = $createTextNode(' world'); + + text4.toggleFormat('bold'); + + paragraph.append(text, text2, text3, text4); + root.append(paragraph); + + $setAnchorPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertParagraph(); + }); + + expect(element.innerHTML).toBe( + '

                                          Hello awesome

                                          beautiful world

                                          ', + ); + }); + + it('adjust offset for inline elements text formatting', async () => { + await init(); + + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + + const text1 = $createTextNode('--'); + const text2 = $createTextNode('abc'); + const text3 = $createTextNode('--'); + + root.append( + $createParagraphNode().append( + text1, + $createLinkNode('https://lexical.dev').append(text2), + text3, + ), + ); + + $setAnchorPoint({ + key: text1.getKey(), + offset: 2, + type: 'text', + }); + + $setFocusPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.formatText('bold'); + + expect(text2.hasFormat('bold')).toBe(true); + }); + }); + }); + }); + + describe('Node.replace', () => { + let text1: TextNode, + text2: TextNode, + text3: TextNode, + paragraph: ParagraphNode, + testEditor: LexicalEditor; + + beforeEach(async () => { + testEditor = createTestEditor(); + + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + + paragraph = $createParagraphNode(); + text1 = $createTextNode('Hello '); + text2 = $createTextNode('awesome'); + + text2.toggleFormat('bold'); + + text3 = $createTextNode(' world'); + + paragraph.append(text1, text2, text3); + root.append(paragraph); + }); + }); + [ + { + fn: () => { + text2.select(1, 1); + text2.replace($createTestDecoratorNode()); + + return { + key: text3.__key, + offset: 0, + }; + }, + name: 'moves selection to to next text node if replacing with decorator', + }, + { + fn: () => { + text3.replace($createTestDecoratorNode()); + text2.select(1, 1); + text2.replace($createTestDecoratorNode()); + + return { + key: paragraph.__key, + offset: 2, + }; + }, + name: 'moves selection to parent if next sibling is not a text node', + }, + ].forEach((testCase) => { + test(testCase.name, async () => { + await testEditor.update(() => { + const {key, offset} = testCase.fn(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor.key).toBe(key); + expect(selection.anchor.offset).toBe(offset); + expect(selection.focus.key).toBe(key); + expect(selection.focus.offset).toBe(offset); + }); + }); + }); + }); + + describe('Testing that $getStyleObjectFromRawCSS handles unformatted css text ', () => { + test('', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle( + ' font-family : Arial ; color : red ;top : 50px', + ); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); + + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); + + const cssFontFamilyValue = $getSelectionStyleValueForProperty( + selection, + 'font-family', + '', + ); + expect(cssFontFamilyValue).toBe('Arial'); + + const cssColorValue = $getSelectionStyleValueForProperty( + selection, + 'color', + '', + ); + expect(cssColorValue).toBe('red'); + + const cssTopValue = $getSelectionStyleValueForProperty( + selection, + 'top', + '', + ); + expect(cssTopValue).toBe('50px'); + }); + }); + }); + + describe('Testing that getStyleObjectFromRawCSS handles values with colons', () => { + test('', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle( + 'font-family: double:prefix:Arial; color: color:white; font-size: 30px', + ); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); + + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); + + const cssFontFamilyValue = $getSelectionStyleValueForProperty( + selection, + 'font-family', + '', + ); + expect(cssFontFamilyValue).toBe('double:prefix:Arial'); + + const cssColorValue = $getSelectionStyleValueForProperty( + selection, + 'color', + '', + ); + expect(cssColorValue).toBe('color:white'); + + const cssFontSizeValue = $getSelectionStyleValueForProperty( + selection, + 'font-size', + '', + ); + expect(cssFontSizeValue).toBe('30px'); + }); + }); + }); + + describe('$patchStyle', () => { + it('should patch the style with the new style object', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle('font-family: serif; color: red;'); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); + + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); + + const newStyle = { + color: 'blue', + 'font-family': 'Arial', + }; + + $patchStyleText(selection, newStyle); + + const cssFontFamilyValue = $getSelectionStyleValueForProperty( + selection, + 'font-family', + '', + ); + expect(cssFontFamilyValue).toBe('Arial'); + + const cssColorValue = $getSelectionStyleValueForProperty( + selection, + 'color', + '', + ); + expect(cssColorValue).toBe('blue'); + }); + }); + }); + + it('should patch the style with property function', async () => { + await ReactTestUtils.act(async () => { + await editor!.update(() => { + const currentColor = 'red'; + const nextColor = 'blue'; + + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle(`color: ${currentColor};`); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); + + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); + + const newStyle = { + color: jest.fn( + (current: string | null, target: LexicalNode | RangeSelection) => + nextColor, + ), + }; + + $patchStyleText(selection, newStyle); + + const cssColorValue = $getSelectionStyleValueForProperty( + selection, + 'color', + '', + ); + + expect(cssColorValue).toBe(nextColor); + expect(newStyle.color).toHaveBeenCalledTimes(1); + + const lastCall = newStyle.color.mock.lastCall!; + expect(lastCall[0]).toBe(currentColor); + // @ts-ignore - It expected to be a LexicalNode + expect($isTextNode(lastCall[1])).toBeTruthy(); + }); + }); + }); + }); + + describe('$setBlocksType', () => { + test('Collapsed selection in text', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode(); + const text1 = $createTextNode('text 1'); + const paragraph2 = $createParagraphNode(); + const text2 = $createTextNode('text 2'); + root.append(paragraph1, paragraph2); + paragraph1.append(text1); + paragraph2.append(text2); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: text1.__key, + offset: text1.__text.length, + type: 'text', + }); + $setFocusPoint({ + key: text1.__key, + offset: text1.__text.length, + type: 'text', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const rootChildren = root.getChildren(); + expect(rootChildren[0].__type).toBe('heading'); + expect(rootChildren[1].__type).toBe('paragraph'); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Collapsed selection in element', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + root.append(paragraph1, paragraph2); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: 'root', + offset: 0, + type: 'element', + }); + $setFocusPoint({ + key: 'root', + offset: 0, + type: 'element', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const rootChildren = root.getChildren(); + expect(rootChildren[0].__type).toBe('heading'); + expect(rootChildren[1].__type).toBe('paragraph'); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Two elements, same top-element', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode(); + const text1 = $createTextNode('text 1'); + const paragraph2 = $createParagraphNode(); + const text2 = $createTextNode('text 2'); + root.append(paragraph1, paragraph2); + paragraph1.append(text1); + paragraph2.append(text2); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: text1.__key, + offset: 0, + type: 'text', + }); + $setFocusPoint({ + key: text2.__key, + offset: text1.__text.length, + type: 'text', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const rootChildren = root.getChildren(); + expect(rootChildren[0].__type).toBe('heading'); + expect(rootChildren[1].__type).toBe('heading'); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Two empty elements, same top-element', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + root.append(paragraph1, paragraph2); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: paragraph1.__key, + offset: 0, + type: 'element', + }); + $setFocusPoint({ + key: paragraph2.__key, + offset: 0, + type: 'element', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const rootChildren = root.getChildren(); + expect(rootChildren[0].__type).toBe('heading'); + expect(rootChildren[1].__type).toBe('heading'); + expect(rootChildren.length).toBe(2); + const sel = $getSelection()!; + expect(sel.getNodes().length).toBe(2); + }); + }); + + test('Two elements, same top-element', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph1 = $createParagraphNode(); + const text1 = $createTextNode('text 1'); + const paragraph2 = $createParagraphNode(); + const text2 = $createTextNode('text 2'); + root.append(paragraph1, paragraph2); + paragraph1.append(text1); + paragraph2.append(text2); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: text1.__key, + offset: 0, + type: 'text', + }); + $setFocusPoint({ + key: text2.__key, + offset: text1.__text.length, + type: 'text', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const rootChildren = root.getChildren(); + expect(rootChildren[0].__type).toBe('heading'); + expect(rootChildren[1].__type).toBe('heading'); + expect(rootChildren.length).toBe(2); + }); + }); + + test('Collapsed in element inside top-element', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(1, 1); + const row = table.getFirstChild(); + invariant($isElementNode(row)); + const column = row.getFirstChild(); + invariant($isElementNode(column)); + const paragraph = column.getFirstChild(); + invariant($isElementNode(paragraph)); + if (paragraph.getFirstChild()) { + paragraph.getFirstChild()!.remove(); + } + root.append(table); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: paragraph.__key, + offset: 0, + type: 'element', + }); + $setFocusPoint({ + key: paragraph.__key, + offset: 0, + type: 'element', + }); + + const columnChildrenPrev = column.getChildren(); + expect(columnChildrenPrev[0].__type).toBe('paragraph'); + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const columnChildrenAfter = column.getChildren(); + expect(columnChildrenAfter[0].__type).toBe('heading'); + expect(columnChildrenAfter.length).toBe(1); + }); + }); + + test('Collapsed in text inside top-element', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(1, 1); + const row = table.getFirstChild(); + invariant($isElementNode(row)); + const column = row.getFirstChild(); + invariant($isElementNode(column)); + const paragraph = column.getFirstChild(); + invariant($isElementNode(paragraph)); + const text = $createTextNode('foo'); + root.append(table); + paragraph.append(text); + + const selectionz = $createRangeSelection(); + $setSelection(selectionz); + $setAnchorPoint({ + key: text.__key, + offset: text.__text.length, + type: 'text', + }); + $setFocusPoint({ + key: text.__key, + offset: text.__text.length, + type: 'text', + }); + const selection = $getSelection() as RangeSelection; + + const columnChildrenPrev = column.getChildren(); + expect(columnChildrenPrev[0].__type).toBe('paragraph'); + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const columnChildrenAfter = column.getChildren(); + expect(columnChildrenAfter[0].__type).toBe('heading'); + expect(columnChildrenAfter.length).toBe(1); + }); + }); + + test('Full editor selection with a mix of top-elements', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + + const paragraph1 = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + const text1 = $createTextNode(); + const text2 = $createTextNode(); + paragraph1.append(text1); + paragraph2.append(text2); + root.append(paragraph1, paragraph2); + + const table = $createTableNodeWithDimensions(1, 2); + const row = table.getFirstChild(); + invariant($isElementNode(row)); + const columns = row.getChildren(); + root.append(table); + + const column1 = columns[0]; + const paragraph3 = $createParagraphNode(); + const paragraph4 = $createParagraphNode(); + const text3 = $createTextNode(); + const text4 = $createTextNode(); + paragraph1.append(text3); + paragraph2.append(text4); + invariant($isElementNode(column1)); + column1.append(paragraph3, paragraph4); + + const column2 = columns[1]; + const paragraph5 = $createParagraphNode(); + const paragraph6 = $createParagraphNode(); + invariant($isElementNode(column2)); + column2.append(paragraph5, paragraph6); + + const paragraph7 = $createParagraphNode(); + root.append(paragraph7); + + const selectionz = $createRangeSelection(); + $setSelection(selectionz); + $setAnchorPoint({ + key: paragraph1.__key, + offset: 0, + type: 'element', + }); + $setFocusPoint({ + key: paragraph7.__key, + offset: 0, + type: 'element', + }); + const selection = $getSelection() as RangeSelection; + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe( + '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}', + ); + }); + }); + + test('Paragraph with links to heading with links', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text1 = $createTextNode('Links: '); + const text2 = $createTextNode('link1'); + const text3 = $createTextNode('link2'); + root.append( + paragraph.append( + text1, + $createLinkNode('https://lexical.dev').append(text2), + $createTextNode(' '), + $createLinkNode('https://playground.lexical.dev').append(text3), + ), + ); + + const paragraphChildrenKeys = [...paragraph.getChildrenKeys()]; + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: text1.getKey(), + offset: 1, + type: 'text', + }); + $setFocusPoint({ + key: text3.getKey(), + offset: 1, + type: 'text', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + + const rootChildren = root.getChildren(); + expect(rootChildren.length).toBe(1); + invariant($isElementNode(rootChildren[0])); + expect(rootChildren[0].getType()).toBe('heading'); + expect(rootChildren[0].getChildrenKeys()).toEqual( + paragraphChildrenKeys, + ); + }); + }); + + test('Nested list', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const ul1 = $createListNode('bullet'); + const text1 = $createTextNode('1'); + const li1 = $createListItemNode().append(text1); + const li1_wrapper = $createListItemNode(); + const ul2 = $createListNode('bullet'); + const text1_1 = $createTextNode('1.1'); + const li1_1 = $createListItemNode().append(text1_1); + ul1.append(li1, li1_wrapper); + li1_wrapper.append(ul2); + ul2.append(li1_1); + root.append(ul1); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: text1.getKey(), + offset: 1, + type: 'text', + }); + $setFocusPoint({ + key: text1_1.getKey(), + offset: 1, + type: 'text', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + }); + expect(element.innerHTML).toStrictEqual( + `

                                          1

                                          1.1

                                          `, + ); + }); + + test('Nested list with listItem twice indented from his father', async () => { + const testEditor = createTestEditor(); + const element = document.createElement('div'); + testEditor.setRootElement(element); + + await testEditor.update(() => { + const root = $getRoot(); + const ul1 = $createListNode('bullet'); + const li1_wrapper = $createListItemNode(); + const ul2 = $createListNode('bullet'); + const text1_1 = $createTextNode('1.1'); + const li1_1 = $createListItemNode().append(text1_1); + ul1.append(li1_wrapper); + li1_wrapper.append(ul2); + ul2.append(li1_1); + root.append(ul1); + + const selection = $createRangeSelection(); + $setSelection(selection); + $setAnchorPoint({ + key: text1_1.getKey(), + offset: 1, + type: 'text', + }); + $setFocusPoint({ + key: text1_1.getKey(), + offset: 1, + type: 'text', + }); + + $setBlocksType(selection, () => { + return $createHeadingNode('h1'); + }); + }); + expect(element.innerHTML).toStrictEqual( + `

                                          1.1

                                          `, + ); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts new file mode 100644 index 000000000..01390ed71 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -0,0 +1,3173 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createLinkNode} from '@lexical/link'; +import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; +import { + $getSelectionStyleValueForProperty, + $patchStyleText, +} from '@lexical/selection'; +import { + $createLineBreakNode, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getNodeByKey, + $getRoot, + $getSelection, + $insertNodes, + $isElementNode, + $isParagraphNode, + $isRangeSelection, + $setSelection, + ElementNode, + LexicalEditor, + LexicalNode, + ParagraphNode, + RangeSelection, + TextModeType, + TextNode, +} from 'lexical'; +import { + $createTestDecoratorNode, + $createTestElementNode, + $createTestShadowRootNode, + createTestEditor, + createTestHeadlessEditor, + invariant, + TestDecoratorNode, +} from 'lexical/src/__tests__/utils'; + +import {$setAnchorPoint, $setFocusPoint} from '../utils'; + +Range.prototype.getBoundingClientRect = function (): DOMRect { + const rect = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + return { + ...rect, + toJSON() { + return rect; + }, + }; +}; + +function $createParagraphWithNodes( + editor: LexicalEditor, + nodes: {text: string; key: string; mergeable?: boolean}[], +) { + const paragraph = $createParagraphNode(); + const nodeMap = editor._pendingEditorState!._nodeMap; + + for (let i = 0; i < nodes.length; i++) { + const {text, key, mergeable} = nodes[i]; + const textNode = new TextNode(text, key); + nodeMap.set(key, textNode); + + if (!mergeable) { + textNode.toggleUnmergeable(); + } + + paragraph.append(textNode); + } + + return paragraph; +} + +describe('LexicalSelectionHelpers tests', () => { + describe('Collapsed', () => { + test('Can handle a text point', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, node: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: false, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + const selection = $getSelection(); + cb(selection as RangeSelection, element); + }); + }; + + // getNodes + setupTestCase((selection, state) => { + expect(selection.getNodes()).toEqual([$getNodeByKey('a')]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual(''); + }); + + // insertText + setupTestCase((selection, state) => { + selection.insertText('Test'); + + expect($getNodeByKey('a')!.getTextContent()).toBe('Testa'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 4, + type: 'text', + }), + ); + }); + + // insertNodes + setupTestCase((selection, element) => { + selection.insertNodes([$createTextNode('foo')]); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 3, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 3, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection) => { + selection.insertParagraph(); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 0, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 0, + type: 'text', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(true); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + + expect(element.getFirstChild()!.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect( + element.getFirstChild()!.getNextSibling()!.getTextContent(), + ).toBe('a'); + }); + + // Extract selection + setupTestCase((selection, state) => { + expect(selection.extract()).toEqual([$getNodeByKey('a')]); + }); + }); + + test('Has correct text point after removal after merge', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: 'a', + }, + { + key: 'bb', + mergeable: true, + text: 'bb', + }, + { + key: 'empty', + mergeable: true, + text: '', + }, + { + key: 'cc', + mergeable: true, + text: 'cc', + }, + { + key: 'd', + mergeable: true, + text: 'd', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: 'bb', + offset: 1, + type: 'text', + }); + + $setFocusPoint({ + key: 'cc', + offset: 1, + type: 'text', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 2, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 4, + type: 'text', + }), + ); + }); + }); + + test('Has correct text point after removal after merge (2)', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: 'a', + }, + { + key: 'empty', + mergeable: true, + text: '', + }, + { + key: 'b', + mergeable: true, + text: 'b', + }, + { + key: 'c', + mergeable: true, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: 'c', + offset: 1, + type: 'text', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 0, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 3, + type: 'text', + }), + ); + }); + }); + + test('Has correct text point adjust to element point after removal of a single empty text node', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element: ParagraphNode; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: '', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + }); + + test('Has correct element point after removal of an empty text node in a group #1', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: '', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 2, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 2, + type: 'element', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'b', + offset: 1, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'b', + offset: 1, + type: 'text', + }), + ); + }); + }); + + test('Has correct element point after removal of an empty text node in a group #2', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: '', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: true, + text: 'c', + }, + { + key: 'd', + mergeable: true, + text: 'd', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 4, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 4, + type: 'element', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'c', + offset: 2, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'c', + offset: 2, + type: 'text', + }), + ); + }); + }); + + test('Has correct text point after removal of an empty text node in a group #3', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: '', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: true, + text: 'c', + }, + { + key: 'd', + mergeable: true, + text: 'd', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: 'd', + offset: 1, + type: 'text', + }); + + $setFocusPoint({ + key: 'd', + offset: 1, + type: 'text', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'c', + offset: 2, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'c', + offset: 2, + type: 'text', + }), + ); + }); + }); + + test('Can handle an element point on empty element', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, el: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, []); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 0, + type: 'element', + }); + const selection = $getSelection(); + cb(selection as RangeSelection, element); + }); + }; + + // getNodes + setupTestCase((selection, element) => { + expect(selection.getNodes()).toEqual([element]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual(''); + }); + + // insertText + setupTestCase((selection, element) => { + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection, element) => { + selection.insertParagraph(); + const nextElement = element.getNextSibling()!; + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: nextElement.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: nextElement.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(true); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // Extract selection + setupTestCase((selection, element) => { + expect(selection.extract()).toEqual([element]); + }); + }); + + test('Can handle a start element point', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, el: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: false, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 0, + type: 'element', + }); + const selection = $getSelection(); + cb(selection as RangeSelection, element); + }); + }; + + // getNodes + setupTestCase((selection, state) => { + expect(selection.getNodes()).toEqual([$getNodeByKey('a')]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual(''); + }); + + // insertText + setupTestCase((selection, element) => { + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection, element) => { + selection.insertParagraph(); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 0, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 0, + type: 'text', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(true); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // Extract selection + setupTestCase((selection, element) => { + expect(selection.extract()).toEqual([$getNodeByKey('a')]); + }); + }); + + test('Can handle an end element point', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, el: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: false, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 3, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 3, + type: 'element', + }); + const selection = $getSelection(); + cb(selection as RangeSelection, element); + }); + }; + + // getNodes + setupTestCase((selection, state) => { + expect(selection.getNodes()).toEqual([$getNodeByKey('c')]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual(''); + }); + + // insertText + setupTestCase((selection, element) => { + selection.insertText('Test'); + const lastChild = element.getLastChild()!; + + expect(lastChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: lastChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: lastChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection, element) => { + selection.insertParagraph(); + const nextSibling = element.getNextSibling()!; + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: nextSibling.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: nextSibling.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 4, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 4, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + const lastChild = element.getLastChild()!; + + expect(lastChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: lastChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: lastChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // Extract selection + setupTestCase((selection, element) => { + expect(selection.extract()).toEqual([$getNodeByKey('c')]); + }); + }); + + test('Has correct element point after merge from middle', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: 'a', + }, + { + key: 'b', + mergeable: true, + text: 'b', + }, + { + key: 'c', + mergeable: true, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 2, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 2, + type: 'element', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 2, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 2, + type: 'text', + }), + ); + }); + }); + + test('Has correct element point after merge from end', async () => { + const editor = createTestEditor(); + + const domElement = document.createElement('div'); + let element; + + editor.setRootElement(domElement); + + editor.update(() => { + const root = $getRoot(); + + element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: true, + text: 'a', + }, + { + key: 'b', + mergeable: true, + text: 'b', + }, + { + key: 'c', + mergeable: true, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 3, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 3, + type: 'element', + }); + }); + + await Promise.resolve().then(); + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 3, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 3, + type: 'text', + }), + ); + }); + }); + }); + + describe('Simple range', () => { + test('Can handle multiple text points', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, el: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: false, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: 'b', + offset: 0, + type: 'text', + }); + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + cb(selection, element); + }); + }; + + // getNodes + setupTestCase((selection, state) => { + expect(selection.getNodes()).toEqual([ + $getNodeByKey('a'), + $getNodeByKey('b'), + ]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual('a'); + }); + + // insertText + setupTestCase((selection, state) => { + selection.insertText('Test'); + + expect($getNodeByKey('a')!.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'a', + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'a', + offset: 4, + type: 'text', + }), + ); + }); + + // insertNodes + setupTestCase((selection, element) => { + selection.insertNodes([$createTextNode('foo')]); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 3, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 3, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection) => { + selection.insertParagraph(); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'b', + offset: 0, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'b', + offset: 0, + type: 'text', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(true); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + + expect(element.getFirstChild()!.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getFirstChild()!.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // Extract selection + setupTestCase((selection, state) => { + expect(selection.extract()).toEqual([{...$getNodeByKey('a')}]); + }); + }); + + test('Can handle multiple element points', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, el: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: false, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: element.getKey(), + offset: 1, + type: 'element', + }); + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + cb(selection, element); + }); + }; + + // getNodes + setupTestCase((selection) => { + expect(selection.getNodes()).toEqual([$getNodeByKey('a')]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual('a'); + }); + + // insertText + setupTestCase((selection, element) => { + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection, element) => { + selection.insertParagraph(); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: 'b', + offset: 0, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: 'b', + offset: 0, + type: 'text', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(true); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // Extract selection + setupTestCase((selection, element) => { + const firstChild = element.getFirstChild(); + + expect(selection.extract()).toEqual([firstChild]); + }); + }); + + test('Can handle a mix of text and element points', () => { + const setupTestCase = ( + cb: (selection: RangeSelection, el: ElementNode) => void, + ) => { + const editor = createTestEditor(); + + editor.update(() => { + const root = $getRoot(); + + const element = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + { + key: 'c', + mergeable: false, + text: 'c', + }, + ]); + + root.append(element); + + $setAnchorPoint({ + key: element.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: 'c', + offset: 1, + type: 'text', + }); + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + cb(selection, element); + }); + }; + + // isBefore + setupTestCase((selection, state) => { + expect(selection.anchor.isBefore(selection.focus)).toEqual(true); + }); + + // getNodes + setupTestCase((selection, state) => { + expect(selection.getNodes()).toEqual([ + $getNodeByKey('a'), + $getNodeByKey('b'), + $getNodeByKey('c'), + ]); + }); + + // getTextContent + setupTestCase((selection) => { + expect(selection.getTextContent()).toEqual('abc'); + }); + + // insertText + setupTestCase((selection, element) => { + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // insertParagraph + setupTestCase((selection, element) => { + selection.insertParagraph(); + const nextElement = element.getNextSibling()!; + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: nextElement.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: nextElement.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // insertLineBreak + setupTestCase((selection, element) => { + selection.insertLineBreak(true); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: element.getKey(), + offset: 0, + type: 'element', + }), + ); + }); + + // Format text + setupTestCase((selection, element) => { + selection.formatText('bold'); + selection.insertText('Test'); + const firstChild = element.getFirstChild()!; + + expect(firstChild.getTextContent()).toBe('Test'); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: firstChild.getKey(), + offset: 4, + type: 'text', + }), + ); + }); + + // Extract selection + setupTestCase((selection, element) => { + expect(selection.extract()).toEqual([ + $getNodeByKey('a'), + $getNodeByKey('b'), + $getNodeByKey('c'), + ]); + }); + }); + }); + + describe('can insert non-element nodes correctly', () => { + describe('with an empty paragraph node selected', () => { + test('a single text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + $setAnchorPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([$createTextNode('foo')]); + }); + + expect(element.innerHTML).toBe( + '

                                          foo

                                          ', + ); + }); + + test('two text nodes', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + $setAnchorPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([ + $createTextNode('foo'), + $createTextNode('bar'), + ]); + }); + + expect(element.innerHTML).toBe( + '

                                          foobar

                                          ', + ); + }); + + test('link insertion without parent element', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + $setAnchorPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + const link = $createLinkNode('https://'); + link.append($createTextNode('ello worl')); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([ + $createTextNode('h'), + link, + $createTextNode('d'), + ]); + }); + + expect(element.innerHTML).toBe( + '

                                          hello world

                                          ', + ); + }); + + test('a single heading node with a child text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + $setAnchorPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + + $setFocusPoint({ + key: paragraph.getKey(), + offset: 0, + type: 'element', + }); + + const heading = $createHeadingNode('h1'); + const child = $createTextNode('foo'); + + heading.append(child); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([heading]); + }); + + expect(element.innerHTML).toBe( + '

                                          foo

                                          ', + ); + }); + }); + + describe('with a paragraph node selected on some existing text', () => { + test('a single text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('Existing text...'); + + paragraph.append(text); + root.append(paragraph); + + $setAnchorPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([$createTextNode('foo')]); + }); + + expect(element.innerHTML).toBe( + '

                                          Existing text...foo

                                          ', + ); + }); + + test('two text nodes', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('Existing text...'); + + paragraph.append(text); + root.append(paragraph); + + $setAnchorPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([ + $createTextNode('foo'), + $createTextNode('bar'), + ]); + }); + + expect(element.innerHTML).toBe( + '

                                          Existing text...foobar

                                          ', + ); + }); + + test('a single heading node with a child text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('Existing text...'); + + paragraph.append(text); + root.append(paragraph); + + $setAnchorPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + const heading = $createHeadingNode('h1'); + const child = $createTextNode('foo'); + + heading.append(child); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([heading]); + }); + + expect(element.innerHTML).toBe( + '

                                          Existing text...foo

                                          ', + ); + }); + + test('a paragraph with a child text and a child italic text and a child text', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('AE'); + + paragraph.append(text); + root.append(paragraph); + + $setAnchorPoint({ + key: text.getKey(), + offset: 1, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 1, + type: 'text', + }); + + const insertedParagraph = $createParagraphNode(); + const insertedTextB = $createTextNode('B'); + const insertedTextC = $createTextNode('C'); + const insertedTextD = $createTextNode('D'); + + insertedTextC.toggleFormat('italic'); + + insertedParagraph.append(insertedTextB, insertedTextC, insertedTextD); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.insertNodes([insertedParagraph]); + + expect(selection.anchor).toEqual( + expect.objectContaining({ + key: paragraph + .getChildAtIndex(paragraph.getChildrenSize() - 2)! + .getKey(), + offset: 1, + type: 'text', + }), + ); + + expect(selection.focus).toEqual( + expect.objectContaining({ + key: paragraph + .getChildAtIndex(paragraph.getChildrenSize() - 2)! + .getKey(), + offset: 1, + type: 'text', + }), + ); + }); + + expect(element.innerHTML).toBe( + '

                                          ABCDE

                                          ', + ); + }); + }); + + describe('with a fully-selected text node', () => { + test('a single text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 'Existing text...'.length, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([$createTextNode('foo')]); + }); + + expect(element.innerHTML).toBe( + '

                                          foo

                                          ', + ); + }); + }); + + describe('with a fully-selected text node followed by an inline element', () => { + test('a single text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 'Existing text...'.length, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([$createTextNode('foo')]); + }); + + expect(element.innerHTML).toBe( + '

                                          foolink

                                          ', + ); + }); + }); + + describe('with a fully-selected text node preceded by an inline element', () => { + test('a single text node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 'Existing text...'.length, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([$createTextNode('foo')]); + }); + + expect(element.innerHTML).toBe( + '

                                          linkfoo

                                          ', + ); + }); + }); + + test.skip('can insert a linebreak node before an inline element node', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const link = $createLinkNode('https://lexical.dev/'); + paragraph.append(link); + const text = $createTextNode('Lexical'); + link.append(text); + text.select(0, 0); + + $insertNodes([$createLineBreakNode()]); + }); + + // TODO #5109 ElementNode should have a way to control when other nodes can be inserted inside + expect(element.innerHTML).toBe( + '


                                          Lexical

                                          ', + ); + }); + }); + + describe('can insert block element nodes correctly', () => { + describe('with a fully-selected text node', () => { + test('a paragraph node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 'Existing text...'.length, + type: 'text', + }); + + const paragraphToInsert = $createParagraphNode(); + paragraphToInsert.append($createTextNode('foo')); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([paragraphToInsert]); + }); + + expect(element.innerHTML).toBe( + '

                                          foo

                                          ', + ); + }); + }); + + describe('with a fully-selected text node followed by an inline element', () => { + test('a paragraph node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 'Existing text...'.length, + type: 'text', + }); + + const paragraphToInsert = $createParagraphNode(); + paragraphToInsert.append($createTextNode('foo')); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([paragraphToInsert]); + }); + + expect(element.innerHTML).toBe( + '

                                          foolink

                                          ', + ); + }); + }); + + describe('with a fully-selected text node preceded by an inline element', () => { + test('a paragraph node', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 'Existing text...'.length, + type: 'text', + }); + + const paragraphToInsert = $createParagraphNode(); + paragraphToInsert.append($createTextNode('foo')); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + selection.insertNodes([paragraphToInsert]); + }); + + expect(element.innerHTML).toBe( + '

                                          linkfoo

                                          ', + ); + }); + }); + + test('Can insert link into empty paragraph', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const linkNode = $createLinkNode('https://lexical.dev'); + const linkTextNode = $createTextNode('Lexical'); + linkNode.append(linkTextNode); + $insertNodes([linkNode]); + }); + expect(element.innerHTML).toBe( + '

                                          Lexical

                                          ', + ); + }); + + test('Can insert link into empty paragraph (2)', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const linkNode = $createLinkNode('https://lexical.dev'); + const linkTextNode = $createTextNode('Lexical'); + linkNode.append(linkTextNode); + const textNode2 = $createTextNode('...'); + $insertNodes([linkNode, textNode2]); + }); + expect(element.innerHTML).toBe( + '

                                          Lexical...

                                          ', + ); + }); + + test('Can insert an ElementNode after ShadowRoot', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.selectStart(); + const element1 = $createTestShadowRootNode(); + const element2 = $createTestElementNode(); + $insertNodes([element1, element2]); + }); + expect([ + '


                                          ', + '


                                          ', + ]).toContain(element.innerHTML); + }); + }); +}); + +describe('extract', () => { + test('Should return the selected node when collapsed on a TextNode', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const text = $createTextNode('Existing text...'); + + paragraph.append(text); + root.append(paragraph); + + $setAnchorPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + $setFocusPoint({ + key: text.getKey(), + offset: 16, + type: 'text', + }); + + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBeTruthy(); + + expect(selection!.extract()).toEqual([text]); + }); + }); +}); + +describe('insertNodes', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('can insert element next to top level decorator node', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + jest.spyOn(TestDecoratorNode.prototype, 'isInline').mockReturnValue(false); + + await editor.update(() => { + $getRoot().append( + $createParagraphNode(), + $createTestDecoratorNode(), + $createParagraphNode().append($createTextNode('Text after')), + ); + }); + + await editor.update(() => { + const selectionNode = $getRoot().getFirstChild(); + invariant($isElementNode(selectionNode)); + const selection = selectionNode.select(); + selection.insertNodes([ + $createParagraphNode().append($createTextNode('Text before')), + ]); + }); + + expect(element.innerHTML).toBe( + '

                                          Text before

                                          ' + + '' + + '

                                          Text after

                                          ', + ); + }); + + it('can insert when previous selection was null', async () => { + const editor = createTestHeadlessEditor(); + await editor.update(() => { + const selection = $createRangeSelection(); + selection.anchor.set('root', 0, 'element'); + selection.focus.set('root', 0, 'element'); + + selection.insertNodes([ + $createParagraphNode().append($createTextNode('Text')), + ]); + + expect($getRoot().getTextContent()).toBe('Text'); + + $setSelection(null); + }); + await editor.update(() => { + const selection = $createRangeSelection(); + const text = $getRoot().getLastDescendant()!; + selection.anchor.set(text.getKey(), 0, 'text'); + selection.focus.set(text.getKey(), 0, 'text'); + + selection.insertNodes([ + $createParagraphNode().append($createTextNode('Before ')), + ]); + + expect($getRoot().getTextContent()).toBe('Before Text'); + }); + }); + + it('can insert when before empty text node', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + // Empty text node to test empty text split + const emptyTextNode = $createTextNode(''); + $getRoot().append( + $createParagraphNode().append(emptyTextNode, $createTextNode('text')), + ); + emptyTextNode.select(0, 0); + const selection = $getSelection()!; + expect($isRangeSelection(selection)).toBeTruthy(); + selection.insertNodes([$createTextNode('foo')]); + + expect($getRoot().getTextContent()).toBe('footext'); + }); + }); + + it('last node is LineBreakNode', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + // Empty text node to test empty text split + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + const selection = paragraph.select(); + expect($isRangeSelection(selection)).toBeTruthy(); + + const newHeading = $createHeadingNode('h1').append( + $createTextNode('heading'), + ); + selection.insertNodes([newHeading, $createLineBreakNode()]); + }); + editor.getEditorState().read(() => { + expect(element.innerHTML).toBe( + '

                                          heading


                                          ', + ); + const selectedNode = ($getSelection() as RangeSelection).anchor.getNode(); + expect($isParagraphNode(selectedNode)).toBeTruthy(); + expect($isHeadingNode(selectedNode.getPreviousSibling())).toBeTruthy(); + }); + }); +}); + +describe('$patchStyleText', () => { + test('can patch a selection anchored to the end of a TextNode before an inline element', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + { + key: 'b', + mergeable: false, + text: 'b', + }, + ]); + + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + + const a = $getNodeByKey('a')!; + a.insertAfter(link); + + $setAnchorPoint({ + key: 'a', + offset: 1, + type: 'text', + }); + $setFocusPoint({ + key: 'b', + offset: 1, + type: 'text', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + }); + + expect(element.innerHTML).toBe( + '

                                          a' + + '' + + 'link' + + '' + + 'b

                                          ', + ); + }); + + test('can patch a selection anchored to the end of a TextNode at the end of a paragraph', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph1 = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + ]); + const paragraph2 = $createParagraphWithNodes(editor, [ + { + key: 'b', + mergeable: false, + text: 'b', + }, + ]); + + root.append(paragraph1); + root.append(paragraph2); + + $setAnchorPoint({ + key: 'a', + offset: 1, + type: 'text', + }); + $setFocusPoint({ + key: 'b', + offset: 1, + type: 'text', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + }); + + expect(element.innerHTML).toBe( + '

                                          a

                                          ' + + '

                                          b

                                          ', + ); + }); + + test('can patch a selection that ends on an element', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + ]); + + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + + const a = $getNodeByKey('a')!; + a.insertAfter(link); + + $setAnchorPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + // Select to end of the link _element_ + $setFocusPoint({ + key: link.getKey(), + offset: 1, + type: 'element', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + }); + + expect(element.innerHTML).toBe( + '

                                          ' + + 'a' + + '' + + 'link' + + '' + + '

                                          ', + ); + }); + + test('can patch a reversed selection that ends on an element', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphWithNodes(editor, [ + { + key: 'a', + mergeable: false, + text: 'a', + }, + ]); + + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + + const a = $getNodeByKey('a')!; + a.insertAfter(link); + + // Select from the end of the link _element_ + $setAnchorPoint({ + key: link.getKey(), + offset: 1, + type: 'element', + }); + $setFocusPoint({ + key: 'a', + offset: 0, + type: 'text', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + }); + + expect(element.innerHTML).toBe( + '

                                          ' + + 'a' + + '' + + 'link' + + '' + + '

                                          ', + ); + }); + + test('can patch a selection that starts and ends on an element', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + $setAnchorPoint({ + key: link.getKey(), + offset: 0, + type: 'element', + }); + $setFocusPoint({ + key: link.getKey(), + offset: 1, + type: 'element', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + }); + + expect(element.innerHTML).toBe( + '

                                          ' + + '' + + 'link' + + '' + + '

                                          ', + ); + }); + + test('can clear a style', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('text'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + $setFocusPoint({ + key: text.getKey(), + offset: text.getTextContentSize(), + type: 'text', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + $patchStyleText(selection, {'text-emphasis': null}); + }); + + expect(element.innerHTML).toBe( + '

                                          text

                                          ', + ); + }); + + test('can toggle a style on a collapsed selection', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('text'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + $setFocusPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $patchStyleText(selection, {'text-emphasis': 'filled'}); + + expect( + $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''), + ).toEqual('filled'); + + $patchStyleText(selection, {'text-emphasis': null}); + + expect( + $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''), + ).toEqual(''); + + $patchStyleText(selection, {'text-emphasis': 'filled'}); + + expect( + $getSelectionStyleValueForProperty(selection, 'text-emphasis', ''), + ).toEqual('filled'); + }); + }); + + test('updates cached styles when setting on a collapsed selection', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('text'); + paragraph.append(text); + + $setAnchorPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + $setFocusPoint({ + key: text.getKey(), + offset: 0, + type: 'text', + }); + + // First fetch the initial style -- this will cause the CSS cache to be + // populated with an empty string pointing to an empty style object. + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + $getSelectionStyleValueForProperty(selection, 'color', ''); + + // Now when we set the style, we should _not_ touch the previously created + // empty style object, but create a new one instead. + $patchStyleText(selection, {color: 'red'}); + + // We can check that result by clearing the style and re-querying it. + ($getSelection() as RangeSelection).setStyle(''); + + const color = $getSelectionStyleValueForProperty( + $getSelection() as RangeSelection, + 'color', + '', + ); + expect(color).toEqual(''); + }); + }); + + test.each(['token', 'segmented'])( + 'can update style of text node that is in %s mode', + async (mode) => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const text = $createTextNode('first').setFormat('bold'); + paragraph.append(text); + + const textInMode = $createTextNode('second').setMode(mode); + paragraph.append(textInMode); + + $setAnchorPoint({ + key: text.getKey(), + offset: 'fir'.length, + type: 'text', + }); + + $setFocusPoint({ + key: textInMode.getKey(), + offset: 'sec'.length, + type: 'text', + }); + + const selection = $getSelection(); + $patchStyleText(selection!, {'font-size': '15px'}); + }); + + expect(element.innerHTML).toBe( + '

                                          ' + + 'fir' + + 'st' + + 'second' + + '

                                          ', + ); + }, + ); + + test('preserve backward selection when changing style of 2 different text nodes', async () => { + const editor = createTestEditor(); + + const element = document.createElement('div'); + + editor.setRootElement(element); + + editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const firstText = $createTextNode('first ').setFormat('bold'); + paragraph.append(firstText); + + const secondText = $createTextNode('second').setFormat('italic'); + paragraph.append(secondText); + + $setAnchorPoint({ + key: secondText.getKey(), + offset: 'sec'.length, + type: 'text', + }); + + $setFocusPoint({ + key: firstText.getKey(), + offset: 'fir'.length, + type: 'text', + }); + + const selection = $getSelection(); + + $patchStyleText(selection!, {'font-size': '11px'}); + + const [newAnchor, newFocus] = selection!.getStartEndPoints()!; + + const newAnchorNode: LexicalNode = newAnchor.getNode(); + expect(newAnchorNode.getTextContent()).toBe('sec'); + expect(newAnchor.offset).toBe('sec'.length); + + const newFocusNode: LexicalNode = newFocus.getNode(); + expect(newFocusNode.getTextContent()).toBe('st '); + expect(newFocus.offset).toBe(0); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts new file mode 100644 index 000000000..84c82edec --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts @@ -0,0 +1,918 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createTextNode, + $getSelection, + $isNodeSelection, + $isRangeSelection, + $isTextNode, + LexicalEditor, + PointType, +} from 'lexical'; + +Object.defineProperty(HTMLElement.prototype, 'contentEditable', { + get() { + return this.getAttribute('contenteditable'); + }, + + set(value) { + this.setAttribute('contenteditable', value); + }, +}); + +type Segment = { + index: number; + isWordLike: boolean; + segment: string; +}; + +if (!Selection.prototype.modify) { + const wordBreakPolyfillRegex = + /[\s.,\\/#!$%^&*;:{}=\-`~()\uD800-\uDBFF\uDC00-\uDFFF\u3000-\u303F]/u; + + const pushSegment = function ( + segments: Array, + index: number, + str: string, + isWordLike: boolean, + ): void { + segments.push({ + index: index - str.length, + isWordLike, + segment: str, + }); + }; + + const getWordsFromString = function (string: string): Array { + const segments: Segment[] = []; + let wordString = ''; + let nonWordString = ''; + let i; + + for (i = 0; i < string.length; i++) { + const char = string[i]; + + if (wordBreakPolyfillRegex.test(char)) { + if (wordString !== '') { + pushSegment(segments, i, wordString, true); + wordString = ''; + } + + nonWordString += char; + } else { + if (nonWordString !== '') { + pushSegment(segments, i, nonWordString, false); + nonWordString = ''; + } + + wordString += char; + } + } + + if (wordString !== '') { + pushSegment(segments, i, wordString, true); + } + + if (nonWordString !== '') { + pushSegment(segments, i, nonWordString, false); + } + + return segments; + }; + + Selection.prototype.modify = function (alter, direction, granularity) { + // This is not a thorough implementation, it was more to get tests working + // given the refactor to use this selection method. + const symbol = Object.getOwnPropertySymbols(this)[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const impl = (this as any)[symbol]; + const focus = impl._focus; + const anchor = impl._anchor; + + if (granularity === 'character') { + let anchorNode = anchor.node; + let anchorOffset = anchor.offset; + let _$isTextNode = false; + + if (anchorNode.nodeType === 3) { + _$isTextNode = true; + anchorNode = anchorNode.parentElement; + } else if (anchorNode.nodeName === 'BR') { + const parentNode = anchorNode.parentElement; + const childNodes = Array.from(parentNode.childNodes); + anchorOffset = childNodes.indexOf(anchorNode); + anchorNode = parentNode; + } + + if (direction === 'backward') { + if (anchorOffset === 0) { + let prevSibling = anchorNode.previousSibling; + + if (prevSibling === null) { + prevSibling = anchorNode.parentElement.previousSibling.lastChild; + } + + if (prevSibling.nodeName === 'P') { + prevSibling = prevSibling.firstChild; + } + + if (prevSibling.nodeName === 'BR') { + anchor.node = prevSibling; + anchor.offset = 0; + } else { + anchor.node = prevSibling.firstChild; + anchor.offset = anchor.node.nodeValue.length - 1; + } + } else if (!_$isTextNode) { + anchor.node = anchorNode.childNodes[anchorOffset - 1]; + anchor.offset = anchor.node.nodeValue.length - 1; + } else { + anchor.offset--; + } + } else { + if ( + (_$isTextNode && anchorOffset === anchorNode.textContent.length) || + (!_$isTextNode && + (anchorNode.childNodes.length === anchorOffset || + (anchorNode.childNodes.length === 1 && + anchorNode.firstChild.nodeName === 'BR'))) + ) { + let nextSibling = anchorNode.nextSibling; + + if (nextSibling === null) { + nextSibling = anchorNode.parentElement.nextSibling.lastChild; + } + + if (nextSibling.nodeName === 'P') { + nextSibling = nextSibling.lastChild; + } + + if (nextSibling.nodeName === 'BR') { + anchor.node = nextSibling; + anchor.offset = 0; + } else { + anchor.node = nextSibling.firstChild; + anchor.offset = 0; + } + } else { + anchor.offset++; + } + } + } else if (granularity === 'word') { + const anchorNode = this.anchorNode!; + const targetTextContent = + direction === 'backward' + ? anchorNode.textContent!.slice(0, this.anchorOffset) + : anchorNode.textContent!.slice(this.anchorOffset); + const segments = getWordsFromString(targetTextContent); + const segmentsLength = segments.length; + let index = anchor.offset; + let foundWordNode = false; + + if (direction === 'backward') { + for (let i = segmentsLength - 1; i >= 0; i--) { + const segment = segments[i]; + const nextIndex = segment.index; + + if (segment.isWordLike) { + index = nextIndex; + foundWordNode = true; + } else if (foundWordNode) { + break; + } else { + index = nextIndex; + } + } + } else { + for (let i = 0; i < segmentsLength; i++) { + const segment = segments[i]; + const nextIndex = segment.index + segment.segment.length; + + if (segment.isWordLike) { + index = nextIndex; + foundWordNode = true; + } else if (foundWordNode) { + break; + } else { + index = nextIndex; + } + } + } + + if (direction === 'forward') { + index += anchor.offset; + } + + anchor.offset = index; + } + + if (alter === 'move') { + focus.offset = anchor.offset; + focus.node = anchor.node; + } + }; +} + +export function printWhitespace(whitespaceCharacter: string) { + return whitespaceCharacter.charCodeAt(0) === 160 + ? ' ' + : whitespaceCharacter; +} + +export function insertText(text: string) { + return { + text, + type: 'insert_text', + }; +} + +export function insertTokenNode(text: string) { + return { + text, + type: 'insert_token_node', + }; +} + +export function insertSegmentedNode(text: string) { + return { + text, + type: 'insert_segmented_node', + }; +} + +export function convertToTokenNode() { + return { + text: null, + type: 'convert_to_token_node', + }; +} + +export function convertToSegmentedNode() { + return { + text: null, + type: 'convert_to_segmented_node', + }; +} + +export function insertParagraph() { + return { + type: 'insert_paragraph', + }; +} + +export function deleteWordBackward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_word_backward', + }; +} + +export function deleteWordForward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_word_forward', + }; +} + +export function moveBackward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'move_backward', + }; +} + +export function moveForward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'move_forward', + }; +} + +export function moveEnd() { + return { + type: 'move_end', + }; +} + +export function deleteBackward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_backward', + }; +} + +export function deleteForward(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'delete_forward', + }; +} + +export function formatBold() { + return { + format: 'bold', + type: 'format_text', + }; +} + +export function formatItalic() { + return { + format: 'italic', + type: 'format_text', + }; +} + +export function formatStrikeThrough() { + return { + format: 'strikethrough', + type: 'format_text', + }; +} + +export function formatUnderline() { + return { + format: 'underline', + type: 'format_text', + }; +} + +export function redo(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'redo', + }; +} + +export function undo(n: number | null | undefined) { + return { + text: null, + times: n, + type: 'undo', + }; +} + +export function pastePlain(text: string) { + return { + text: text, + type: 'paste_plain', + }; +} + +export function pasteLexical(text: string) { + return { + text: text, + type: 'paste_lexical', + }; +} + +export function pasteHTML(text: string) { + return { + text: text, + type: 'paste_html', + }; +} + +export function moveNativeSelection( + anchorPath: number[], + anchorOffset: number, + focusPath: number[], + focusOffset: number, +) { + return { + anchorOffset, + anchorPath, + focusOffset, + focusPath, + type: 'move_native_selection', + }; +} + +export function getNodeFromPath(path: number[], rootElement: Node) { + let node = rootElement; + + for (let i = 0; i < path.length; i++) { + node = node.childNodes[path[i]]; + } + + return node; +} + +export function setNativeSelection( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, +) { + const domSelection = window.getSelection()!; + const range = document.createRange(); + range.setStart(anchorNode, anchorOffset); + range.setEnd(focusNode, focusOffset); + domSelection.removeAllRanges(); + domSelection.addRange(range); + Promise.resolve().then(() => { + document.dispatchEvent(new Event('selectionchange')); + }); +} + +export function setNativeSelectionWithPaths( + rootElement: Node, + anchorPath: number[], + anchorOffset: number, + focusPath: number[], + focusOffset: number, +) { + const anchorNode = getNodeFromPath(anchorPath, rootElement); + const focusNode = getNodeFromPath(focusPath, rootElement); + setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset); +} + +function getLastTextNode(startingNode: Node) { + let node = startingNode; + + mainLoop: while (node !== null) { + if (node !== startingNode && node.nodeType === 3) { + return node; + } + + const child = node.lastChild; + + if (child !== null) { + node = child; + continue; + } + + const previousSibling = node.previousSibling; + + if (previousSibling !== null) { + node = previousSibling; + continue; + } + + let parent = node.parentNode; + + while (parent !== null) { + const parentSibling = parent.previousSibling; + + if (parentSibling !== null) { + node = parentSibling; + continue mainLoop; + } + + parent = parent.parentNode; + } + } + + return null; +} + +function getNextTextNode(startingNode: Node) { + let node = startingNode; + + mainLoop: while (node !== null) { + if (node !== startingNode && node.nodeType === 3) { + return node; + } + + const child = node.firstChild; + + if (child !== null) { + node = child; + continue; + } + + const nextSibling = node.nextSibling; + + if (nextSibling !== null) { + node = nextSibling; + continue; + } + + let parent = node.parentNode; + + while (parent !== null) { + const parentSibling = parent.nextSibling; + + if (parentSibling !== null) { + node = parentSibling; + continue mainLoop; + } + + parent = parent.parentNode; + } + } + + return null; +} + +function moveNativeSelectionBackward() { + const domSelection = window.getSelection()!; + let anchorNode = domSelection.anchorNode!; + let anchorOffset = domSelection.anchorOffset!; + + if (domSelection.isCollapsed) { + const target = ( + anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode + )!; + const keyDownEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowLeft', + keyCode: 37, + }); + target.dispatchEvent(keyDownEvent); + + if (!keyDownEvent.defaultPrevented) { + if (anchorNode.nodeType === 3) { + if (anchorOffset === 0) { + const lastTextNode = getLastTextNode(anchorNode); + + if (lastTextNode === null) { + throw new Error('moveNativeSelectionBackward: TODO'); + } else { + const textLength = lastTextNode.nodeValue!.length; + setNativeSelection( + lastTextNode, + textLength, + lastTextNode, + textLength, + ); + } + } else { + setNativeSelection( + anchorNode, + anchorOffset - 1, + anchorNode, + anchorOffset - 1, + ); + } + } else if (anchorNode.nodeType === 1) { + if (anchorNode.nodeName === 'BR') { + const parentNode = anchorNode.parentNode!; + const childNodes = Array.from(parentNode.childNodes); + anchorOffset = childNodes.indexOf(anchorNode as ChildNode); + anchorNode = parentNode; + } else { + anchorOffset--; + } + + setNativeSelection(anchorNode, anchorOffset, anchorNode, anchorOffset); + } else { + throw new Error('moveNativeSelectionBackward: TODO'); + } + } + + const keyUpEvent = new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + key: 'ArrowLeft', + keyCode: 37, + }); + target.dispatchEvent(keyUpEvent); + } else { + throw new Error('moveNativeSelectionBackward: TODO'); + } +} + +function moveNativeSelectionForward() { + const domSelection = window.getSelection()!; + const anchorNode = domSelection.anchorNode!; + const anchorOffset = domSelection.anchorOffset!; + + if (domSelection.isCollapsed) { + const target = ( + anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode + )!; + const keyDownEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'ArrowRight', + keyCode: 39, + }); + target.dispatchEvent(keyDownEvent); + + if (!keyDownEvent.defaultPrevented) { + if (anchorNode.nodeType === 3) { + const text = anchorNode.nodeValue!; + + if (text.length === anchorOffset) { + const nextTextNode = getNextTextNode(anchorNode); + + if (nextTextNode === null) { + throw new Error('moveNativeSelectionForward: TODO'); + } else { + setNativeSelection(nextTextNode, 0, nextTextNode, 0); + } + } else { + setNativeSelection( + anchorNode, + anchorOffset + 1, + anchorNode, + anchorOffset + 1, + ); + } + } else { + throw new Error('moveNativeSelectionForward: TODO'); + } + } + + const keyUpEvent = new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + key: 'ArrowRight', + keyCode: 39, + }); + target.dispatchEvent(keyUpEvent); + } else { + throw new Error('moveNativeSelectionForward: TODO'); + } +} + +export async function applySelectionInputs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: Record[], + update: (fn: () => void) => Promise, + editor: LexicalEditor, +) { + const rootElement = editor.getRootElement()!; + + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const times = input?.times ?? 1; + + for (let j = 0; j < times; j++) { + await update(() => { + const selection = $getSelection()!; + + switch (input.type) { + case 'insert_text': { + selection.insertText(input.text); + break; + } + + case 'insert_paragraph': { + if ($isRangeSelection(selection)) { + selection.insertParagraph(); + } + break; + } + + case 'move_backward': { + moveNativeSelectionBackward(); + break; + } + + case 'move_forward': { + moveNativeSelectionForward(); + break; + } + + case 'move_end': { + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + if ($isTextNode(anchorNode)) { + anchorNode.select(); + } + } + break; + } + + case 'delete_backward': { + if ($isRangeSelection(selection)) { + selection.deleteCharacter(true); + } + break; + } + + case 'delete_forward': { + if ($isRangeSelection(selection)) { + selection.deleteCharacter(false); + } + break; + } + + case 'delete_word_backward': { + if ($isRangeSelection(selection)) { + selection.deleteWord(true); + } + break; + } + + case 'delete_word_forward': { + if ($isRangeSelection(selection)) { + selection.deleteWord(false); + } + break; + } + + case 'format_text': { + if ($isRangeSelection(selection)) { + selection.formatText(input.format); + } + break; + } + + case 'move_native_selection': { + setNativeSelectionWithPaths( + rootElement, + input.anchorPath, + input.anchorOffset, + input.focusPath, + input.focusOffset, + ); + break; + } + + case 'insert_token_node': { + const text = $createTextNode(input.text); + text.setMode('token'); + if ($isRangeSelection(selection)) { + selection.insertNodes([text]); + } + break; + } + + case 'insert_segmented_node': { + const text = $createTextNode(input.text); + text.setMode('segmented'); + if ($isRangeSelection(selection)) { + selection.insertNodes([text]); + } + text.selectNext(); + break; + } + + case 'convert_to_token_node': { + const text = $createTextNode(selection.getTextContent()); + text.setMode('token'); + if ($isRangeSelection(selection)) { + selection.insertNodes([text]); + } + text.selectNext(); + break; + } + + case 'convert_to_segmented_node': { + const text = $createTextNode(selection.getTextContent()); + text.setMode('segmented'); + if ($isRangeSelection(selection)) { + selection.insertNodes([text]); + } + text.selectNext(); + break; + } + + case 'undo': { + rootElement.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: 'z', + keyCode: 90, + }), + ); + break; + } + + case 'redo': { + rootElement.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: 'z', + keyCode: 90, + shiftKey: true, + }), + ); + break; + } + + case 'paste_plain': { + rootElement.dispatchEvent( + Object.assign( + new Event('paste', { + bubbles: true, + cancelable: true, + }), + { + clipboardData: { + getData: (type: string) => { + if (type === 'text/plain') { + return input.text; + } + + return ''; + }, + }, + }, + ), + ); + break; + } + + case 'paste_lexical': { + rootElement.dispatchEvent( + Object.assign( + new Event('paste', { + bubbles: true, + cancelable: true, + }), + { + clipboardData: { + getData: (type: string) => { + if (type === 'application/x-lexical-editor') { + return input.text; + } + + return ''; + }, + }, + }, + ), + ); + break; + } + + case 'paste_html': { + rootElement.dispatchEvent( + Object.assign( + new Event('paste', { + bubbles: true, + cancelable: true, + }), + { + clipboardData: { + getData: (type: string) => { + if (type === 'text/html') { + return input.text; + } + + return ''; + }, + }, + }, + ), + ); + break; + } + } + }); + } + } +} + +export function $setAnchorPoint( + point: Pick, +) { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + const dummyTextNode = $createTextNode(); + dummyTextNode.select(); + return $setAnchorPoint(point); + } + + if ($isNodeSelection(selection)) { + return; + } + + const anchor = selection.anchor; + anchor.type = point.type; + anchor.offset = point.offset; + anchor.key = point.key; +} + +export function $setFocusPoint( + point: Pick, +) { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + const dummyTextNode = $createTextNode(); + dummyTextNode.select(); + return $setFocusPoint(point); + } + + if ($isNodeSelection(selection)) { + return; + } + + const focus = selection.focus; + focus.type = point.type; + focus.offset = point.offset; + focus.key = point.key; +} diff --git a/resources/js/wysiwyg/lexical/selection/constants.ts b/resources/js/wysiwyg/lexical/selection/constants.ts new file mode 100644 index 000000000..104f57df5 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/constants.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +export const CSS_TO_STYLES: Map> = new Map(); diff --git a/resources/js/wysiwyg/lexical/selection/index.ts b/resources/js/wysiwyg/lexical/selection/index.ts new file mode 100644 index 000000000..b2d18b164 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/index.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $addNodeStyle, + $isAtNodeEnd, + $patchStyleText, + $sliceSelectedTextNodeContent, + $trimTextContentFromAnchor, +} from './lexical-node'; +import { + $getSelectionStyleValueForProperty, + $isParentElementRTL, + $moveCaretSelection, + $moveCharacter, + $selectAll, + $setBlocksType, + $shouldOverrideDefaultCharacterSelection, + $wrapNodes, +} from './range-selection'; +import { + createDOMRange, + createRectsFromDOMRange, + getStyleObjectFromCSS, +} from './utils'; + +export { + /** @deprecated moved to the lexical package */ $cloneWithProperties, +} from 'lexical'; +export { + $addNodeStyle, + $isAtNodeEnd, + $patchStyleText, + $sliceSelectedTextNodeContent, + $trimTextContentFromAnchor, +}; +/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */ +export const trimTextContentFromAnchor = $trimTextContentFromAnchor; + +export { + $getSelectionStyleValueForProperty, + $isParentElementRTL, + $moveCaretSelection, + $moveCharacter, + $selectAll, + $setBlocksType, + $shouldOverrideDefaultCharacterSelection, + $wrapNodes, +}; + +export {createDOMRange, createRectsFromDOMRange, getStyleObjectFromCSS}; diff --git a/resources/js/wysiwyg/lexical/selection/lexical-node.ts b/resources/js/wysiwyg/lexical/selection/lexical-node.ts new file mode 100644 index 000000000..82f7d330e --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/lexical-node.ts @@ -0,0 +1,427 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { + $createTextNode, + $getCharacterOffsets, + $getNodeByKey, + $getPreviousSelection, + $isElementNode, + $isRangeSelection, + $isRootNode, + $isTextNode, + $isTokenOrSegmented, + BaseSelection, + LexicalEditor, + LexicalNode, + Point, + RangeSelection, + TextNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {CSS_TO_STYLES} from './constants'; +import { + getCSSFromStyleObject, + getStyleObjectFromCSS, + getStyleObjectFromRawCSS, +} from './utils'; + +/** + * Generally used to append text content to HTML and JSON. Grabs the text content and "slices" + * it to be generated into the new TextNode. + * @param selection - The selection containing the node whose TextNode is to be edited. + * @param textNode - The TextNode to be edited. + * @returns The updated TextNode. + */ +export function $sliceSelectedTextNodeContent( + selection: BaseSelection, + textNode: TextNode, +): LexicalNode { + const anchorAndFocus = selection.getStartEndPoints(); + if ( + textNode.isSelected(selection) && + !textNode.isSegmented() && + !textNode.isToken() && + anchorAndFocus !== null + ) { + const [anchor, focus] = anchorAndFocus; + const isBackward = selection.isBackward(); + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + const isAnchor = textNode.is(anchorNode); + const isFocus = textNode.is(focusNode); + + if (isAnchor || isFocus) { + const [anchorOffset, focusOffset] = $getCharacterOffsets(selection); + const isSame = anchorNode.is(focusNode); + const isFirst = textNode.is(isBackward ? focusNode : anchorNode); + const isLast = textNode.is(isBackward ? anchorNode : focusNode); + let startOffset = 0; + let endOffset = undefined; + + if (isSame) { + startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset; + endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset; + } else if (isFirst) { + const offset = isBackward ? focusOffset : anchorOffset; + startOffset = offset; + endOffset = undefined; + } else if (isLast) { + const offset = isBackward ? anchorOffset : focusOffset; + startOffset = 0; + endOffset = offset; + } + + textNode.__text = textNode.__text.slice(startOffset, endOffset); + return textNode; + } + } + return textNode; +} + +/** + * Determines if the current selection is at the end of the node. + * @param point - The point of the selection to test. + * @returns true if the provided point offset is in the last possible position, false otherwise. + */ +export function $isAtNodeEnd(point: Point): boolean { + if (point.type === 'text') { + return point.offset === point.getNode().getTextContentSize(); + } + const node = point.getNode(); + invariant( + $isElementNode(node), + 'isAtNodeEnd: node must be a TextNode or ElementNode', + ); + + return point.offset === node.getChildrenSize(); +} + +/** + * Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text + * that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes + * the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode. + * @param editor - The lexical editor. + * @param anchor - The anchor of the current selection, where the selection should be pointing. + * @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength; + */ +export function $trimTextContentFromAnchor( + editor: LexicalEditor, + anchor: Point, + delCount: number, +): void { + // Work from the current selection anchor point + let currentNode: LexicalNode | null = anchor.getNode(); + let remaining: number = delCount; + + if ($isElementNode(currentNode)) { + const descendantNode = currentNode.getDescendantByIndex(anchor.offset); + if (descendantNode !== null) { + currentNode = descendantNode; + } + } + + while (remaining > 0 && currentNode !== null) { + if ($isElementNode(currentNode)) { + const lastDescendant: null | LexicalNode = + currentNode.getLastDescendant(); + if (lastDescendant !== null) { + currentNode = lastDescendant; + } + } + let nextNode: LexicalNode | null = currentNode.getPreviousSibling(); + let additionalElementWhitespace = 0; + if (nextNode === null) { + let parent: LexicalNode | null = currentNode.getParentOrThrow(); + let parentSibling: LexicalNode | null = parent.getPreviousSibling(); + + while (parentSibling === null) { + parent = parent.getParent(); + if (parent === null) { + nextNode = null; + break; + } + parentSibling = parent.getPreviousSibling(); + } + if (parent !== null) { + additionalElementWhitespace = parent.isInline() ? 0 : 2; + nextNode = parentSibling; + } + } + let text = currentNode.getTextContent(); + // If the text is empty, we need to consider adding in two line breaks to match + // the content if we were to get it from its parent. + if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) { + // TODO: should this be handled in core? + text = '\n\n'; + } + const currentNodeSize = text.length; + + if (!$isTextNode(currentNode) || remaining >= currentNodeSize) { + const parent = currentNode.getParent(); + currentNode.remove(); + if ( + parent != null && + parent.getChildrenSize() === 0 && + !$isRootNode(parent) + ) { + parent.remove(); + } + remaining -= currentNodeSize + additionalElementWhitespace; + currentNode = nextNode; + } else { + const key = currentNode.getKey(); + // See if we can just revert it to what was in the last editor state + const prevTextContent: string | null = editor + .getEditorState() + .read(() => { + const prevNode = $getNodeByKey(key); + if ($isTextNode(prevNode) && prevNode.isSimpleText()) { + return prevNode.getTextContent(); + } + return null; + }); + const offset = currentNodeSize - remaining; + const slicedText = text.slice(0, offset); + if (prevTextContent !== null && prevTextContent !== text) { + const prevSelection = $getPreviousSelection(); + let target = currentNode; + if (!currentNode.isSimpleText()) { + const textNode = $createTextNode(prevTextContent); + currentNode.replace(textNode); + target = textNode; + } else { + currentNode.setTextContent(prevTextContent); + } + if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) { + const prevOffset = prevSelection.anchor.offset; + target.select(prevOffset, prevOffset); + } + } else if (currentNode.isSimpleText()) { + // Split text + const isSelected = anchor.key === key; + let anchorOffset = anchor.offset; + // Move offset to end if it's less than the remaining number, otherwise + // we'll have a negative splitStart. + if (anchorOffset < remaining) { + anchorOffset = currentNodeSize; + } + const splitStart = isSelected ? anchorOffset - remaining : 0; + const splitEnd = isSelected ? anchorOffset : offset; + if (isSelected && splitStart === 0) { + const [excessNode] = currentNode.splitText(splitStart, splitEnd); + excessNode.remove(); + } else { + const [, excessNode] = currentNode.splitText(splitStart, splitEnd); + excessNode.remove(); + } + } else { + const textNode = $createTextNode(slicedText); + currentNode.replace(textNode); + } + remaining = 0; + } + } +} + +/** + * Gets the TextNode's style object and adds the styles to the CSS. + * @param node - The TextNode to add styles to. + */ +export function $addNodeStyle(node: TextNode): void { + const CSSText = node.getStyle(); + const styles = getStyleObjectFromRawCSS(CSSText); + CSS_TO_STYLES.set(CSSText, styles); +} + +function $patchStyle( + target: TextNode | RangeSelection, + patch: Record< + string, + | string + | null + | ((currentStyleValue: string | null, _target: typeof target) => string) + >, +): void { + const prevStyles = getStyleObjectFromCSS( + 'getStyle' in target ? target.getStyle() : target.style, + ); + const newStyles = Object.entries(patch).reduce>( + (styles, [key, value]) => { + if (typeof value === 'function') { + styles[key] = value(prevStyles[key], target); + } else if (value === null) { + delete styles[key]; + } else { + styles[key] = value; + } + return styles; + }, + {...prevStyles} || {}, + ); + const newCSSText = getCSSFromStyleObject(newStyles); + target.setStyle(newCSSText); + CSS_TO_STYLES.set(newCSSText, newStyles); +} + +/** + * Applies the provided styles to the TextNodes in the provided Selection. + * Will update partially selected TextNodes by splitting the TextNode and applying + * the styles to the appropriate one. + * @param selection - The selected node(s) to update. + * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value. + */ +export function $patchStyleText( + selection: BaseSelection, + patch: Record< + string, + | string + | null + | (( + currentStyleValue: string | null, + target: TextNode | RangeSelection, + ) => string) + >, +): void { + const selectedNodes = selection.getNodes(); + const selectedNodesLength = selectedNodes.length; + const anchorAndFocus = selection.getStartEndPoints(); + if (anchorAndFocus === null) { + return; + } + const [anchor, focus] = anchorAndFocus; + + const lastIndex = selectedNodesLength - 1; + let firstNode = selectedNodes[0]; + let lastNode = selectedNodes[lastIndex]; + + if (selection.isCollapsed() && $isRangeSelection(selection)) { + $patchStyle(selection, patch); + return; + } + + const firstNodeText = firstNode.getTextContent(); + const firstNodeTextLength = firstNodeText.length; + const focusOffset = focus.offset; + let anchorOffset = anchor.offset; + const isBefore = anchor.isBefore(focus); + let startOffset = isBefore ? anchorOffset : focusOffset; + let endOffset = isBefore ? focusOffset : anchorOffset; + const startType = isBefore ? anchor.type : focus.type; + const endType = isBefore ? focus.type : anchor.type; + const endKey = isBefore ? focus.key : anchor.key; + + // This is the case where the user only selected the very end of the + // first node so we don't want to include it in the formatting change. + if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) { + const nextSibling = firstNode.getNextSibling(); + + if ($isTextNode(nextSibling)) { + // we basically make the second node the firstNode, changing offsets accordingly + anchorOffset = 0; + startOffset = 0; + firstNode = nextSibling; + } + } + + // This is the case where we only selected a single node + if (selectedNodes.length === 1) { + if ($isTextNode(firstNode) && firstNode.canHaveFormat()) { + startOffset = + startType === 'element' + ? 0 + : anchorOffset > focusOffset + ? focusOffset + : anchorOffset; + endOffset = + endType === 'element' + ? firstNodeTextLength + : anchorOffset > focusOffset + ? anchorOffset + : focusOffset; + + // No actual text is selected, so do nothing. + if (startOffset === endOffset) { + return; + } + + // The entire node is selected or a token/segment, so just format it + if ( + $isTokenOrSegmented(firstNode) || + (startOffset === 0 && endOffset === firstNodeTextLength) + ) { + $patchStyle(firstNode, patch); + firstNode.select(startOffset, endOffset); + } else { + // The node is partially selected, so split it into two nodes + // and style the selected one. + const splitNodes = firstNode.splitText(startOffset, endOffset); + const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]; + $patchStyle(replacement, patch); + replacement.select(0, endOffset - startOffset); + } + } // multiple nodes selected. + } else { + if ( + $isTextNode(firstNode) && + startOffset < firstNode.getTextContentSize() && + firstNode.canHaveFormat() + ) { + if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) { + // the entire first node isn't selected and it isn't a token or segmented, so split it + firstNode = firstNode.splitText(startOffset)[1]; + startOffset = 0; + if (isBefore) { + anchor.set(firstNode.getKey(), startOffset, 'text'); + } else { + focus.set(firstNode.getKey(), startOffset, 'text'); + } + } + + $patchStyle(firstNode as TextNode, patch); + } + + if ($isTextNode(lastNode) && lastNode.canHaveFormat()) { + const lastNodeText = lastNode.getTextContent(); + const lastNodeTextLength = lastNodeText.length; + + // The last node might not actually be the end node + // + // If not, assume the last node is fully-selected unless the end offset is + // zero. + if (lastNode.__key !== endKey && endOffset !== 0) { + endOffset = lastNodeTextLength; + } + + // if the entire last node isn't selected and it isn't a token or segmented, split it + if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) { + [lastNode] = lastNode.splitText(endOffset); + } + + if (endOffset !== 0 || endType === 'element') { + $patchStyle(lastNode as TextNode, patch); + } + } + + // style all the text nodes in between + for (let i = 1; i < lastIndex; i++) { + const selectedNode = selectedNodes[i]; + const selectedNodeKey = selectedNode.getKey(); + + if ( + $isTextNode(selectedNode) && + selectedNode.canHaveFormat() && + selectedNodeKey !== firstNode.getKey() && + selectedNodeKey !== lastNode.getKey() && + !selectedNode.isToken() + ) { + $patchStyle(selectedNode, patch); + } + } + } +} diff --git a/resources/js/wysiwyg/lexical/selection/range-selection.ts b/resources/js/wysiwyg/lexical/selection/range-selection.ts new file mode 100644 index 000000000..dbadaf346 --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/range-selection.ts @@ -0,0 +1,608 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + BaseSelection, + ElementNode, + LexicalNode, + NodeKey, + Point, + RangeSelection, + TextNode, +} from 'lexical'; + +import {TableSelection} from '@lexical/table'; +import { + $getAdjacentNode, + $getPreviousSelection, + $getRoot, + $hasAncestor, + $isDecoratorNode, + $isElementNode, + $isLeafNode, + $isLineBreakNode, + $isRangeSelection, + $isRootNode, + $isRootOrShadowRoot, + $isTextNode, + $setSelection, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {getStyleObjectFromCSS} from './utils'; + +/** + * Converts all nodes in the selection that are of one block type to another. + * @param selection - The selected blocks to be converted. + * @param createElement - The function that creates the node. eg. $createParagraphNode. + */ +export function $setBlocksType( + selection: BaseSelection | null, + createElement: () => ElementNode, +): void { + if (selection === null) { + return; + } + const anchorAndFocus = selection.getStartEndPoints(); + const anchor = anchorAndFocus ? anchorAndFocus[0] : null; + + if (anchor !== null && anchor.key === 'root') { + const element = createElement(); + const root = $getRoot(); + const firstChild = root.getFirstChild(); + + if (firstChild) { + firstChild.replace(element, true); + } else { + root.append(element); + } + + return; + } + + const nodes = selection.getNodes(); + const firstSelectedBlock = + anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false; + if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) { + nodes.push(firstSelectedBlock); + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if (!INTERNAL_$isBlock(node)) { + continue; + } + invariant($isElementNode(node), 'Expected block node to be an ElementNode'); + + const targetElement = createElement(); + targetElement.setFormat(node.getFormatType()); + targetElement.setIndent(node.getIndent()); + node.replace(targetElement, true); + } +} + +function isPointAttached(point: Point): boolean { + return point.getNode().isAttached(); +} + +function $removeParentEmptyElements(startingNode: ElementNode): void { + let node: ElementNode | null = startingNode; + + while (node !== null && !$isRootOrShadowRoot(node)) { + const latest = node.getLatest(); + const parentNode: ElementNode | null = node.getParent(); + + if (latest.getChildrenSize() === 0) { + node.remove(true); + } + + node = parentNode; + } +} + +/** + * @deprecated + * Wraps all nodes in the selection into another node of the type returned by createElement. + * @param selection - The selection of nodes to be wrapped. + * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode. + * @param wrappingElement - An element to append the wrapped selection and its children to. + */ +export function $wrapNodes( + selection: BaseSelection, + createElement: () => ElementNode, + wrappingElement: null | ElementNode = null, +): void { + const anchorAndFocus = selection.getStartEndPoints(); + const anchor = anchorAndFocus ? anchorAndFocus[0] : null; + const nodes = selection.getNodes(); + const nodesLength = nodes.length; + + if ( + anchor !== null && + (nodesLength === 0 || + (nodesLength === 1 && + anchor.type === 'element' && + anchor.getNode().getChildrenSize() === 0)) + ) { + const target = + anchor.type === 'text' + ? anchor.getNode().getParentOrThrow() + : anchor.getNode(); + const children = target.getChildren(); + let element = createElement(); + element.setFormat(target.getFormatType()); + element.setIndent(target.getIndent()); + children.forEach((child) => element.append(child)); + + if (wrappingElement) { + element = wrappingElement.append(element); + } + + target.replace(element); + + return; + } + + let topLevelNode = null; + let descendants: LexicalNode[] = []; + for (let i = 0; i < nodesLength; i++) { + const node = nodes[i]; + // Determine whether wrapping has to be broken down into multiple chunks. This can happen if the + // user selected multiple Root-like nodes that have to be treated separately as if they are + // their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each + // of each of the cell nodes. + if ($isRootOrShadowRoot(node)) { + $wrapNodesImpl( + selection, + descendants, + descendants.length, + createElement, + wrappingElement, + ); + descendants = []; + topLevelNode = node; + } else if ( + topLevelNode === null || + (topLevelNode !== null && $hasAncestor(node, topLevelNode)) + ) { + descendants.push(node); + } else { + $wrapNodesImpl( + selection, + descendants, + descendants.length, + createElement, + wrappingElement, + ); + descendants = [node]; + } + } + $wrapNodesImpl( + selection, + descendants, + descendants.length, + createElement, + wrappingElement, + ); +} + +/** + * Wraps each node into a new ElementNode. + * @param selection - The selection of nodes to wrap. + * @param nodes - An array of nodes, generally the descendants of the selection. + * @param nodesLength - The length of nodes. + * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode. + * @param wrappingElement - An element to wrap all the nodes into. + * @returns + */ +export function $wrapNodesImpl( + selection: BaseSelection, + nodes: LexicalNode[], + nodesLength: number, + createElement: () => ElementNode, + wrappingElement: null | ElementNode = null, +): void { + if (nodes.length === 0) { + return; + } + + const firstNode = nodes[0]; + const elementMapping: Map = new Map(); + const elements = []; + // The below logic is to find the right target for us to + // either insertAfter/insertBefore/append the corresponding + // elements to. This is made more complicated due to nested + // structures. + let target = $isElementNode(firstNode) + ? firstNode + : firstNode.getParentOrThrow(); + + if (target.isInline()) { + target = target.getParentOrThrow(); + } + + let targetIsPrevSibling = false; + while (target !== null) { + const prevSibling = target.getPreviousSibling(); + + if (prevSibling !== null) { + target = prevSibling; + targetIsPrevSibling = true; + break; + } + + target = target.getParentOrThrow(); + + if ($isRootOrShadowRoot(target)) { + break; + } + } + + const emptyElements = new Set(); + + // Find any top level empty elements + for (let i = 0; i < nodesLength; i++) { + const node = nodes[i]; + + if ($isElementNode(node) && node.getChildrenSize() === 0) { + emptyElements.add(node.getKey()); + } + } + + const movedNodes: Set = new Set(); + + // Move out all leaf nodes into our elements array. + // If we find a top level empty element, also move make + // an element for that. + for (let i = 0; i < nodesLength; i++) { + const node = nodes[i]; + let parent = node.getParent(); + + if (parent !== null && parent.isInline()) { + parent = parent.getParent(); + } + + if ( + parent !== null && + $isLeafNode(node) && + !movedNodes.has(node.getKey()) + ) { + const parentKey = parent.getKey(); + + if (elementMapping.get(parentKey) === undefined) { + const targetElement = createElement(); + targetElement.setFormat(parent.getFormatType()); + targetElement.setIndent(parent.getIndent()); + elements.push(targetElement); + elementMapping.set(parentKey, targetElement); + // Move node and its siblings to the new + // element. + parent.getChildren().forEach((child) => { + targetElement.append(child); + movedNodes.add(child.getKey()); + if ($isElementNode(child)) { + // Skip nested leaf nodes if the parent has already been moved + child.getChildrenKeys().forEach((key) => movedNodes.add(key)); + } + }); + $removeParentEmptyElements(parent); + } + } else if (emptyElements.has(node.getKey())) { + invariant( + $isElementNode(node), + 'Expected node in emptyElements to be an ElementNode', + ); + const targetElement = createElement(); + targetElement.setFormat(node.getFormatType()); + targetElement.setIndent(node.getIndent()); + elements.push(targetElement); + node.remove(true); + } + } + + if (wrappingElement !== null) { + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + wrappingElement.append(element); + } + } + let lastElement = null; + + // If our target is Root-like, let's see if we can re-adjust + // so that the target is the first child instead. + if ($isRootOrShadowRoot(target)) { + if (targetIsPrevSibling) { + if (wrappingElement !== null) { + target.insertAfter(wrappingElement); + } else { + for (let i = elements.length - 1; i >= 0; i--) { + const element = elements[i]; + target.insertAfter(element); + } + } + } else { + const firstChild = target.getFirstChild(); + + if ($isElementNode(firstChild)) { + target = firstChild; + } + + if (firstChild === null) { + if (wrappingElement) { + target.append(wrappingElement); + } else { + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + target.append(element); + lastElement = element; + } + } + } else { + if (wrappingElement !== null) { + firstChild.insertBefore(wrappingElement); + } else { + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + firstChild.insertBefore(element); + lastElement = element; + } + } + } + } + } else { + if (wrappingElement) { + target.insertAfter(wrappingElement); + } else { + for (let i = elements.length - 1; i >= 0; i--) { + const element = elements[i]; + target.insertAfter(element); + lastElement = element; + } + } + } + + const prevSelection = $getPreviousSelection(); + + if ( + $isRangeSelection(prevSelection) && + isPointAttached(prevSelection.anchor) && + isPointAttached(prevSelection.focus) + ) { + $setSelection(prevSelection.clone()); + } else if (lastElement !== null) { + lastElement.selectEnd(); + } else { + selection.dirty = true; + } +} + +/** + * Determines if the default character selection should be overridden. Used with DecoratorNodes + * @param selection - The selection whose default character selection may need to be overridden. + * @param isBackward - Is the selection backwards (the focus comes before the anchor)? + * @returns true if it should be overridden, false if not. + */ +export function $shouldOverrideDefaultCharacterSelection( + selection: RangeSelection, + isBackward: boolean, +): boolean { + const possibleNode = $getAdjacentNode(selection.focus, isBackward); + + return ( + ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) || + ($isElementNode(possibleNode) && + !possibleNode.isInline() && + !possibleNode.canBeEmpty()) + ); +} + +/** + * Moves the selection according to the arguments. + * @param selection - The selected text or nodes. + * @param isHoldingShift - Is the shift key being held down during the operation. + * @param isBackward - Is the selection selected backwards (the focus comes before the anchor)? + * @param granularity - The distance to adjust the current selection. + */ +export function $moveCaretSelection( + selection: RangeSelection, + isHoldingShift: boolean, + isBackward: boolean, + granularity: 'character' | 'word' | 'lineboundary', +): void { + selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity); +} + +/** + * Tests a parent element for right to left direction. + * @param selection - The selection whose parent is to be tested. + * @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise. + */ +export function $isParentElementRTL(selection: RangeSelection): boolean { + const anchorNode = selection.anchor.getNode(); + const parent = $isRootNode(anchorNode) + ? anchorNode + : anchorNode.getParentOrThrow(); + + return parent.getDirection() === 'rtl'; +} + +/** + * Moves selection by character according to arguments. + * @param selection - The selection of the characters to move. + * @param isHoldingShift - Is the shift key being held down during the operation. + * @param isBackward - Is the selection backward (the focus comes before the anchor)? + */ +export function $moveCharacter( + selection: RangeSelection, + isHoldingShift: boolean, + isBackward: boolean, +): void { + const isRTL = $isParentElementRTL(selection); + $moveCaretSelection( + selection, + isHoldingShift, + isBackward ? !isRTL : isRTL, + 'character', + ); +} + +/** + * Expands the current Selection to cover all of the content in the editor. + * @param selection - The current selection. + */ +export function $selectAll(selection: RangeSelection): void { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const topParent = anchorNode.getTopLevelElementOrThrow(); + const root = topParent.getParentOrThrow(); + let firstNode = root.getFirstDescendant(); + let lastNode = root.getLastDescendant(); + let firstType: 'element' | 'text' = 'element'; + let lastType: 'element' | 'text' = 'element'; + let lastOffset = 0; + + if ($isTextNode(firstNode)) { + firstType = 'text'; + } else if (!$isElementNode(firstNode) && firstNode !== null) { + firstNode = firstNode.getParentOrThrow(); + } + + if ($isTextNode(lastNode)) { + lastType = 'text'; + lastOffset = lastNode.getTextContentSize(); + } else if (!$isElementNode(lastNode) && lastNode !== null) { + lastNode = lastNode.getParentOrThrow(); + } + + if (firstNode && lastNode) { + anchor.set(firstNode.getKey(), 0, firstType); + focus.set(lastNode.getKey(), lastOffset, lastType); + } +} + +/** + * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue. + * @param node - The node whose style value to get. + * @param styleProperty - The CSS style property. + * @param defaultValue - The default value for the property. + * @returns The value of the property for node. + */ +function $getNodeStyleValueForProperty( + node: TextNode, + styleProperty: string, + defaultValue: string, +): string { + const css = node.getStyle(); + const styleObject = getStyleObjectFromCSS(css); + + if (styleObject !== null) { + return styleObject[styleProperty] || defaultValue; + } + + return defaultValue; +} + +/** + * Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue. + * If all TextNodes do not have the same value, it returns an empty string. + * @param selection - The selection of TextNodes whose value to find. + * @param styleProperty - The CSS style property. + * @param defaultValue - The default value for the property, defaults to an empty string. + * @returns The value of the property for the selected TextNodes. + */ +export function $getSelectionStyleValueForProperty( + selection: RangeSelection | TableSelection, + styleProperty: string, + defaultValue = '', +): string { + let styleValue: string | null = null; + const nodes = selection.getNodes(); + const anchor = selection.anchor; + const focus = selection.focus; + const isBackward = selection.isBackward(); + const endOffset = isBackward ? focus.offset : anchor.offset; + const endNode = isBackward ? focus.getNode() : anchor.getNode(); + + if ( + $isRangeSelection(selection) && + selection.isCollapsed() && + selection.style !== '' + ) { + const css = selection.style; + const styleObject = getStyleObjectFromCSS(css); + + if (styleObject !== null && styleProperty in styleObject) { + return styleObject[styleProperty]; + } + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + // if no actual characters in the end node are selected, we don't + // include it in the selection for purposes of determining style + // value + if (i !== 0 && endOffset === 0 && node.is(endNode)) { + continue; + } + + if ($isTextNode(node)) { + const nodeStyleValue = $getNodeStyleValueForProperty( + node, + styleProperty, + defaultValue, + ); + + if (styleValue === null) { + styleValue = nodeStyleValue; + } else if (styleValue !== nodeStyleValue) { + // multiple text nodes are in the selection and they don't all + // have the same style. + styleValue = ''; + break; + } + } + } + + return styleValue === null ? defaultValue : styleValue; +} + +/** + * This function is for internal use of the library. + * Please do not use it as it may change in the future. + */ +export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode { + if ($isDecoratorNode(node)) { + return false; + } + if (!$isElementNode(node) || $isRootOrShadowRoot(node)) { + return false; + } + + const firstChild = node.getFirstChild(); + const isLeafElement = + firstChild === null || + $isLineBreakNode(firstChild) || + $isTextNode(firstChild) || + firstChild.isInline(); + + return !node.isInline() && node.canBeEmpty() !== false && isLeafElement; +} + +export function $getAncestor( + node: LexicalNode, + predicate: (ancestor: LexicalNode) => ancestor is NodeType, +) { + let parent = node; + while (parent !== null && parent.getParent() !== null && !predicate(parent)) { + parent = parent.getParentOrThrow(); + } + return predicate(parent) ? parent : null; +} diff --git a/resources/js/wysiwyg/lexical/selection/utils.ts b/resources/js/wysiwyg/lexical/selection/utils.ts new file mode 100644 index 000000000..0608706ea --- /dev/null +++ b/resources/js/wysiwyg/lexical/selection/utils.ts @@ -0,0 +1,228 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {LexicalEditor, LexicalNode} from 'lexical'; + +import {$isTextNode} from 'lexical'; + +import {CSS_TO_STYLES} from './constants'; + +function getDOMTextNode(element: Node | null): Text | null { + let node = element; + + while (node != null) { + if (node.nodeType === Node.TEXT_NODE) { + return node as Text; + } + + node = node.firstChild; + } + + return null; +} + +function getDOMIndexWithinParent(node: ChildNode): [ParentNode, number] { + const parent = node.parentNode; + + if (parent == null) { + throw new Error('Should never happen'); + } + + return [parent, Array.from(parent.childNodes).indexOf(node)]; +} + +/** + * Creates a selection range for the DOM. + * @param editor - The lexical editor. + * @param anchorNode - The anchor node of a selection. + * @param _anchorOffset - The amount of space offset from the anchor to the focus. + * @param focusNode - The current focus. + * @param _focusOffset - The amount of space offset from the focus to the anchor. + * @returns The range of selection for the DOM that was created. + */ +export function createDOMRange( + editor: LexicalEditor, + anchorNode: LexicalNode, + _anchorOffset: number, + focusNode: LexicalNode, + _focusOffset: number, +): Range | null { + const anchorKey = anchorNode.getKey(); + const focusKey = focusNode.getKey(); + const range = document.createRange(); + let anchorDOM: Node | Text | null = editor.getElementByKey(anchorKey); + let focusDOM: Node | Text | null = editor.getElementByKey(focusKey); + let anchorOffset = _anchorOffset; + let focusOffset = _focusOffset; + + if ($isTextNode(anchorNode)) { + anchorDOM = getDOMTextNode(anchorDOM); + } + + if ($isTextNode(focusNode)) { + focusDOM = getDOMTextNode(focusDOM); + } + + if ( + anchorNode === undefined || + focusNode === undefined || + anchorDOM === null || + focusDOM === null + ) { + return null; + } + + if (anchorDOM.nodeName === 'BR') { + [anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM as ChildNode); + } + + if (focusDOM.nodeName === 'BR') { + [focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM as ChildNode); + } + + const firstChild = anchorDOM.firstChild; + + if ( + anchorDOM === focusDOM && + firstChild != null && + firstChild.nodeName === 'BR' && + anchorOffset === 0 && + focusOffset === 0 + ) { + focusOffset = 1; + } + + try { + range.setStart(anchorDOM, anchorOffset); + range.setEnd(focusDOM, focusOffset); + } catch (e) { + return null; + } + + if ( + range.collapsed && + (anchorOffset !== focusOffset || anchorKey !== focusKey) + ) { + // Range is backwards, we need to reverse it + range.setStart(focusDOM, focusOffset); + range.setEnd(anchorDOM, anchorOffset); + } + + return range; +} + +/** + * Creates DOMRects, generally used to help the editor find a specific location on the screen. + * @param editor - The lexical editor + * @param range - A fragment of a document that can contain nodes and parts of text nodes. + * @returns The selectionRects as an array. + */ +export function createRectsFromDOMRange( + editor: LexicalEditor, + range: Range, +): Array { + const rootElement = editor.getRootElement(); + + if (rootElement === null) { + return []; + } + const rootRect = rootElement.getBoundingClientRect(); + const computedStyle = getComputedStyle(rootElement); + const rootPadding = + parseFloat(computedStyle.paddingLeft) + + parseFloat(computedStyle.paddingRight); + const selectionRects = Array.from(range.getClientRects()); + let selectionRectsLength = selectionRects.length; + //sort rects from top left to bottom right. + selectionRects.sort((a, b) => { + const top = a.top - b.top; + // Some rects match position closely, but not perfectly, + // so we give a 3px tolerance. + if (Math.abs(top) <= 3) { + return a.left - b.left; + } + return top; + }); + let prevRect; + for (let i = 0; i < selectionRectsLength; i++) { + const selectionRect = selectionRects[i]; + // Exclude rects that overlap preceding Rects in the sorted list. + const isOverlappingRect = + prevRect && + prevRect.top <= selectionRect.top && + prevRect.top + prevRect.height > selectionRect.top && + prevRect.left + prevRect.width > selectionRect.left; + // Exclude selections that span the entire element + const selectionSpansElement = + selectionRect.width + rootPadding === rootRect.width; + if (isOverlappingRect || selectionSpansElement) { + selectionRects.splice(i--, 1); + selectionRectsLength--; + continue; + } + prevRect = selectionRect; + } + return selectionRects; +} + +/** + * Creates an object containing all the styles and their values provided in the CSS string. + * @param css - The CSS string of styles and their values. + * @returns The styleObject containing all the styles and their values. + */ +export function getStyleObjectFromRawCSS(css: string): Record { + const styleObject: Record = {}; + const styles = css.split(';'); + + for (const style of styles) { + if (style !== '') { + const [key, value] = style.split(/:([^]+)/); // split on first colon + if (key && value) { + styleObject[key.trim()] = value.trim(); + } + } + } + + return styleObject; +} + +/** + * Given a CSS string, returns an object from the style cache. + * @param css - The CSS property as a string. + * @returns The value of the given CSS property. + */ +export function getStyleObjectFromCSS(css: string): Record { + let value = CSS_TO_STYLES.get(css); + if (value === undefined) { + value = getStyleObjectFromRawCSS(css); + CSS_TO_STYLES.set(css, value); + } + + if (__DEV__) { + // Freeze the value in DEV to prevent accidental mutations + Object.freeze(value); + } + + return value; +} + +/** + * Gets the CSS styles from the style object. + * @param styles - The style object containing the styles to get. + * @returns A string containing the CSS styles and their values. + */ +export function getCSSFromStyleObject(styles: Record): string { + let css = ''; + + for (const style in styles) { + if (style) { + css += `${style}: ${styles[style]};`; + } + } + + return css; +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts new file mode 100644 index 000000000..455d39bf6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -0,0 +1,374 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedElementNode, + Spread, +} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import { + $applyNodeReplacement, + $createParagraphNode, + $isElementNode, + $isLineBreakNode, + $isTextNode, + ElementNode, +} from 'lexical'; + +import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants'; + +export const TableCellHeaderStates = { + BOTH: 3, + COLUMN: 2, + NO_STATUS: 0, + ROW: 1, +}; + +export type TableCellHeaderState = + typeof TableCellHeaderStates[keyof typeof TableCellHeaderStates]; + +export type SerializedTableCellNode = Spread< + { + colSpan?: number; + rowSpan?: number; + headerState: TableCellHeaderState; + width?: number; + backgroundColor?: null | string; + }, + SerializedElementNode +>; + +/** @noInheritDoc */ +export class TableCellNode extends ElementNode { + /** @internal */ + __colSpan: number; + /** @internal */ + __rowSpan: number; + /** @internal */ + __headerState: TableCellHeaderState; + /** @internal */ + __width?: number; + /** @internal */ + __backgroundColor: null | string; + + static getType(): string { + return 'tablecell'; + } + + static clone(node: TableCellNode): TableCellNode { + const cellNode = new TableCellNode( + node.__headerState, + node.__colSpan, + node.__width, + node.__key, + ); + cellNode.__rowSpan = node.__rowSpan; + cellNode.__backgroundColor = node.__backgroundColor; + return cellNode; + } + + static importDOM(): DOMConversionMap | null { + return { + td: (node: Node) => ({ + conversion: $convertTableCellNodeElement, + priority: 0, + }), + th: (node: Node) => ({ + conversion: $convertTableCellNodeElement, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedTableCellNode): TableCellNode { + const colSpan = serializedNode.colSpan || 1; + const rowSpan = serializedNode.rowSpan || 1; + const cellNode = $createTableCellNode( + serializedNode.headerState, + colSpan, + serializedNode.width || undefined, + ); + cellNode.__rowSpan = rowSpan; + cellNode.__backgroundColor = serializedNode.backgroundColor || null; + return cellNode; + } + + constructor( + headerState = TableCellHeaderStates.NO_STATUS, + colSpan = 1, + width?: number, + key?: NodeKey, + ) { + super(key); + this.__colSpan = colSpan; + this.__rowSpan = 1; + this.__headerState = headerState; + this.__width = width; + this.__backgroundColor = null; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement( + this.getTag(), + ) as HTMLTableCellElement; + + if (this.__width) { + element.style.width = `${this.__width}px`; + } + if (this.__colSpan > 1) { + element.colSpan = this.__colSpan; + } + if (this.__rowSpan > 1) { + element.rowSpan = this.__rowSpan; + } + if (this.__backgroundColor !== null) { + element.style.backgroundColor = this.__backgroundColor; + } + + addClassNamesToElement( + element, + config.theme.tableCell, + this.hasHeader() && config.theme.tableCellHeader, + ); + + return element; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element) { + const element_ = element as HTMLTableCellElement; + element_.style.border = '1px solid black'; + if (this.__colSpan > 1) { + element_.colSpan = this.__colSpan; + } + if (this.__rowSpan > 1) { + element_.rowSpan = this.__rowSpan; + } + element_.style.width = `${this.getWidth() || COLUMN_WIDTH}px`; + + element_.style.verticalAlign = 'top'; + element_.style.textAlign = 'start'; + + const backgroundColor = this.getBackgroundColor(); + if (backgroundColor !== null) { + element_.style.backgroundColor = backgroundColor; + } else if (this.hasHeader()) { + element_.style.backgroundColor = '#f2f3f5'; + } + } + + return { + element, + }; + } + + exportJSON(): SerializedTableCellNode { + return { + ...super.exportJSON(), + backgroundColor: this.getBackgroundColor(), + colSpan: this.__colSpan, + headerState: this.__headerState, + rowSpan: this.__rowSpan, + type: 'tablecell', + width: this.getWidth(), + }; + } + + getColSpan(): number { + return this.__colSpan; + } + + setColSpan(colSpan: number): this { + this.getWritable().__colSpan = colSpan; + return this; + } + + getRowSpan(): number { + return this.__rowSpan; + } + + setRowSpan(rowSpan: number): this { + this.getWritable().__rowSpan = rowSpan; + return this; + } + + getTag(): string { + return this.hasHeader() ? 'th' : 'td'; + } + + setHeaderStyles(headerState: TableCellHeaderState): TableCellHeaderState { + const self = this.getWritable(); + self.__headerState = headerState; + return this.__headerState; + } + + getHeaderStyles(): TableCellHeaderState { + return this.getLatest().__headerState; + } + + setWidth(width: number): number | null | undefined { + const self = this.getWritable(); + self.__width = width; + return this.__width; + } + + getWidth(): number | undefined { + return this.getLatest().__width; + } + + getBackgroundColor(): null | string { + return this.getLatest().__backgroundColor; + } + + setBackgroundColor(newBackgroundColor: null | string): void { + this.getWritable().__backgroundColor = newBackgroundColor; + } + + toggleHeaderStyle(headerStateToToggle: TableCellHeaderState): TableCellNode { + const self = this.getWritable(); + + if ((self.__headerState & headerStateToToggle) === headerStateToToggle) { + self.__headerState -= headerStateToToggle; + } else { + self.__headerState += headerStateToToggle; + } + + return self; + } + + hasHeaderState(headerState: TableCellHeaderState): boolean { + return (this.getHeaderStyles() & headerState) === headerState; + } + + hasHeader(): boolean { + return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS; + } + + updateDOM(prevNode: TableCellNode): boolean { + return ( + prevNode.__headerState !== this.__headerState || + prevNode.__width !== this.__width || + prevNode.__colSpan !== this.__colSpan || + prevNode.__rowSpan !== this.__rowSpan || + prevNode.__backgroundColor !== this.__backgroundColor + ); + } + + isShadowRoot(): boolean { + return true; + } + + collapseAtStart(): true { + return true; + } + + canBeEmpty(): false { + return false; + } + + canIndent(): false { + return false; + } +} + +export function $convertTableCellNodeElement( + domNode: Node, +): DOMConversionOutput { + const domNode_ = domNode as HTMLTableCellElement; + const nodeName = domNode.nodeName.toLowerCase(); + + let width: number | undefined = undefined; + + if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) { + width = parseFloat(domNode_.style.width); + } + + const tableCellNode = $createTableCellNode( + nodeName === 'th' + ? TableCellHeaderStates.ROW + : TableCellHeaderStates.NO_STATUS, + domNode_.colSpan, + width, + ); + + tableCellNode.__rowSpan = domNode_.rowSpan; + const backgroundColor = domNode_.style.backgroundColor; + if (backgroundColor !== '') { + tableCellNode.__backgroundColor = backgroundColor; + } + + const style = domNode_.style; + const textDecoration = style.textDecoration.split(' '); + const hasBoldFontWeight = + style.fontWeight === '700' || style.fontWeight === 'bold'; + const hasLinethroughTextDecoration = textDecoration.includes('line-through'); + const hasItalicFontStyle = style.fontStyle === 'italic'; + const hasUnderlineTextDecoration = textDecoration.includes('underline'); + return { + after: (childLexicalNodes) => { + if (childLexicalNodes.length === 0) { + childLexicalNodes.push($createParagraphNode()); + } + return childLexicalNodes; + }, + forChild: (lexicalNode, parentLexicalNode) => { + if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) { + const paragraphNode = $createParagraphNode(); + if ( + $isLineBreakNode(lexicalNode) && + lexicalNode.getTextContent() === '\n' + ) { + return null; + } + if ($isTextNode(lexicalNode)) { + if (hasBoldFontWeight) { + lexicalNode.toggleFormat('bold'); + } + if (hasLinethroughTextDecoration) { + lexicalNode.toggleFormat('strikethrough'); + } + if (hasItalicFontStyle) { + lexicalNode.toggleFormat('italic'); + } + if (hasUnderlineTextDecoration) { + lexicalNode.toggleFormat('underline'); + } + } + paragraphNode.append(lexicalNode); + return paragraphNode; + } + + return lexicalNode; + }, + node: tableCellNode, + }; +} + +export function $createTableCellNode( + headerState: TableCellHeaderState, + colSpan = 1, + width?: number, +): TableCellNode { + return $applyNodeReplacement(new TableCellNode(headerState, colSpan, width)); +} + +export function $isTableCellNode( + node: LexicalNode | null | undefined, +): node is TableCellNode { + return node instanceof TableCellNode; +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts new file mode 100644 index 000000000..8fb542383 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCommands.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalCommand} from 'lexical'; + +import {createCommand} from 'lexical'; + +export type InsertTableCommandPayloadHeaders = + | Readonly<{ + rows: boolean; + columns: boolean; + }> + | boolean; + +export type InsertTableCommandPayload = Readonly<{ + columns: string; + rows: string; + includeHeaders?: InsertTableCommandPayloadHeaders; +}>; + +export const INSERT_TABLE_COMMAND: LexicalCommand = + createCommand('INSERT_TABLE_COMMAND'); diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts new file mode 100644 index 000000000..3e695eaa4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -0,0 +1,258 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {TableCellNode} from './LexicalTableCellNode'; +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedElementNode, +} from 'lexical'; + +import {addClassNamesToElement, isHTMLElement} from '@lexical/utils'; +import { + $applyNodeReplacement, + $getNearestNodeFromDOMNode, + ElementNode, +} from 'lexical'; + +import {$isTableCellNode} from './LexicalTableCellNode'; +import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; +import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; +import {getTable} from './LexicalTableSelectionHelpers'; + +export type SerializedTableNode = SerializedElementNode; + +/** @noInheritDoc */ +export class TableNode extends ElementNode { + static getType(): string { + return 'table'; + } + + static clone(node: TableNode): TableNode { + return new TableNode(node.__key); + } + + static importDOM(): DOMConversionMap | null { + return { + table: (_node: Node) => ({ + conversion: $convertTableElement, + priority: 1, + }), + }; + } + + static importJSON(_serializedNode: SerializedTableNode): TableNode { + return $createTableNode(); + } + + constructor(key?: NodeKey) { + super(key); + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'table', + version: 1, + }; + } + + createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement { + const tableElement = document.createElement('table'); + + addClassNamesToElement(tableElement, config.theme.table); + + return tableElement; + } + + updateDOM(): boolean { + return false; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + return { + ...super.exportDOM(editor), + after: (tableElement) => { + if (tableElement) { + const newElement = tableElement.cloneNode() as ParentNode; + const colGroup = document.createElement('colgroup'); + const tBody = document.createElement('tbody'); + if (isHTMLElement(tableElement)) { + tBody.append(...tableElement.children); + } + const firstRow = this.getFirstChildOrThrow(); + + if (!$isTableRowNode(firstRow)) { + throw new Error('Expected to find row node.'); + } + + const colCount = firstRow.getChildrenSize(); + + for (let i = 0; i < colCount; i++) { + const col = document.createElement('col'); + colGroup.append(col); + } + + newElement.replaceChildren(colGroup, tBody); + + return newElement as HTMLElement; + } + }, + }; + } + + canBeEmpty(): false { + return false; + } + + isShadowRoot(): boolean { + return true; + } + + getCordsFromCellNode( + tableCellNode: TableCellNode, + table: TableDOMTable, + ): {x: number; y: number} { + const {rows, domRows} = table; + + for (let y = 0; y < rows; y++) { + const row = domRows[y]; + + if (row == null) { + continue; + } + + const x = row.findIndex((cell) => { + if (!cell) { + return; + } + const {elem} = cell; + const cellNode = $getNearestNodeFromDOMNode(elem); + return cellNode === tableCellNode; + }); + + if (x !== -1) { + return {x, y}; + } + } + + throw new Error('Cell not found in table.'); + } + + getDOMCellFromCords( + x: number, + y: number, + table: TableDOMTable, + ): null | TableDOMCell { + const {domRows} = table; + + const row = domRows[y]; + + if (row == null) { + return null; + } + + const index = x < row.length ? x : row.length - 1; + + const cell = row[index]; + + if (cell == null) { + return null; + } + + return cell; + } + + getDOMCellFromCordsOrThrow( + x: number, + y: number, + table: TableDOMTable, + ): TableDOMCell { + const cell = this.getDOMCellFromCords(x, y, table); + + if (!cell) { + throw new Error('Cell not found at cords.'); + } + + return cell; + } + + getCellNodeFromCords( + x: number, + y: number, + table: TableDOMTable, + ): null | TableCellNode { + const cell = this.getDOMCellFromCords(x, y, table); + + if (cell == null) { + return null; + } + + const node = $getNearestNodeFromDOMNode(cell.elem); + + if ($isTableCellNode(node)) { + return node; + } + + return null; + } + + getCellNodeFromCordsOrThrow( + x: number, + y: number, + table: TableDOMTable, + ): TableCellNode { + const node = this.getCellNodeFromCords(x, y, table); + + if (!node) { + throw new Error('Node at cords not TableCellNode.'); + } + + return node; + } + + canSelectBefore(): true { + return true; + } + + canIndent(): false { + return false; + } +} + +export function $getElementForTableNode( + editor: LexicalEditor, + tableNode: TableNode, +): TableDOMTable { + const tableElement = editor.getElementByKey(tableNode.getKey()); + + if (tableElement == null) { + throw new Error('Table Element Not Found'); + } + + return getTable(tableElement); +} + +export function $convertTableElement(_domNode: Node): DOMConversionOutput { + return {node: $createTableNode()}; +} + +export function $createTableNode(): TableNode { + return $applyNodeReplacement(new TableNode()); +} + +export function $isTableNode( + node: LexicalNode | null | undefined, +): node is TableNode { + return node instanceof TableNode; +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts b/resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts new file mode 100644 index 000000000..0d40d0699 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableObserver.ts @@ -0,0 +1,414 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor, NodeKey, TextFormatType} from 'lexical'; + +import { + addClassNamesToElement, + removeClassNamesFromElement, +} from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $setSelection, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {$isTableCellNode} from './LexicalTableCellNode'; +import {$isTableNode} from './LexicalTableNode'; +import { + $createTableSelection, + $isTableSelection, + type TableSelection, +} from './LexicalTableSelection'; +import { + $findTableNode, + $updateDOMForSelection, + getDOMSelection, + getTable, +} from './LexicalTableSelectionHelpers'; + +export type TableDOMCell = { + elem: HTMLElement; + highlighted: boolean; + hasBackgroundColor: boolean; + x: number; + y: number; +}; + +export type TableDOMRows = Array | undefined>; + +export type TableDOMTable = { + domRows: TableDOMRows; + columns: number; + rows: number; +}; + +export class TableObserver { + focusX: number; + focusY: number; + listenersToRemove: Set<() => void>; + table: TableDOMTable; + isHighlightingCells: boolean; + anchorX: number; + anchorY: number; + tableNodeKey: NodeKey; + anchorCell: TableDOMCell | null; + focusCell: TableDOMCell | null; + anchorCellNodeKey: NodeKey | null; + focusCellNodeKey: NodeKey | null; + editor: LexicalEditor; + tableSelection: TableSelection | null; + hasHijackedSelectionStyles: boolean; + isSelecting: boolean; + + constructor(editor: LexicalEditor, tableNodeKey: string) { + this.isHighlightingCells = false; + this.anchorX = -1; + this.anchorY = -1; + this.focusX = -1; + this.focusY = -1; + this.listenersToRemove = new Set(); + this.tableNodeKey = tableNodeKey; + this.editor = editor; + this.table = { + columns: 0, + domRows: [], + rows: 0, + }; + this.tableSelection = null; + this.anchorCellNodeKey = null; + this.focusCellNodeKey = null; + this.anchorCell = null; + this.focusCell = null; + this.hasHijackedSelectionStyles = false; + this.trackTable(); + this.isSelecting = false; + } + + getTable(): TableDOMTable { + return this.table; + } + + removeListeners() { + Array.from(this.listenersToRemove).forEach((removeListener) => + removeListener(), + ); + } + + trackTable() { + const observer = new MutationObserver((records) => { + this.editor.update(() => { + let gridNeedsRedraw = false; + + for (let i = 0; i < records.length; i++) { + const record = records[i]; + const target = record.target; + const nodeName = target.nodeName; + + if ( + nodeName === 'TABLE' || + nodeName === 'TBODY' || + nodeName === 'THEAD' || + nodeName === 'TR' + ) { + gridNeedsRedraw = true; + break; + } + } + + if (!gridNeedsRedraw) { + return; + } + + const tableElement = this.editor.getElementByKey(this.tableNodeKey); + + if (!tableElement) { + throw new Error('Expected to find TableElement in DOM'); + } + + this.table = getTable(tableElement); + }); + }); + this.editor.update(() => { + const tableElement = this.editor.getElementByKey(this.tableNodeKey); + + if (!tableElement) { + throw new Error('Expected to find TableElement in DOM'); + } + + this.table = getTable(tableElement); + observer.observe(tableElement, { + attributes: true, + childList: true, + subtree: true, + }); + }); + } + + clearHighlight() { + const editor = this.editor; + this.isHighlightingCells = false; + this.anchorX = -1; + this.anchorY = -1; + this.focusX = -1; + this.focusY = -1; + this.tableSelection = null; + this.anchorCellNodeKey = null; + this.focusCellNodeKey = null; + this.anchorCell = null; + this.focusCell = null; + this.hasHijackedSelectionStyles = false; + + this.enableHighlightStyle(); + + editor.update(() => { + const tableNode = $getNodeByKey(this.tableNodeKey); + + if (!$isTableNode(tableNode)) { + throw new Error('Expected TableNode.'); + } + + const tableElement = editor.getElementByKey(this.tableNodeKey); + + if (!tableElement) { + throw new Error('Expected to find TableElement in DOM'); + } + + const grid = getTable(tableElement); + $updateDOMForSelection(editor, grid, null); + $setSelection(null); + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + }); + } + + enableHighlightStyle() { + const editor = this.editor; + editor.update(() => { + const tableElement = editor.getElementByKey(this.tableNodeKey); + + if (!tableElement) { + throw new Error('Expected to find TableElement in DOM'); + } + + removeClassNamesFromElement( + tableElement, + editor._config.theme.tableSelection, + ); + tableElement.classList.remove('disable-selection'); + this.hasHijackedSelectionStyles = false; + }); + } + + disableHighlightStyle() { + const editor = this.editor; + editor.update(() => { + const tableElement = editor.getElementByKey(this.tableNodeKey); + + if (!tableElement) { + throw new Error('Expected to find TableElement in DOM'); + } + + addClassNamesToElement(tableElement, editor._config.theme.tableSelection); + this.hasHijackedSelectionStyles = true; + }); + } + + updateTableTableSelection(selection: TableSelection | null): void { + if (selection !== null && selection.tableKey === this.tableNodeKey) { + const editor = this.editor; + this.tableSelection = selection; + this.isHighlightingCells = true; + this.disableHighlightStyle(); + $updateDOMForSelection(editor, this.table, this.tableSelection); + } else if (selection == null) { + this.clearHighlight(); + } else { + this.tableNodeKey = selection.tableKey; + this.updateTableTableSelection(selection); + } + } + + setFocusCellForSelection(cell: TableDOMCell, ignoreStart = false) { + const editor = this.editor; + editor.update(() => { + const tableNode = $getNodeByKey(this.tableNodeKey); + + if (!$isTableNode(tableNode)) { + throw new Error('Expected TableNode.'); + } + + const tableElement = editor.getElementByKey(this.tableNodeKey); + + if (!tableElement) { + throw new Error('Expected to find TableElement in DOM'); + } + + const cellX = cell.x; + const cellY = cell.y; + this.focusCell = cell; + + if (this.anchorCell !== null) { + const domSelection = getDOMSelection(editor._window); + // Collapse the selection + if (domSelection) { + domSelection.setBaseAndExtent( + this.anchorCell.elem, + 0, + this.focusCell.elem, + 0, + ); + } + } + + if ( + !this.isHighlightingCells && + (this.anchorX !== cellX || this.anchorY !== cellY || ignoreStart) + ) { + this.isHighlightingCells = true; + this.disableHighlightStyle(); + } else if (cellX === this.focusX && cellY === this.focusY) { + return; + } + + this.focusX = cellX; + this.focusY = cellY; + + if (this.isHighlightingCells) { + const focusTableCellNode = $getNearestNodeFromDOMNode(cell.elem); + + if ( + this.tableSelection != null && + this.anchorCellNodeKey != null && + $isTableCellNode(focusTableCellNode) && + tableNode.is($findTableNode(focusTableCellNode)) + ) { + const focusNodeKey = focusTableCellNode.getKey(); + + this.tableSelection = + this.tableSelection.clone() || $createTableSelection(); + + this.focusCellNodeKey = focusNodeKey; + this.tableSelection.set( + this.tableNodeKey, + this.anchorCellNodeKey, + this.focusCellNodeKey, + ); + + $setSelection(this.tableSelection); + + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + + $updateDOMForSelection(editor, this.table, this.tableSelection); + } + } + }); + } + + setAnchorCellForSelection(cell: TableDOMCell) { + this.isHighlightingCells = false; + this.anchorCell = cell; + this.anchorX = cell.x; + this.anchorY = cell.y; + + this.editor.update(() => { + const anchorTableCellNode = $getNearestNodeFromDOMNode(cell.elem); + + if ($isTableCellNode(anchorTableCellNode)) { + const anchorNodeKey = anchorTableCellNode.getKey(); + this.tableSelection = + this.tableSelection != null + ? this.tableSelection.clone() + : $createTableSelection(); + this.anchorCellNodeKey = anchorNodeKey; + } + }); + } + + formatCells(type: TextFormatType) { + this.editor.update(() => { + const selection = $getSelection(); + + if (!$isTableSelection(selection)) { + invariant(false, 'Expected grid selection'); + } + + const formatSelection = $createRangeSelection(); + + const anchor = formatSelection.anchor; + const focus = formatSelection.focus; + + selection.getNodes().forEach((cellNode) => { + if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) { + anchor.set(cellNode.getKey(), 0, 'element'); + focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); + formatSelection.formatText(type); + } + }); + + $setSelection(selection); + + this.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + }); + } + + clearText() { + const editor = this.editor; + editor.update(() => { + const tableNode = $getNodeByKey(this.tableNodeKey); + + if (!$isTableNode(tableNode)) { + throw new Error('Expected TableNode.'); + } + + const selection = $getSelection(); + + if (!$isTableSelection(selection)) { + invariant(false, 'Expected grid selection'); + } + + const selectedNodes = selection.getNodes().filter($isTableCellNode); + + if (selectedNodes.length === this.table.columns * this.table.rows) { + tableNode.selectPrevious(); + // Delete entire table + tableNode.remove(); + const rootNode = $getRoot(); + rootNode.selectStart(); + return; + } + + selectedNodes.forEach((cellNode) => { + if ($isElementNode(cellNode)) { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(); + paragraphNode.append(textNode); + cellNode.append(paragraphNode); + cellNode.getChildren().forEach((child) => { + if (child !== paragraphNode) { + child.remove(); + } + }); + } + }); + + $updateDOMForSelection(editor, this.table, null); + + $setSelection(null); + + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + }); + } +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts new file mode 100644 index 000000000..eddea69a2 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Spread} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import { + $applyNodeReplacement, + DOMConversionMap, + DOMConversionOutput, + EditorConfig, + ElementNode, + LexicalNode, + NodeKey, + SerializedElementNode, +} from 'lexical'; + +import {PIXEL_VALUE_REG_EXP} from './constants'; + +export type SerializedTableRowNode = Spread< + { + height?: number; + }, + SerializedElementNode +>; + +/** @noInheritDoc */ +export class TableRowNode extends ElementNode { + /** @internal */ + __height?: number; + + static getType(): string { + return 'tablerow'; + } + + static clone(node: TableRowNode): TableRowNode { + return new TableRowNode(node.__height, node.__key); + } + + static importDOM(): DOMConversionMap | null { + return { + tr: (node: Node) => ({ + conversion: $convertTableRowElement, + priority: 0, + }), + }; + } + + static importJSON(serializedNode: SerializedTableRowNode): TableRowNode { + return $createTableRowNode(serializedNode.height); + } + + constructor(height?: number, key?: NodeKey) { + super(key); + this.__height = height; + } + + exportJSON(): SerializedTableRowNode { + return { + ...super.exportJSON(), + ...(this.getHeight() && {height: this.getHeight()}), + type: 'tablerow', + version: 1, + }; + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('tr'); + + if (this.__height) { + element.style.height = `${this.__height}px`; + } + + addClassNamesToElement(element, config.theme.tableRow); + + return element; + } + + isShadowRoot(): boolean { + return true; + } + + setHeight(height: number): number | null | undefined { + const self = this.getWritable(); + self.__height = height; + return this.__height; + } + + getHeight(): number | undefined { + return this.getLatest().__height; + } + + updateDOM(prevNode: TableRowNode): boolean { + return prevNode.__height !== this.__height; + } + + canBeEmpty(): false { + return false; + } + + canIndent(): false { + return false; + } +} + +export function $convertTableRowElement(domNode: Node): DOMConversionOutput { + const domNode_ = domNode as HTMLTableCellElement; + let height: number | undefined = undefined; + + if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) { + height = parseFloat(domNode_.style.height); + } + + return {node: $createTableRowNode(height)}; +} + +export function $createTableRowNode(height?: number): TableRowNode { + return $applyNodeReplacement(new TableRowNode(height)); +} + +export function $isTableRowNode( + node: LexicalNode | null | undefined, +): node is TableRowNode { + return node instanceof TableRowNode; +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts new file mode 100644 index 000000000..4564ace7f --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelection.ts @@ -0,0 +1,373 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$findMatchingParent} from '@lexical/utils'; +import { + $createPoint, + $getNodeByKey, + $isElementNode, + $normalizeSelection__EXPERIMENTAL, + BaseSelection, + isCurrentlyReadOnlyMode, + LexicalNode, + NodeKey, + PointType, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; +import {$isTableNode} from './LexicalTableNode'; +import {$isTableRowNode} from './LexicalTableRowNode'; +import {$computeTableMap, $getTableCellNodeRect} from './LexicalTableUtils'; + +export type TableSelectionShape = { + fromX: number; + fromY: number; + toX: number; + toY: number; +}; + +export type TableMapValueType = { + cell: TableCellNode; + startRow: number; + startColumn: number; +}; +export type TableMapType = Array>; + +export class TableSelection implements BaseSelection { + tableKey: NodeKey; + anchor: PointType; + focus: PointType; + _cachedNodes: Array | null; + dirty: boolean; + + constructor(tableKey: NodeKey, anchor: PointType, focus: PointType) { + this.anchor = anchor; + this.focus = focus; + anchor._selection = this; + focus._selection = this; + this._cachedNodes = null; + this.dirty = false; + this.tableKey = tableKey; + } + + getStartEndPoints(): [PointType, PointType] { + return [this.anchor, this.focus]; + } + + /** + * Returns whether the Selection is "backwards", meaning the focus + * logically precedes the anchor in the EditorState. + * @returns true if the Selection is backwards, false otherwise. + */ + isBackward(): boolean { + return this.focus.isBefore(this.anchor); + } + + getCachedNodes(): LexicalNode[] | null { + return this._cachedNodes; + } + + setCachedNodes(nodes: LexicalNode[] | null): void { + this._cachedNodes = nodes; + } + + is(selection: null | BaseSelection): boolean { + if (!$isTableSelection(selection)) { + return false; + } + return ( + this.tableKey === selection.tableKey && + this.anchor.is(selection.anchor) && + this.focus.is(selection.focus) + ); + } + + set(tableKey: NodeKey, anchorCellKey: NodeKey, focusCellKey: NodeKey): void { + this.dirty = true; + this.tableKey = tableKey; + this.anchor.key = anchorCellKey; + this.focus.key = focusCellKey; + this._cachedNodes = null; + } + + clone(): TableSelection { + return new TableSelection(this.tableKey, this.anchor, this.focus); + } + + isCollapsed(): boolean { + return false; + } + + extract(): Array { + return this.getNodes(); + } + + insertRawText(text: string): void { + // Do nothing? + } + + insertText(): void { + // Do nothing? + } + + insertNodes(nodes: Array) { + const focusNode = this.focus.getNode(); + invariant( + $isElementNode(focusNode), + 'Expected TableSelection focus to be an ElementNode', + ); + const selection = $normalizeSelection__EXPERIMENTAL( + focusNode.select(0, focusNode.getChildrenSize()), + ); + selection.insertNodes(nodes); + } + + // TODO Deprecate this method. It's confusing when used with colspan|rowspan + getShape(): TableSelectionShape { + const anchorCellNode = $getNodeByKey(this.anchor.key); + invariant( + $isTableCellNode(anchorCellNode), + 'Expected TableSelection anchor to be (or a child of) TableCellNode', + ); + const anchorCellNodeRect = $getTableCellNodeRect(anchorCellNode); + invariant( + anchorCellNodeRect !== null, + 'getCellRect: expected to find AnchorNode', + ); + + const focusCellNode = $getNodeByKey(this.focus.key); + invariant( + $isTableCellNode(focusCellNode), + 'Expected TableSelection focus to be (or a child of) TableCellNode', + ); + const focusCellNodeRect = $getTableCellNodeRect(focusCellNode); + invariant( + focusCellNodeRect !== null, + 'getCellRect: expected to find focusCellNode', + ); + + const startX = Math.min( + anchorCellNodeRect.columnIndex, + focusCellNodeRect.columnIndex, + ); + const stopX = Math.max( + anchorCellNodeRect.columnIndex, + focusCellNodeRect.columnIndex, + ); + + const startY = Math.min( + anchorCellNodeRect.rowIndex, + focusCellNodeRect.rowIndex, + ); + const stopY = Math.max( + anchorCellNodeRect.rowIndex, + focusCellNodeRect.rowIndex, + ); + + return { + fromX: Math.min(startX, stopX), + fromY: Math.min(startY, stopY), + toX: Math.max(startX, stopX), + toY: Math.max(startY, stopY), + }; + } + + getNodes(): Array { + const cachedNodes = this._cachedNodes; + if (cachedNodes !== null) { + return cachedNodes; + } + + const anchorNode = this.anchor.getNode(); + const focusNode = this.focus.getNode(); + const anchorCell = $findMatchingParent(anchorNode, $isTableCellNode); + // todo replace with triplet + const focusCell = $findMatchingParent(focusNode, $isTableCellNode); + invariant( + $isTableCellNode(anchorCell), + 'Expected TableSelection anchor to be (or a child of) TableCellNode', + ); + invariant( + $isTableCellNode(focusCell), + 'Expected TableSelection focus to be (or a child of) TableCellNode', + ); + const anchorRow = anchorCell.getParent(); + invariant( + $isTableRowNode(anchorRow), + 'Expected anchorCell to have a parent TableRowNode', + ); + const tableNode = anchorRow.getParent(); + invariant( + $isTableNode(tableNode), + 'Expected tableNode to have a parent TableNode', + ); + + const focusCellGrid = focusCell.getParents()[1]; + if (focusCellGrid !== tableNode) { + if (!tableNode.isParentOf(focusCell)) { + // focus is on higher Grid level than anchor + const gridParent = tableNode.getParent(); + invariant(gridParent != null, 'Expected gridParent to have a parent'); + this.set(this.tableKey, gridParent.getKey(), focusCell.getKey()); + } else { + // anchor is on higher Grid level than focus + const focusCellParent = focusCellGrid.getParent(); + invariant( + focusCellParent != null, + 'Expected focusCellParent to have a parent', + ); + this.set(this.tableKey, focusCell.getKey(), focusCellParent.getKey()); + } + return this.getNodes(); + } + + // TODO Mapping the whole Grid every time not efficient. We need to compute the entire state only + // once (on load) and iterate on it as updates occur. However, to do this we need to have the + // ability to store a state. Killing TableSelection and moving the logic to the plugin would make + // this possible. + const [map, cellAMap, cellBMap] = $computeTableMap( + tableNode, + anchorCell, + focusCell, + ); + + let minColumn = Math.min(cellAMap.startColumn, cellBMap.startColumn); + let minRow = Math.min(cellAMap.startRow, cellBMap.startRow); + let maxColumn = Math.max( + cellAMap.startColumn + cellAMap.cell.__colSpan - 1, + cellBMap.startColumn + cellBMap.cell.__colSpan - 1, + ); + let maxRow = Math.max( + cellAMap.startRow + cellAMap.cell.__rowSpan - 1, + cellBMap.startRow + cellBMap.cell.__rowSpan - 1, + ); + let exploredMinColumn = minColumn; + let exploredMinRow = minRow; + let exploredMaxColumn = minColumn; + let exploredMaxRow = minRow; + function expandBoundary(mapValue: TableMapValueType): void { + const { + cell, + startColumn: cellStartColumn, + startRow: cellStartRow, + } = mapValue; + minColumn = Math.min(minColumn, cellStartColumn); + minRow = Math.min(minRow, cellStartRow); + maxColumn = Math.max(maxColumn, cellStartColumn + cell.__colSpan - 1); + maxRow = Math.max(maxRow, cellStartRow + cell.__rowSpan - 1); + } + while ( + minColumn < exploredMinColumn || + minRow < exploredMinRow || + maxColumn > exploredMaxColumn || + maxRow > exploredMaxRow + ) { + if (minColumn < exploredMinColumn) { + // Expand on the left + const rowDiff = exploredMaxRow - exploredMinRow; + const previousColumn = exploredMinColumn - 1; + for (let i = 0; i <= rowDiff; i++) { + expandBoundary(map[exploredMinRow + i][previousColumn]); + } + exploredMinColumn = previousColumn; + } + if (minRow < exploredMinRow) { + // Expand on top + const columnDiff = exploredMaxColumn - exploredMinColumn; + const previousRow = exploredMinRow - 1; + for (let i = 0; i <= columnDiff; i++) { + expandBoundary(map[previousRow][exploredMinColumn + i]); + } + exploredMinRow = previousRow; + } + if (maxColumn > exploredMaxColumn) { + // Expand on the right + const rowDiff = exploredMaxRow - exploredMinRow; + const nextColumn = exploredMaxColumn + 1; + for (let i = 0; i <= rowDiff; i++) { + expandBoundary(map[exploredMinRow + i][nextColumn]); + } + exploredMaxColumn = nextColumn; + } + if (maxRow > exploredMaxRow) { + // Expand on the bottom + const columnDiff = exploredMaxColumn - exploredMinColumn; + const nextRow = exploredMaxRow + 1; + for (let i = 0; i <= columnDiff; i++) { + expandBoundary(map[nextRow][exploredMinColumn + i]); + } + exploredMaxRow = nextRow; + } + } + + const nodes: Array = [tableNode]; + let lastRow = null; + for (let i = minRow; i <= maxRow; i++) { + for (let j = minColumn; j <= maxColumn; j++) { + const {cell} = map[i][j]; + const currentRow = cell.getParent(); + invariant( + $isTableRowNode(currentRow), + 'Expected TableCellNode parent to be a TableRowNode', + ); + if (currentRow !== lastRow) { + nodes.push(currentRow); + } + nodes.push(cell, ...$getChildrenRecursively(cell)); + lastRow = currentRow; + } + } + + if (!isCurrentlyReadOnlyMode()) { + this._cachedNodes = nodes; + } + return nodes; + } + + getTextContent(): string { + const nodes = this.getNodes().filter((node) => $isTableCellNode(node)); + let textContent = ''; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const row = node.__parent; + const nextRow = (nodes[i + 1] || {}).__parent; + textContent += node.getTextContent() + (nextRow !== row ? '\n' : '\t'); + } + return textContent; + } +} + +export function $isTableSelection(x: unknown): x is TableSelection { + return x instanceof TableSelection; +} + +export function $createTableSelection(): TableSelection { + const anchor = $createPoint('root', 0, 'element'); + const focus = $createPoint('root', 0, 'element'); + return new TableSelection('root', anchor, focus); +} + +export function $getChildrenRecursively(node: LexicalNode): Array { + const nodes = []; + const stack = [node]; + while (stack.length > 0) { + const currentNode = stack.pop(); + invariant( + currentNode !== undefined, + "Stack.length > 0; can't be undefined", + ); + if ($isElementNode(currentNode)) { + stack.unshift(...currentNode.getChildren()); + } + if (currentNode !== node) { + nodes.push(currentNode); + } + } + return nodes; +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts new file mode 100644 index 000000000..812cccc0d --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -0,0 +1,1819 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {TableCellNode} from './LexicalTableCellNode'; +import type {TableNode} from './LexicalTableNode'; +import type {TableDOMCell, TableDOMRows} from './LexicalTableObserver'; +import type { + TableMapType, + TableMapValueType, + TableSelection, +} from './LexicalTableSelection'; +import type { + BaseSelection, + ElementFormatType, + LexicalCommand, + LexicalEditor, + LexicalNode, + RangeSelection, + TextFormatType, +} from 'lexical'; + +import { + $getClipboardDataFromSelection, + copyToClipboard, +} from '@lexical/clipboard'; +import {$findMatchingParent, objectKlassEquals} from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelectionFromDom, + $createTextNode, + $getNearestNodeFromDOMNode, + $getPreviousSelection, + $getSelection, + $isDecoratorNode, + $isElementNode, + $isRangeSelection, + $isRootOrShadowRoot, + $isTextNode, + $setSelection, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_HIGH, + CONTROLLED_TEXT_INSERTION_COMMAND, + CUT_COMMAND, + DELETE_CHARACTER_COMMAND, + DELETE_LINE_COMMAND, + DELETE_WORD_COMMAND, + FOCUS_COMMAND, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + INSERT_PARAGRAPH_COMMAND, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_TAB_COMMAND, + SELECTION_CHANGE_COMMAND, + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, +} from 'lexical'; +import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; +import invariant from 'lexical/shared/invariant'; + +import {$isTableCellNode} from './LexicalTableCellNode'; +import {$isTableNode} from './LexicalTableNode'; +import {TableDOMTable, TableObserver} from './LexicalTableObserver'; +import {$isTableRowNode} from './LexicalTableRowNode'; +import {$isTableSelection} from './LexicalTableSelection'; +import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils'; + +const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection'; + +export const getDOMSelection = ( + targetWindow: Window | null, +): Selection | null => + CAN_USE_DOM ? (targetWindow || window).getSelection() : null; + +const isMouseDownOnEvent = (event: MouseEvent) => { + return (event.buttons & 1) === 1; +}; + +export function applyTableHandlers( + tableNode: TableNode, + tableElement: HTMLTableElementWithWithTableSelectionState, + editor: LexicalEditor, + hasTabHandler: boolean, +): TableObserver { + const rootElement = editor.getRootElement(); + + if (rootElement === null) { + throw new Error('No root element.'); + } + + const tableObserver = new TableObserver(editor, tableNode.getKey()); + const editorWindow = editor._window || window; + + attachTableObserverToTableElement(tableElement, tableObserver); + + const createMouseHandlers = () => { + const onMouseUp = () => { + tableObserver.isSelecting = false; + editorWindow.removeEventListener('mouseup', onMouseUp); + editorWindow.removeEventListener('mousemove', onMouseMove); + }; + + const onMouseMove = (moveEvent: MouseEvent) => { + // delaying mousemove handler to allow selectionchange handler from LexicalEvents.ts to be executed first + setTimeout(() => { + if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) { + tableObserver.isSelecting = false; + editorWindow.removeEventListener('mouseup', onMouseUp); + editorWindow.removeEventListener('mousemove', onMouseMove); + return; + } + const focusCell = getDOMCellFromTarget(moveEvent.target as Node); + if ( + focusCell !== null && + (tableObserver.anchorX !== focusCell.x || + tableObserver.anchorY !== focusCell.y) + ) { + moveEvent.preventDefault(); + tableObserver.setFocusCellForSelection(focusCell); + } + }, 0); + }; + return {onMouseMove: onMouseMove, onMouseUp: onMouseUp}; + }; + + tableElement.addEventListener('mousedown', (event: MouseEvent) => { + setTimeout(() => { + if (event.button !== 0) { + return; + } + + if (!editorWindow) { + return; + } + + const anchorCell = getDOMCellFromTarget(event.target as Node); + if (anchorCell !== null) { + stopEvent(event); + tableObserver.setAnchorCellForSelection(anchorCell); + } + + const {onMouseUp, onMouseMove} = createMouseHandlers(); + tableObserver.isSelecting = true; + editorWindow.addEventListener('mouseup', onMouseUp); + editorWindow.addEventListener('mousemove', onMouseMove); + }, 0); + }); + + // Clear selection when clicking outside of dom. + const mouseDownCallback = (event: MouseEvent) => { + if (event.button !== 0) { + return; + } + + editor.update(() => { + const selection = $getSelection(); + const target = event.target as Node; + if ( + $isTableSelection(selection) && + selection.tableKey === tableObserver.tableNodeKey && + rootElement.contains(target) + ) { + tableObserver.clearHighlight(); + } + }); + }; + + editorWindow.addEventListener('mousedown', mouseDownCallback); + + tableObserver.listenersToRemove.add(() => + editorWindow.removeEventListener('mousedown', mouseDownCallback), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (event) => + $handleArrowKey(editor, event, 'down', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (event) => $handleArrowKey(editor, event, 'up', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + (event) => + $handleArrowKey(editor, event, 'backward', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (event) => + $handleArrowKey(editor, event, 'forward', tableNode, tableObserver), + COMMAND_PRIORITY_HIGH, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event) => { + const selection = $getSelection(); + if ($isTableSelection(selection)) { + const focusCellNode = $findMatchingParent( + selection.focus.getNode(), + $isTableCellNode, + ); + if ($isTableCellNode(focusCellNode)) { + stopEvent(event); + focusCellNode.selectEnd(); + return true; + } + } + + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ); + + const deleteTextHandler = (command: LexicalCommand) => () => { + const selection = $getSelection(); + + if (!$isSelectionInTable(selection, tableNode)) { + return false; + } + + if ($isTableSelection(selection)) { + tableObserver.clearText(); + + return true; + } else if ($isRangeSelection(selection)) { + const tableCellNode = $findMatchingParent( + selection.anchor.getNode(), + (n) => $isTableCellNode(n), + ); + + if (!$isTableCellNode(tableCellNode)) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + const isAnchorInside = tableNode.isParentOf(anchorNode); + const isFocusInside = tableNode.isParentOf(focusNode); + + const selectionContainsPartialTable = + (isAnchorInside && !isFocusInside) || + (isFocusInside && !isAnchorInside); + + if (selectionContainsPartialTable) { + tableObserver.clearText(); + return true; + } + + const nearestElementNode = $findMatchingParent( + selection.anchor.getNode(), + (n) => $isElementNode(n), + ); + + const topLevelCellElementNode = + nearestElementNode && + $findMatchingParent( + nearestElementNode, + (n) => $isElementNode(n) && $isTableCellNode(n.getParent()), + ); + + if ( + !$isElementNode(topLevelCellElementNode) || + !$isElementNode(nearestElementNode) + ) { + return false; + } + + if ( + command === DELETE_LINE_COMMAND && + topLevelCellElementNode.getPreviousSibling() === null + ) { + // TODO: Fix Delete Line in Table Cells. + return true; + } + } + + return false; + }; + + [DELETE_WORD_COMMAND, DELETE_LINE_COMMAND, DELETE_CHARACTER_COMMAND].forEach( + (command) => { + tableObserver.listenersToRemove.add( + editor.registerCommand( + command, + deleteTextHandler(command), + COMMAND_PRIORITY_CRITICAL, + ), + ); + }, + ); + + const $deleteCellHandler = ( + event: KeyboardEvent | ClipboardEvent | null, + ): boolean => { + const selection = $getSelection(); + + if (!$isSelectionInTable(selection, tableNode)) { + const nodes = selection ? selection.getNodes() : null; + if (nodes) { + const table = nodes.find( + (node) => + $isTableNode(node) && node.getKey() === tableObserver.tableNodeKey, + ); + if ($isTableNode(table)) { + const parentNode = table.getParent(); + if (!parentNode) { + return false; + } + table.remove(); + } + } + return false; + } + + if ($isTableSelection(selection)) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + tableObserver.clearText(); + + return true; + } else if ($isRangeSelection(selection)) { + const tableCellNode = $findMatchingParent( + selection.anchor.getNode(), + (n) => $isTableCellNode(n), + ); + + if (!$isTableCellNode(tableCellNode)) { + return false; + } + } + + return false; + }; + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + $deleteCellHandler, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_DELETE_COMMAND, + $deleteCellHandler, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + CUT_COMMAND, + (event) => { + const selection = $getSelection(); + if (selection) { + if (!($isTableSelection(selection) || $isRangeSelection(selection))) { + return false; + } + // Copying to the clipboard is async so we must capture the data + // before we delete it + void copyToClipboard( + editor, + objectKlassEquals(event, ClipboardEvent) + ? (event as ClipboardEvent) + : null, + $getClipboardDataFromSelection(selection), + ); + const intercepted = $deleteCellHandler(event); + if ($isRangeSelection(selection)) { + selection.removeText(); + } + return intercepted; + } + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + FORMAT_TEXT_COMMAND, + (payload) => { + const selection = $getSelection(); + + if (!$isSelectionInTable(selection, tableNode)) { + return false; + } + + if ($isTableSelection(selection)) { + tableObserver.formatCells(payload); + + return true; + } else if ($isRangeSelection(selection)) { + const tableCellNode = $findMatchingParent( + selection.anchor.getNode(), + (n) => $isTableCellNode(n), + ); + + if (!$isTableCellNode(tableCellNode)) { + return false; + } + } + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + FORMAT_ELEMENT_COMMAND, + (formatType) => { + const selection = $getSelection(); + if ( + !$isTableSelection(selection) || + !$isSelectionInTable(selection, tableNode) + ) { + return false; + } + + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) { + return false; + } + + const [tableMap, anchorCell, focusCell] = $computeTableMap( + tableNode, + anchorNode, + focusNode, + ); + const maxRow = Math.max(anchorCell.startRow, focusCell.startRow); + const maxColumn = Math.max( + anchorCell.startColumn, + focusCell.startColumn, + ); + const minRow = Math.min(anchorCell.startRow, focusCell.startRow); + const minColumn = Math.min( + anchorCell.startColumn, + focusCell.startColumn, + ); + for (let i = minRow; i <= maxRow; i++) { + for (let j = minColumn; j <= maxColumn; j++) { + const cell = tableMap[i][j].cell; + cell.setFormat(formatType); + + const cellChildren = cell.getChildren(); + for (let k = 0; k < cellChildren.length; k++) { + const child = cellChildren[k]; + if ($isElementNode(child) && !child.isInline()) { + child.setFormat(formatType); + } + } + } + } + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + CONTROLLED_TEXT_INSERTION_COMMAND, + (payload) => { + const selection = $getSelection(); + + if (!$isSelectionInTable(selection, tableNode)) { + return false; + } + + if ($isTableSelection(selection)) { + tableObserver.clearHighlight(); + + return false; + } else if ($isRangeSelection(selection)) { + const tableCellNode = $findMatchingParent( + selection.anchor.getNode(), + (n) => $isTableCellNode(n), + ); + + if (!$isTableCellNode(tableCellNode)) { + return false; + } + + if (typeof payload === 'string') { + const edgePosition = $getTableEdgeCursorPosition( + editor, + selection, + tableNode, + ); + if (edgePosition) { + $insertParagraphAtTableEdge(edgePosition, tableNode, [ + $createTextNode(payload), + ]); + return true; + } + } + } + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + if (hasTabHandler) { + tableObserver.listenersToRemove.add( + editor.registerCommand( + KEY_TAB_COMMAND, + (event) => { + const selection = $getSelection(); + if ( + !$isRangeSelection(selection) || + !selection.isCollapsed() || + !$isSelectionInTable(selection, tableNode) + ) { + return false; + } + + const tableCellNode = $findCellNode(selection.anchor.getNode()); + if (tableCellNode === null) { + return false; + } + + stopEvent(event); + + const currentCords = tableNode.getCordsFromCellNode( + tableCellNode, + tableObserver.table, + ); + + selectTableNodeInDirection( + tableObserver, + tableNode, + currentCords.x, + currentCords.y, + !event.shiftKey ? 'forward' : 'backward', + ); + + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + } + + tableObserver.listenersToRemove.add( + editor.registerCommand( + FOCUS_COMMAND, + (payload) => { + return tableNode.isSelected(); + }, + COMMAND_PRIORITY_HIGH, + ), + ); + + function getObserverCellFromCellNode( + tableCellNode: TableCellNode, + ): TableDOMCell { + const currentCords = tableNode.getCordsFromCellNode( + tableCellNode, + tableObserver.table, + ); + return tableNode.getDOMCellFromCordsOrThrow( + currentCords.x, + currentCords.y, + tableObserver.table, + ); + } + + tableObserver.listenersToRemove.add( + editor.registerCommand( + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, + (selectionPayload) => { + const {nodes, selection} = selectionPayload; + const anchorAndFocus = selection.getStartEndPoints(); + const isTableSelection = $isTableSelection(selection); + const isRangeSelection = $isRangeSelection(selection); + const isSelectionInsideOfGrid = + (isRangeSelection && + $findMatchingParent(selection.anchor.getNode(), (n) => + $isTableCellNode(n), + ) !== null && + $findMatchingParent(selection.focus.getNode(), (n) => + $isTableCellNode(n), + ) !== null) || + isTableSelection; + + if ( + nodes.length !== 1 || + !$isTableNode(nodes[0]) || + !isSelectionInsideOfGrid || + anchorAndFocus === null + ) { + return false; + } + const [anchor] = anchorAndFocus; + + const newGrid = nodes[0]; + const newGridRows = newGrid.getChildren(); + const newColumnCount = newGrid + .getFirstChildOrThrow() + .getChildrenSize(); + const newRowCount = newGrid.getChildrenSize(); + const gridCellNode = $findMatchingParent(anchor.getNode(), (n) => + $isTableCellNode(n), + ); + const gridRowNode = + gridCellNode && + $findMatchingParent(gridCellNode, (n) => $isTableRowNode(n)); + const gridNode = + gridRowNode && + $findMatchingParent(gridRowNode, (n) => $isTableNode(n)); + + if ( + !$isTableCellNode(gridCellNode) || + !$isTableRowNode(gridRowNode) || + !$isTableNode(gridNode) + ) { + return false; + } + + const startY = gridRowNode.getIndexWithinParent(); + const stopY = Math.min( + gridNode.getChildrenSize() - 1, + startY + newRowCount - 1, + ); + const startX = gridCellNode.getIndexWithinParent(); + const stopX = Math.min( + gridRowNode.getChildrenSize() - 1, + startX + newColumnCount - 1, + ); + const fromX = Math.min(startX, stopX); + const fromY = Math.min(startY, stopY); + const toX = Math.max(startX, stopX); + const toY = Math.max(startY, stopY); + const gridRowNodes = gridNode.getChildren(); + let newRowIdx = 0; + + for (let r = fromY; r <= toY; r++) { + const currentGridRowNode = gridRowNodes[r]; + + if (!$isTableRowNode(currentGridRowNode)) { + return false; + } + + const newGridRowNode = newGridRows[newRowIdx]; + + if (!$isTableRowNode(newGridRowNode)) { + return false; + } + + const gridCellNodes = currentGridRowNode.getChildren(); + const newGridCellNodes = newGridRowNode.getChildren(); + let newColumnIdx = 0; + + for (let c = fromX; c <= toX; c++) { + const currentGridCellNode = gridCellNodes[c]; + + if (!$isTableCellNode(currentGridCellNode)) { + return false; + } + + const newGridCellNode = newGridCellNodes[newColumnIdx]; + + if (!$isTableCellNode(newGridCellNode)) { + return false; + } + + const originalChildren = currentGridCellNode.getChildren(); + newGridCellNode.getChildren().forEach((child) => { + if ($isTextNode(child)) { + const paragraphNode = $createParagraphNode(); + paragraphNode.append(child); + currentGridCellNode.append(child); + } else { + currentGridCellNode.append(child); + } + }); + originalChildren.forEach((n) => n.remove()); + newColumnIdx++; + } + + newRowIdx++; + } + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + const selection = $getSelection(); + const prevSelection = $getPreviousSelection(); + + if ($isRangeSelection(selection)) { + const {anchor, focus} = selection; + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + // Using explicit comparison with table node to ensure it's not a nested table + // as in that case we'll leave selection resolving to that table + const anchorCellNode = $findCellNode(anchorNode); + const focusCellNode = $findCellNode(focusNode); + const isAnchorInside = !!( + anchorCellNode && tableNode.is($findTableNode(anchorCellNode)) + ); + const isFocusInside = !!( + focusCellNode && tableNode.is($findTableNode(focusCellNode)) + ); + const isPartialyWithinTable = isAnchorInside !== isFocusInside; + const isWithinTable = isAnchorInside && isFocusInside; + const isBackward = selection.isBackward(); + + if (isPartialyWithinTable) { + const newSelection = selection.clone(); + if (isFocusInside) { + const [tableMap] = $computeTableMap( + tableNode, + focusCellNode, + focusCellNode, + ); + const firstCell = tableMap[0][0].cell; + const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell; + newSelection.focus.set( + isBackward ? firstCell.getKey() : lastCell.getKey(), + isBackward + ? firstCell.getChildrenSize() + : lastCell.getChildrenSize(), + 'element', + ); + } + $setSelection(newSelection); + $addHighlightStyleToTable(editor, tableObserver); + } else if (isWithinTable) { + // Handle case when selection spans across multiple cells but still + // has range selection, then we convert it into grid selection + if (!anchorCellNode.is(focusCellNode)) { + tableObserver.setAnchorCellForSelection( + getObserverCellFromCellNode(anchorCellNode), + ); + tableObserver.setFocusCellForSelection( + getObserverCellFromCellNode(focusCellNode), + true, + ); + if (!tableObserver.isSelecting) { + setTimeout(() => { + const {onMouseUp, onMouseMove} = createMouseHandlers(); + tableObserver.isSelecting = true; + editorWindow.addEventListener('mouseup', onMouseUp); + editorWindow.addEventListener('mousemove', onMouseMove); + }, 0); + } + } + } + } else if ( + selection && + $isTableSelection(selection) && + selection.is(prevSelection) && + selection.tableKey === tableNode.getKey() + ) { + // if selection goes outside of the table we need to change it to Range selection + const domSelection = getDOMSelection(editor._window); + if ( + domSelection && + domSelection.anchorNode && + domSelection.focusNode + ) { + const focusNode = $getNearestNodeFromDOMNode( + domSelection.focusNode, + ); + const isFocusOutside = + focusNode && !tableNode.is($findTableNode(focusNode)); + + const anchorNode = $getNearestNodeFromDOMNode( + domSelection.anchorNode, + ); + const isAnchorInside = + anchorNode && tableNode.is($findTableNode(anchorNode)); + + if ( + isFocusOutside && + isAnchorInside && + domSelection.rangeCount > 0 + ) { + const newSelection = $createRangeSelectionFromDom( + domSelection, + editor, + ); + if (newSelection) { + newSelection.anchor.set( + tableNode.getKey(), + selection.isBackward() ? tableNode.getChildrenSize() : 0, + 'element', + ); + domSelection.removeAllRanges(); + $setSelection(newSelection); + } + } + } + } + + if ( + selection && + !selection.is(prevSelection) && + ($isTableSelection(selection) || $isTableSelection(prevSelection)) && + tableObserver.tableSelection && + !tableObserver.tableSelection.is(prevSelection) + ) { + if ( + $isTableSelection(selection) && + selection.tableKey === tableObserver.tableNodeKey + ) { + tableObserver.updateTableTableSelection(selection); + } else if ( + !$isTableSelection(selection) && + $isTableSelection(prevSelection) && + prevSelection.tableKey === tableObserver.tableNodeKey + ) { + tableObserver.updateTableTableSelection(null); + } + return false; + } + + if ( + tableObserver.hasHijackedSelectionStyles && + !tableNode.isSelected() + ) { + $removeHighlightStyleToTable(editor, tableObserver); + } else if ( + !tableObserver.hasHijackedSelectionStyles && + tableNode.isSelected() + ) { + $addHighlightStyleToTable(editor, tableObserver); + } + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + tableObserver.listenersToRemove.add( + editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + const selection = $getSelection(); + if ( + !$isRangeSelection(selection) || + !selection.isCollapsed() || + !$isSelectionInTable(selection, tableNode) + ) { + return false; + } + const edgePosition = $getTableEdgeCursorPosition( + editor, + selection, + tableNode, + ); + if (edgePosition) { + $insertParagraphAtTableEdge(edgePosition, tableNode); + return true; + } + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + + return tableObserver; +} + +export type HTMLTableElementWithWithTableSelectionState = HTMLTableElement & + Record; + +export function attachTableObserverToTableElement( + tableElement: HTMLTableElementWithWithTableSelectionState, + tableObserver: TableObserver, +) { + tableElement[LEXICAL_ELEMENT_KEY] = tableObserver; +} + +export function getTableObserverFromTableElement( + tableElement: HTMLTableElementWithWithTableSelectionState, +): TableObserver | null { + return tableElement[LEXICAL_ELEMENT_KEY]; +} + +export function getDOMCellFromTarget(node: Node): TableDOMCell | null { + let currentNode: ParentNode | Node | null = node; + + while (currentNode != null) { + const nodeName = currentNode.nodeName; + + if (nodeName === 'TD' || nodeName === 'TH') { + // @ts-expect-error: internal field + const cell = currentNode._cell; + + if (cell === undefined) { + return null; + } + + return cell; + } + + currentNode = currentNode.parentNode; + } + + return null; +} + +export function doesTargetContainText(node: Node): boolean { + const currentNode: ParentNode | Node | null = node; + + if (currentNode !== null) { + const nodeName = currentNode.nodeName; + + if (nodeName === 'SPAN') { + return true; + } + } + return false; +} + +export function getTable(tableElement: HTMLElement): TableDOMTable { + const domRows: TableDOMRows = []; + const grid = { + columns: 0, + domRows, + rows: 0, + }; + let currentNode = tableElement.firstChild; + let x = 0; + let y = 0; + domRows.length = 0; + + while (currentNode != null) { + const nodeMame = currentNode.nodeName; + + if (nodeMame === 'TD' || nodeMame === 'TH') { + const elem = currentNode as HTMLElement; + const cell = { + elem, + hasBackgroundColor: elem.style.backgroundColor !== '', + highlighted: false, + x, + y, + }; + + // @ts-expect-error: internal field + currentNode._cell = cell; + + let row = domRows[y]; + if (row === undefined) { + row = domRows[y] = []; + } + + row[x] = cell; + } else { + const child = currentNode.firstChild; + + if (child != null) { + currentNode = child; + continue; + } + } + + const sibling = currentNode.nextSibling; + + if (sibling != null) { + x++; + currentNode = sibling; + continue; + } + + const parent = currentNode.parentNode; + + if (parent != null) { + const parentSibling = parent.nextSibling; + + if (parentSibling == null) { + break; + } + + y++; + x = 0; + currentNode = parentSibling; + } + } + + grid.columns = x + 1; + grid.rows = y + 1; + + return grid; +} + +export function $updateDOMForSelection( + editor: LexicalEditor, + table: TableDOMTable, + selection: TableSelection | RangeSelection | null, +) { + const selectedCellNodes = new Set(selection ? selection.getNodes() : []); + $forEachTableCell(table, (cell, lexicalNode) => { + const elem = cell.elem; + + if (selectedCellNodes.has(lexicalNode)) { + cell.highlighted = true; + $addHighlightToDOM(editor, cell); + } else { + cell.highlighted = false; + $removeHighlightFromDOM(editor, cell); + if (!elem.getAttribute('style')) { + elem.removeAttribute('style'); + } + } + }); +} + +export function $forEachTableCell( + grid: TableDOMTable, + cb: ( + cell: TableDOMCell, + lexicalNode: LexicalNode, + cords: { + x: number; + y: number; + }, + ) => void, +) { + const {domRows} = grid; + + for (let y = 0; y < domRows.length; y++) { + const row = domRows[y]; + if (!row) { + continue; + } + + for (let x = 0; x < row.length; x++) { + const cell = row[x]; + if (!cell) { + continue; + } + const lexicalNode = $getNearestNodeFromDOMNode(cell.elem); + + if (lexicalNode !== null) { + cb(cell, lexicalNode, { + x, + y, + }); + } + } + } +} + +export function $addHighlightStyleToTable( + editor: LexicalEditor, + tableSelection: TableObserver, +) { + tableSelection.disableHighlightStyle(); + $forEachTableCell(tableSelection.table, (cell) => { + cell.highlighted = true; + $addHighlightToDOM(editor, cell); + }); +} + +export function $removeHighlightStyleToTable( + editor: LexicalEditor, + tableObserver: TableObserver, +) { + tableObserver.enableHighlightStyle(); + $forEachTableCell(tableObserver.table, (cell) => { + const elem = cell.elem; + cell.highlighted = false; + $removeHighlightFromDOM(editor, cell); + + if (!elem.getAttribute('style')) { + elem.removeAttribute('style'); + } + }); +} + +type Direction = 'backward' | 'forward' | 'up' | 'down'; + +const selectTableNodeInDirection = ( + tableObserver: TableObserver, + tableNode: TableNode, + x: number, + y: number, + direction: Direction, +): boolean => { + const isForward = direction === 'forward'; + + switch (direction) { + case 'backward': + case 'forward': + if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) { + selectTableCellNode( + tableNode.getCellNodeFromCordsOrThrow( + x + (isForward ? 1 : -1), + y, + tableObserver.table, + ), + isForward, + ); + } else { + if (y !== (isForward ? tableObserver.table.rows - 1 : 0)) { + selectTableCellNode( + tableNode.getCellNodeFromCordsOrThrow( + isForward ? 0 : tableObserver.table.columns - 1, + y + (isForward ? 1 : -1), + tableObserver.table, + ), + isForward, + ); + } else if (!isForward) { + tableNode.selectPrevious(); + } else { + tableNode.selectNext(); + } + } + + return true; + + case 'up': + if (y !== 0) { + selectTableCellNode( + tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table), + false, + ); + } else { + tableNode.selectPrevious(); + } + + return true; + + case 'down': + if (y !== tableObserver.table.rows - 1) { + selectTableCellNode( + tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table), + true, + ); + } else { + tableNode.selectNext(); + } + + return true; + default: + return false; + } +}; + +const adjustFocusNodeInDirection = ( + tableObserver: TableObserver, + tableNode: TableNode, + x: number, + y: number, + direction: Direction, +): boolean => { + const isForward = direction === 'forward'; + + switch (direction) { + case 'backward': + case 'forward': + if (x !== (isForward ? tableObserver.table.columns - 1 : 0)) { + tableObserver.setFocusCellForSelection( + tableNode.getDOMCellFromCordsOrThrow( + x + (isForward ? 1 : -1), + y, + tableObserver.table, + ), + ); + } + + return true; + case 'up': + if (y !== 0) { + tableObserver.setFocusCellForSelection( + tableNode.getDOMCellFromCordsOrThrow(x, y - 1, tableObserver.table), + ); + + return true; + } else { + return false; + } + case 'down': + if (y !== tableObserver.table.rows - 1) { + tableObserver.setFocusCellForSelection( + tableNode.getDOMCellFromCordsOrThrow(x, y + 1, tableObserver.table), + ); + + return true; + } else { + return false; + } + default: + return false; + } +}; + +function $isSelectionInTable( + selection: null | BaseSelection, + tableNode: TableNode, +): boolean { + if ($isRangeSelection(selection) || $isTableSelection(selection)) { + const isAnchorInside = tableNode.isParentOf(selection.anchor.getNode()); + const isFocusInside = tableNode.isParentOf(selection.focus.getNode()); + + return isAnchorInside && isFocusInside; + } + + return false; +} + +function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) { + if (fromStart) { + tableCell.selectStart(); + } else { + tableCell.selectEnd(); + } +} + +const BROWSER_BLUE_RGB = '172,206,247'; +function $addHighlightToDOM(editor: LexicalEditor, cell: TableDOMCell): void { + const element = cell.elem; + const node = $getNearestNodeFromDOMNode(element); + invariant( + $isTableCellNode(node), + 'Expected to find LexicalNode from Table Cell DOMNode', + ); + const backgroundColor = node.getBackgroundColor(); + if (backgroundColor === null) { + element.style.setProperty('background-color', `rgb(${BROWSER_BLUE_RGB})`); + } else { + element.style.setProperty( + 'background-image', + `linear-gradient(to right, rgba(${BROWSER_BLUE_RGB},0.85), rgba(${BROWSER_BLUE_RGB},0.85))`, + ); + } + element.style.setProperty('caret-color', 'transparent'); +} + +function $removeHighlightFromDOM( + editor: LexicalEditor, + cell: TableDOMCell, +): void { + const element = cell.elem; + const node = $getNearestNodeFromDOMNode(element); + invariant( + $isTableCellNode(node), + 'Expected to find LexicalNode from Table Cell DOMNode', + ); + const backgroundColor = node.getBackgroundColor(); + if (backgroundColor === null) { + element.style.removeProperty('background-color'); + } + element.style.removeProperty('background-image'); + element.style.removeProperty('caret-color'); +} + +export function $findCellNode(node: LexicalNode): null | TableCellNode { + const cellNode = $findMatchingParent(node, $isTableCellNode); + return $isTableCellNode(cellNode) ? cellNode : null; +} + +export function $findTableNode(node: LexicalNode): null | TableNode { + const tableNode = $findMatchingParent(node, $isTableNode); + return $isTableNode(tableNode) ? tableNode : null; +} + +function $handleArrowKey( + editor: LexicalEditor, + event: KeyboardEvent, + direction: Direction, + tableNode: TableNode, + tableObserver: TableObserver, +): boolean { + if ( + (direction === 'up' || direction === 'down') && + isTypeaheadMenuInView(editor) + ) { + return false; + } + + const selection = $getSelection(); + + if (!$isSelectionInTable(selection, tableNode)) { + if ($isRangeSelection(selection)) { + if (selection.isCollapsed() && direction === 'backward') { + const anchorType = selection.anchor.type; + const anchorOffset = selection.anchor.offset; + if ( + anchorType !== 'element' && + !(anchorType === 'text' && anchorOffset === 0) + ) { + return false; + } + const anchorNode = selection.anchor.getNode(); + if (!anchorNode) { + return false; + } + const parentNode = $findMatchingParent( + anchorNode, + (n) => $isElementNode(n) && !n.isInline(), + ); + if (!parentNode) { + return false; + } + const siblingNode = parentNode.getPreviousSibling(); + if (!siblingNode || !$isTableNode(siblingNode)) { + return false; + } + stopEvent(event); + siblingNode.selectEnd(); + return true; + } else if ( + event.shiftKey && + (direction === 'up' || direction === 'down') + ) { + const focusNode = selection.focus.getNode(); + if ($isRootOrShadowRoot(focusNode)) { + const selectedNode = selection.getNodes()[0]; + if (selectedNode) { + const tableCellNode = $findMatchingParent( + selectedNode, + $isTableCellNode, + ); + if (tableCellNode && tableNode.isParentOf(tableCellNode)) { + const firstDescendant = tableNode.getFirstDescendant(); + const lastDescendant = tableNode.getLastDescendant(); + if (!firstDescendant || !lastDescendant) { + return false; + } + const [firstCellNode] = $getNodeTriplet(firstDescendant); + const [lastCellNode] = $getNodeTriplet(lastDescendant); + const firstCellCoords = tableNode.getCordsFromCellNode( + firstCellNode, + tableObserver.table, + ); + const lastCellCoords = tableNode.getCordsFromCellNode( + lastCellNode, + tableObserver.table, + ); + const firstCellDOM = tableNode.getDOMCellFromCordsOrThrow( + firstCellCoords.x, + firstCellCoords.y, + tableObserver.table, + ); + const lastCellDOM = tableNode.getDOMCellFromCordsOrThrow( + lastCellCoords.x, + lastCellCoords.y, + tableObserver.table, + ); + tableObserver.setAnchorCellForSelection(firstCellDOM); + tableObserver.setFocusCellForSelection(lastCellDOM, true); + return true; + } + } + return false; + } else { + const focusParentNode = $findMatchingParent( + focusNode, + (n) => $isElementNode(n) && !n.isInline(), + ); + if (!focusParentNode) { + return false; + } + const sibling = + direction === 'down' + ? focusParentNode.getNextSibling() + : focusParentNode.getPreviousSibling(); + if ( + $isTableNode(sibling) && + tableObserver.tableNodeKey === sibling.getKey() + ) { + const firstDescendant = sibling.getFirstDescendant(); + const lastDescendant = sibling.getLastDescendant(); + if (!firstDescendant || !lastDescendant) { + return false; + } + const [firstCellNode] = $getNodeTriplet(firstDescendant); + const [lastCellNode] = $getNodeTriplet(lastDescendant); + const newSelection = selection.clone(); + newSelection.focus.set( + (direction === 'up' ? firstCellNode : lastCellNode).getKey(), + direction === 'up' ? 0 : lastCellNode.getChildrenSize(), + 'element', + ); + $setSelection(newSelection); + return true; + } + } + } + } + return false; + } + + if ($isRangeSelection(selection) && selection.isCollapsed()) { + const {anchor, focus} = selection; + const anchorCellNode = $findMatchingParent( + anchor.getNode(), + $isTableCellNode, + ); + const focusCellNode = $findMatchingParent( + focus.getNode(), + $isTableCellNode, + ); + if ( + !$isTableCellNode(anchorCellNode) || + !anchorCellNode.is(focusCellNode) + ) { + return false; + } + const anchorCellTable = $findTableNode(anchorCellNode); + if (anchorCellTable !== tableNode && anchorCellTable != null) { + const anchorCellTableElement = editor.getElementByKey( + anchorCellTable.getKey(), + ); + if (anchorCellTableElement != null) { + tableObserver.table = getTable(anchorCellTableElement); + return $handleArrowKey( + editor, + event, + direction, + anchorCellTable, + tableObserver, + ); + } + } + + if (direction === 'backward' || direction === 'forward') { + const anchorType = anchor.type; + const anchorOffset = anchor.offset; + const anchorNode = anchor.getNode(); + if (!anchorNode) { + return false; + } + + const selectedNodes = selection.getNodes(); + if (selectedNodes.length === 1 && $isDecoratorNode(selectedNodes[0])) { + return false; + } + + if ( + isExitingTableAnchor(anchorType, anchorOffset, anchorNode, direction) + ) { + return $handleTableExit(event, anchorNode, tableNode, direction); + } + + return false; + } + + const anchorCellDom = editor.getElementByKey(anchorCellNode.__key); + const anchorDOM = editor.getElementByKey(anchor.key); + if (anchorDOM == null || anchorCellDom == null) { + return false; + } + + let edgeSelectionRect; + if (anchor.type === 'element') { + edgeSelectionRect = anchorDOM.getBoundingClientRect(); + } else { + const domSelection = window.getSelection(); + if (domSelection === null || domSelection.rangeCount === 0) { + return false; + } + + const range = domSelection.getRangeAt(0); + edgeSelectionRect = range.getBoundingClientRect(); + } + + const edgeChild = + direction === 'up' + ? anchorCellNode.getFirstChild() + : anchorCellNode.getLastChild(); + if (edgeChild == null) { + return false; + } + + const edgeChildDOM = editor.getElementByKey(edgeChild.__key); + + if (edgeChildDOM == null) { + return false; + } + + const edgeRect = edgeChildDOM.getBoundingClientRect(); + const isExiting = + direction === 'up' + ? edgeRect.top > edgeSelectionRect.top - edgeSelectionRect.height + : edgeSelectionRect.bottom + edgeSelectionRect.height > edgeRect.bottom; + + if (isExiting) { + stopEvent(event); + + const cords = tableNode.getCordsFromCellNode( + anchorCellNode, + tableObserver.table, + ); + + if (event.shiftKey) { + const cell = tableNode.getDOMCellFromCordsOrThrow( + cords.x, + cords.y, + tableObserver.table, + ); + tableObserver.setAnchorCellForSelection(cell); + tableObserver.setFocusCellForSelection(cell, true); + } else { + return selectTableNodeInDirection( + tableObserver, + tableNode, + cords.x, + cords.y, + direction, + ); + } + + return true; + } + } else if ($isTableSelection(selection)) { + const {anchor, focus} = selection; + const anchorCellNode = $findMatchingParent( + anchor.getNode(), + $isTableCellNode, + ); + const focusCellNode = $findMatchingParent( + focus.getNode(), + $isTableCellNode, + ); + + const [tableNodeFromSelection] = selection.getNodes(); + const tableElement = editor.getElementByKey( + tableNodeFromSelection.getKey(), + ); + if ( + !$isTableCellNode(anchorCellNode) || + !$isTableCellNode(focusCellNode) || + !$isTableNode(tableNodeFromSelection) || + tableElement == null + ) { + return false; + } + tableObserver.updateTableTableSelection(selection); + + const grid = getTable(tableElement); + const cordsAnchor = tableNode.getCordsFromCellNode(anchorCellNode, grid); + const anchorCell = tableNode.getDOMCellFromCordsOrThrow( + cordsAnchor.x, + cordsAnchor.y, + grid, + ); + tableObserver.setAnchorCellForSelection(anchorCell); + + stopEvent(event); + + if (event.shiftKey) { + const cords = tableNode.getCordsFromCellNode(focusCellNode, grid); + return adjustFocusNodeInDirection( + tableObserver, + tableNodeFromSelection, + cords.x, + cords.y, + direction, + ); + } else { + focusCellNode.selectEnd(); + } + + return true; + } + + return false; +} + +function stopEvent(event: Event) { + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); +} + +function isTypeaheadMenuInView(editor: LexicalEditor) { + // There is no inbuilt way to check if the component picker is in view + // but we can check if the root DOM element has the aria-controls attribute "typeahead-menu". + const root = editor.getRootElement(); + if (!root) { + return false; + } + return ( + root.hasAttribute('aria-controls') && + root.getAttribute('aria-controls') === 'typeahead-menu' + ); +} + +function isExitingTableAnchor( + type: string, + offset: number, + anchorNode: LexicalNode, + direction: 'backward' | 'forward', +) { + return ( + isExitingTableElementAnchor(type, anchorNode, direction) || + $isExitingTableTextAnchor(type, offset, anchorNode, direction) + ); +} + +function isExitingTableElementAnchor( + type: string, + anchorNode: LexicalNode, + direction: 'backward' | 'forward', +) { + return ( + type === 'element' && + (direction === 'backward' + ? anchorNode.getPreviousSibling() === null + : anchorNode.getNextSibling() === null) + ); +} + +function $isExitingTableTextAnchor( + type: string, + offset: number, + anchorNode: LexicalNode, + direction: 'backward' | 'forward', +) { + const parentNode = $findMatchingParent( + anchorNode, + (n) => $isElementNode(n) && !n.isInline(), + ); + if (!parentNode) { + return false; + } + const hasValidOffset = + direction === 'backward' + ? offset === 0 + : offset === anchorNode.getTextContentSize(); + return ( + type === 'text' && + hasValidOffset && + (direction === 'backward' + ? parentNode.getPreviousSibling() === null + : parentNode.getNextSibling() === null) + ); +} + +function $handleTableExit( + event: KeyboardEvent, + anchorNode: LexicalNode, + tableNode: TableNode, + direction: 'backward' | 'forward', +) { + const anchorCellNode = $findMatchingParent(anchorNode, $isTableCellNode); + if (!$isTableCellNode(anchorCellNode)) { + return false; + } + const [tableMap, cellValue] = $computeTableMap( + tableNode, + anchorCellNode, + anchorCellNode, + ); + if (!isExitingCell(tableMap, cellValue, direction)) { + return false; + } + + const toNode = $getExitingToNode(anchorNode, direction, tableNode); + if (!toNode || $isTableNode(toNode)) { + return false; + } + + stopEvent(event); + if (direction === 'backward') { + toNode.selectEnd(); + } else { + toNode.selectStart(); + } + return true; +} + +function isExitingCell( + tableMap: TableMapType, + cellValue: TableMapValueType, + direction: 'backward' | 'forward', +) { + const firstCell = tableMap[0][0]; + const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1]; + const {startColumn, startRow} = cellValue; + return direction === 'backward' + ? startColumn === firstCell.startColumn && startRow === firstCell.startRow + : startColumn === lastCell.startColumn && startRow === lastCell.startRow; +} + +function $getExitingToNode( + anchorNode: LexicalNode, + direction: 'backward' | 'forward', + tableNode: TableNode, +) { + const parentNode = $findMatchingParent( + anchorNode, + (n) => $isElementNode(n) && !n.isInline(), + ); + if (!parentNode) { + return undefined; + } + const anchorSibling = + direction === 'backward' + ? parentNode.getPreviousSibling() + : parentNode.getNextSibling(); + return anchorSibling && $isTableNode(anchorSibling) + ? anchorSibling + : direction === 'backward' + ? tableNode.getPreviousSibling() + : tableNode.getNextSibling(); +} + +function $insertParagraphAtTableEdge( + edgePosition: 'first' | 'last', + tableNode: TableNode, + children?: LexicalNode[], +) { + const paragraphNode = $createParagraphNode(); + if (edgePosition === 'first') { + tableNode.insertBefore(paragraphNode); + } else { + tableNode.insertAfter(paragraphNode); + } + paragraphNode.append(...(children || [])); + paragraphNode.selectEnd(); +} + +function $getTableEdgeCursorPosition( + editor: LexicalEditor, + selection: RangeSelection, + tableNode: TableNode, +) { + const tableNodeParent = tableNode.getParent(); + if (!tableNodeParent) { + return undefined; + } + + const tableNodeParentDOM = editor.getElementByKey(tableNodeParent.getKey()); + if (!tableNodeParentDOM) { + return undefined; + } + + // TODO: Add support for nested tables + const domSelection = window.getSelection(); + if (!domSelection || domSelection.anchorNode !== tableNodeParentDOM) { + return undefined; + } + + const anchorCellNode = $findMatchingParent(selection.anchor.getNode(), (n) => + $isTableCellNode(n), + ) as TableCellNode | null; + if (!anchorCellNode) { + return undefined; + } + + const parentTable = $findMatchingParent(anchorCellNode, (n) => + $isTableNode(n), + ); + if (!$isTableNode(parentTable) || !parentTable.is(tableNode)) { + return undefined; + } + + const [tableMap, cellValue] = $computeTableMap( + tableNode, + anchorCellNode, + anchorCellNode, + ); + const firstCell = tableMap[0][0]; + const lastCell = tableMap[tableMap.length - 1][tableMap[0].length - 1]; + const {startRow, startColumn} = cellValue; + + const isAtFirstCell = + startRow === firstCell.startRow && startColumn === firstCell.startColumn; + const isAtLastCell = + startRow === lastCell.startRow && startColumn === lastCell.startColumn; + + if (isAtFirstCell) { + return 'first'; + } else if (isAtLastCell) { + return 'last'; + } else { + return undefined; + } +} diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts new file mode 100644 index 000000000..cdbc84658 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts @@ -0,0 +1,894 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {TableMapType, TableMapValueType} from './LexicalTableSelection'; +import type {ElementNode, PointType} from 'lexical'; + +import {$findMatchingParent} from '@lexical/utils'; +import { + $createParagraphNode, + $createTextNode, + $getSelection, + $isRangeSelection, + LexicalNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {InsertTableCommandPayloadHeaders} from '.'; +import { + $createTableCellNode, + $isTableCellNode, + TableCellHeaderState, + TableCellHeaderStates, + TableCellNode, +} from './LexicalTableCellNode'; +import {$createTableNode, $isTableNode, TableNode} from './LexicalTableNode'; +import {TableDOMTable} from './LexicalTableObserver'; +import { + $createTableRowNode, + $isTableRowNode, + TableRowNode, +} from './LexicalTableRowNode'; +import {$isTableSelection} from './LexicalTableSelection'; + +export function $createTableNodeWithDimensions( + rowCount: number, + columnCount: number, + includeHeaders: InsertTableCommandPayloadHeaders = true, +): TableNode { + const tableNode = $createTableNode(); + + for (let iRow = 0; iRow < rowCount; iRow++) { + const tableRowNode = $createTableRowNode(); + + for (let iColumn = 0; iColumn < columnCount; iColumn++) { + let headerState = TableCellHeaderStates.NO_STATUS; + + if (typeof includeHeaders === 'object') { + if (iRow === 0 && includeHeaders.rows) { + headerState |= TableCellHeaderStates.ROW; + } + if (iColumn === 0 && includeHeaders.columns) { + headerState |= TableCellHeaderStates.COLUMN; + } + } else if (includeHeaders) { + if (iRow === 0) { + headerState |= TableCellHeaderStates.ROW; + } + if (iColumn === 0) { + headerState |= TableCellHeaderStates.COLUMN; + } + } + + const tableCellNode = $createTableCellNode(headerState); + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode()); + tableCellNode.append(paragraphNode); + tableRowNode.append(tableCellNode); + } + + tableNode.append(tableRowNode); + } + + return tableNode; +} + +export function $getTableCellNodeFromLexicalNode( + startingNode: LexicalNode, +): TableCellNode | null { + const node = $findMatchingParent(startingNode, (n) => $isTableCellNode(n)); + + if ($isTableCellNode(node)) { + return node; + } + + return null; +} + +export function $getTableRowNodeFromTableCellNodeOrThrow( + startingNode: LexicalNode, +): TableRowNode { + const node = $findMatchingParent(startingNode, (n) => $isTableRowNode(n)); + + if ($isTableRowNode(node)) { + return node; + } + + throw new Error('Expected table cell to be inside of table row.'); +} + +export function $getTableNodeFromLexicalNodeOrThrow( + startingNode: LexicalNode, +): TableNode { + const node = $findMatchingParent(startingNode, (n) => $isTableNode(n)); + + if ($isTableNode(node)) { + return node; + } + + throw new Error('Expected table cell to be inside of table.'); +} + +export function $getTableRowIndexFromTableCellNode( + tableCellNode: TableCellNode, +): number { + const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode); + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableRowNode); + return tableNode.getChildren().findIndex((n) => n.is(tableRowNode)); +} + +export function $getTableColumnIndexFromTableCellNode( + tableCellNode: TableCellNode, +): number { + const tableRowNode = $getTableRowNodeFromTableCellNodeOrThrow(tableCellNode); + return tableRowNode.getChildren().findIndex((n) => n.is(tableCellNode)); +} + +export type TableCellSiblings = { + above: TableCellNode | null | undefined; + below: TableCellNode | null | undefined; + left: TableCellNode | null | undefined; + right: TableCellNode | null | undefined; +}; + +export function $getTableCellSiblingsFromTableCellNode( + tableCellNode: TableCellNode, + table: TableDOMTable, +): TableCellSiblings { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + const {x, y} = tableNode.getCordsFromCellNode(tableCellNode, table); + return { + above: tableNode.getCellNodeFromCords(x, y - 1, table), + below: tableNode.getCellNodeFromCords(x, y + 1, table), + left: tableNode.getCellNodeFromCords(x - 1, y, table), + right: tableNode.getCellNodeFromCords(x + 1, y, table), + }; +} + +export function $removeTableRowAtIndex( + tableNode: TableNode, + indexToDelete: number, +): TableNode { + const tableRows = tableNode.getChildren(); + + if (indexToDelete >= tableRows.length || indexToDelete < 0) { + throw new Error('Expected table cell to be inside of table row.'); + } + + const targetRowNode = tableRows[indexToDelete]; + targetRowNode.remove(); + return tableNode; +} + +export function $insertTableRow( + tableNode: TableNode, + targetIndex: number, + shouldInsertAfter = true, + rowCount: number, + table: TableDOMTable, +): TableNode { + const tableRows = tableNode.getChildren(); + + if (targetIndex >= tableRows.length || targetIndex < 0) { + throw new Error('Table row target index out of range'); + } + + const targetRowNode = tableRows[targetIndex]; + + if ($isTableRowNode(targetRowNode)) { + for (let r = 0; r < rowCount; r++) { + const tableRowCells = targetRowNode.getChildren(); + const tableColumnCount = tableRowCells.length; + const newTableRowNode = $createTableRowNode(); + + for (let c = 0; c < tableColumnCount; c++) { + const tableCellFromTargetRow = tableRowCells[c]; + + invariant( + $isTableCellNode(tableCellFromTargetRow), + 'Expected table cell', + ); + + const {above, below} = $getTableCellSiblingsFromTableCellNode( + tableCellFromTargetRow, + table, + ); + + let headerState = TableCellHeaderStates.NO_STATUS; + const width = + (above && above.getWidth()) || + (below && below.getWidth()) || + undefined; + + if ( + (above && above.hasHeaderState(TableCellHeaderStates.COLUMN)) || + (below && below.hasHeaderState(TableCellHeaderStates.COLUMN)) + ) { + headerState |= TableCellHeaderStates.COLUMN; + } + + const tableCellNode = $createTableCellNode(headerState, 1, width); + + tableCellNode.append($createParagraphNode()); + + newTableRowNode.append(tableCellNode); + } + + if (shouldInsertAfter) { + targetRowNode.insertAfter(newTableRowNode); + } else { + targetRowNode.insertBefore(newTableRowNode); + } + } + } else { + throw new Error('Row before insertion index does not exist.'); + } + + return tableNode; +} + +const getHeaderState = ( + currentState: TableCellHeaderState, + possibleState: TableCellHeaderState, +): TableCellHeaderState => { + if ( + currentState === TableCellHeaderStates.BOTH || + currentState === possibleState + ) { + return possibleState; + } + return TableCellHeaderStates.NO_STATUS; +}; + +export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection) || $isTableSelection(selection), + 'Expected a RangeSelection or TableSelection', + ); + const focus = selection.focus.getNode(); + const [focusCell, , grid] = $getNodeTriplet(focus); + const [gridMap, focusCellMap] = $computeTableMap(grid, focusCell, focusCell); + const columnCount = gridMap[0].length; + const {startRow: focusStartRow} = focusCellMap; + if (insertAfter) { + const focusEndRow = focusStartRow + focusCell.__rowSpan - 1; + const focusEndRowMap = gridMap[focusEndRow]; + const newRow = $createTableRowNode(); + for (let i = 0; i < columnCount; i++) { + const {cell, startRow} = focusEndRowMap[i]; + if (startRow + cell.__rowSpan - 1 <= focusEndRow) { + const currentCell = focusEndRowMap[i].cell as TableCellNode; + const currentCellHeaderState = currentCell.__headerState; + + const headerState = getHeaderState( + currentCellHeaderState, + TableCellHeaderStates.COLUMN, + ); + + newRow.append( + $createTableCellNode(headerState).append($createParagraphNode()), + ); + } else { + cell.setRowSpan(cell.__rowSpan + 1); + } + } + const focusEndRowNode = grid.getChildAtIndex(focusEndRow); + invariant( + $isTableRowNode(focusEndRowNode), + 'focusEndRow is not a TableRowNode', + ); + focusEndRowNode.insertAfter(newRow); + } else { + const focusStartRowMap = gridMap[focusStartRow]; + const newRow = $createTableRowNode(); + for (let i = 0; i < columnCount; i++) { + const {cell, startRow} = focusStartRowMap[i]; + if (startRow === focusStartRow) { + const currentCell = focusStartRowMap[i].cell as TableCellNode; + const currentCellHeaderState = currentCell.__headerState; + + const headerState = getHeaderState( + currentCellHeaderState, + TableCellHeaderStates.COLUMN, + ); + + newRow.append( + $createTableCellNode(headerState).append($createParagraphNode()), + ); + } else { + cell.setRowSpan(cell.__rowSpan + 1); + } + } + const focusStartRowNode = grid.getChildAtIndex(focusStartRow); + invariant( + $isTableRowNode(focusStartRowNode), + 'focusEndRow is not a TableRowNode', + ); + focusStartRowNode.insertBefore(newRow); + } +} + +export function $insertTableColumn( + tableNode: TableNode, + targetIndex: number, + shouldInsertAfter = true, + columnCount: number, + table: TableDOMTable, +): TableNode { + const tableRows = tableNode.getChildren(); + + const tableCellsToBeInserted = []; + for (let r = 0; r < tableRows.length; r++) { + const currentTableRowNode = tableRows[r]; + + if ($isTableRowNode(currentTableRowNode)) { + for (let c = 0; c < columnCount; c++) { + const tableRowChildren = currentTableRowNode.getChildren(); + if (targetIndex >= tableRowChildren.length || targetIndex < 0) { + throw new Error('Table column target index out of range'); + } + + const targetCell = tableRowChildren[targetIndex]; + + invariant($isTableCellNode(targetCell), 'Expected table cell'); + + const {left, right} = $getTableCellSiblingsFromTableCellNode( + targetCell, + table, + ); + + let headerState = TableCellHeaderStates.NO_STATUS; + + if ( + (left && left.hasHeaderState(TableCellHeaderStates.ROW)) || + (right && right.hasHeaderState(TableCellHeaderStates.ROW)) + ) { + headerState |= TableCellHeaderStates.ROW; + } + + const newTableCell = $createTableCellNode(headerState); + + newTableCell.append($createParagraphNode()); + tableCellsToBeInserted.push({ + newTableCell, + targetCell, + }); + } + } + } + tableCellsToBeInserted.forEach(({newTableCell, targetCell}) => { + if (shouldInsertAfter) { + targetCell.insertAfter(newTableCell); + } else { + targetCell.insertBefore(newTableCell); + } + }); + + return tableNode; +} + +export function $insertTableColumn__EXPERIMENTAL(insertAfter = true): void { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection) || $isTableSelection(selection), + 'Expected a RangeSelection or TableSelection', + ); + const anchor = selection.anchor.getNode(); + const focus = selection.focus.getNode(); + const [anchorCell] = $getNodeTriplet(anchor); + const [focusCell, , grid] = $getNodeTriplet(focus); + const [gridMap, focusCellMap, anchorCellMap] = $computeTableMap( + grid, + focusCell, + anchorCell, + ); + const rowCount = gridMap.length; + const startColumn = insertAfter + ? Math.max(focusCellMap.startColumn, anchorCellMap.startColumn) + : Math.min(focusCellMap.startColumn, anchorCellMap.startColumn); + const insertAfterColumn = insertAfter + ? startColumn + focusCell.__colSpan - 1 + : startColumn - 1; + const gridFirstChild = grid.getFirstChild(); + invariant( + $isTableRowNode(gridFirstChild), + 'Expected firstTable child to be a row', + ); + let firstInsertedCell: null | TableCellNode = null; + function $createTableCellNodeForInsertTableColumn( + headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, + ) { + const cell = $createTableCellNode(headerState).append( + $createParagraphNode(), + ); + if (firstInsertedCell === null) { + firstInsertedCell = cell; + } + return cell; + } + let loopRow: TableRowNode = gridFirstChild; + rowLoop: for (let i = 0; i < rowCount; i++) { + if (i !== 0) { + const currentRow = loopRow.getNextSibling(); + invariant( + $isTableRowNode(currentRow), + 'Expected row nextSibling to be a row', + ); + loopRow = currentRow; + } + const rowMap = gridMap[i]; + + const currentCellHeaderState = ( + rowMap[insertAfterColumn < 0 ? 0 : insertAfterColumn] + .cell as TableCellNode + ).__headerState; + + const headerState = getHeaderState( + currentCellHeaderState, + TableCellHeaderStates.ROW, + ); + + if (insertAfterColumn < 0) { + $insertFirst( + loopRow, + $createTableCellNodeForInsertTableColumn(headerState), + ); + continue; + } + const { + cell: currentCell, + startColumn: currentStartColumn, + startRow: currentStartRow, + } = rowMap[insertAfterColumn]; + if (currentStartColumn + currentCell.__colSpan - 1 <= insertAfterColumn) { + let insertAfterCell: TableCellNode = currentCell; + let insertAfterCellRowStart = currentStartRow; + let prevCellIndex = insertAfterColumn; + while (insertAfterCellRowStart !== i && insertAfterCell.__rowSpan > 1) { + prevCellIndex -= currentCell.__colSpan; + if (prevCellIndex >= 0) { + const {cell: cell_, startRow: startRow_} = rowMap[prevCellIndex]; + insertAfterCell = cell_; + insertAfterCellRowStart = startRow_; + } else { + loopRow.append($createTableCellNodeForInsertTableColumn(headerState)); + continue rowLoop; + } + } + insertAfterCell.insertAfter( + $createTableCellNodeForInsertTableColumn(headerState), + ); + } else { + currentCell.setColSpan(currentCell.__colSpan + 1); + } + } + if (firstInsertedCell !== null) { + $moveSelectionToCell(firstInsertedCell); + } +} + +export function $deleteTableColumn( + tableNode: TableNode, + targetIndex: number, +): TableNode { + const tableRows = tableNode.getChildren(); + + for (let i = 0; i < tableRows.length; i++) { + const currentTableRowNode = tableRows[i]; + + if ($isTableRowNode(currentTableRowNode)) { + const tableRowChildren = currentTableRowNode.getChildren(); + + if (targetIndex >= tableRowChildren.length || targetIndex < 0) { + throw new Error('Table column target index out of range'); + } + + tableRowChildren[targetIndex].remove(); + } + } + + return tableNode; +} + +export function $deleteTableRow__EXPERIMENTAL(): void { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection) || $isTableSelection(selection), + 'Expected a RangeSelection or TableSelection', + ); + const anchor = selection.anchor.getNode(); + const focus = selection.focus.getNode(); + const [anchorCell, , grid] = $getNodeTriplet(anchor); + const [focusCell] = $getNodeTriplet(focus); + const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap( + grid, + anchorCell, + focusCell, + ); + const {startRow: anchorStartRow} = anchorCellMap; + const {startRow: focusStartRow} = focusCellMap; + const focusEndRow = focusStartRow + focusCell.__rowSpan - 1; + if (gridMap.length === focusEndRow - anchorStartRow + 1) { + // Empty grid + grid.remove(); + return; + } + const columnCount = gridMap[0].length; + const nextRow = gridMap[focusEndRow + 1]; + const nextRowNode: null | TableRowNode = grid.getChildAtIndex( + focusEndRow + 1, + ); + for (let row = focusEndRow; row >= anchorStartRow; row--) { + for (let column = columnCount - 1; column >= 0; column--) { + const { + cell, + startRow: cellStartRow, + startColumn: cellStartColumn, + } = gridMap[row][column]; + if (cellStartColumn !== column) { + // Don't repeat work for the same Cell + continue; + } + // Rows overflowing top have to be trimmed + if (row === anchorStartRow && cellStartRow < anchorStartRow) { + cell.setRowSpan(cell.__rowSpan - (cellStartRow - anchorStartRow)); + } + // Rows overflowing bottom have to be trimmed and moved to the next row + if ( + cellStartRow >= anchorStartRow && + cellStartRow + cell.__rowSpan - 1 > focusEndRow + ) { + cell.setRowSpan(cell.__rowSpan - (focusEndRow - cellStartRow + 1)); + invariant(nextRowNode !== null, 'Expected nextRowNode not to be null'); + if (column === 0) { + $insertFirst(nextRowNode, cell); + } else { + const {cell: previousCell} = nextRow[column - 1]; + previousCell.insertAfter(cell); + } + } + } + const rowNode = grid.getChildAtIndex(row); + invariant( + $isTableRowNode(rowNode), + 'Expected GridNode childAtIndex(%s) to be RowNode', + String(row), + ); + rowNode.remove(); + } + if (nextRow !== undefined) { + const {cell} = nextRow[0]; + $moveSelectionToCell(cell); + } else { + const previousRow = gridMap[anchorStartRow - 1]; + const {cell} = previousRow[0]; + $moveSelectionToCell(cell); + } +} + +export function $deleteTableColumn__EXPERIMENTAL(): void { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection) || $isTableSelection(selection), + 'Expected a RangeSelection or TableSelection', + ); + const anchor = selection.anchor.getNode(); + const focus = selection.focus.getNode(); + const [anchorCell, , grid] = $getNodeTriplet(anchor); + const [focusCell] = $getNodeTriplet(focus); + const [gridMap, anchorCellMap, focusCellMap] = $computeTableMap( + grid, + anchorCell, + focusCell, + ); + const {startColumn: anchorStartColumn} = anchorCellMap; + const {startRow: focusStartRow, startColumn: focusStartColumn} = focusCellMap; + const startColumn = Math.min(anchorStartColumn, focusStartColumn); + const endColumn = Math.max( + anchorStartColumn + anchorCell.__colSpan - 1, + focusStartColumn + focusCell.__colSpan - 1, + ); + const selectedColumnCount = endColumn - startColumn + 1; + const columnCount = gridMap[0].length; + if (columnCount === endColumn - startColumn + 1) { + // Empty grid + grid.selectPrevious(); + grid.remove(); + return; + } + const rowCount = gridMap.length; + for (let row = 0; row < rowCount; row++) { + for (let column = startColumn; column <= endColumn; column++) { + const {cell, startColumn: cellStartColumn} = gridMap[row][column]; + if (cellStartColumn < startColumn) { + if (column === startColumn) { + const overflowLeft = startColumn - cellStartColumn; + // Overflowing left + cell.setColSpan( + cell.__colSpan - + // Possible overflow right too + Math.min(selectedColumnCount, cell.__colSpan - overflowLeft), + ); + } + } else if (cellStartColumn + cell.__colSpan - 1 > endColumn) { + if (column === endColumn) { + // Overflowing right + const inSelectedArea = endColumn - cellStartColumn + 1; + cell.setColSpan(cell.__colSpan - inSelectedArea); + } + } else { + cell.remove(); + } + } + } + const focusRowMap = gridMap[focusStartRow]; + const nextColumn = + anchorStartColumn > focusStartColumn + ? focusRowMap[anchorStartColumn + anchorCell.__colSpan] + : focusRowMap[focusStartColumn + focusCell.__colSpan]; + if (nextColumn !== undefined) { + const {cell} = nextColumn; + $moveSelectionToCell(cell); + } else { + const previousRow = + focusStartColumn < anchorStartColumn + ? focusRowMap[focusStartColumn - 1] + : focusRowMap[anchorStartColumn - 1]; + const {cell} = previousRow; + $moveSelectionToCell(cell); + } +} + +function $moveSelectionToCell(cell: TableCellNode): void { + const firstDescendant = cell.getFirstDescendant(); + if (firstDescendant == null) { + cell.selectStart(); + } else { + firstDescendant.getParentOrThrow().selectStart(); + } +} + +function $insertFirst(parent: ElementNode, node: LexicalNode): void { + const firstChild = parent.getFirstChild(); + if (firstChild !== null) { + firstChild.insertBefore(node); + } else { + parent.append(node); + } +} + +export function $unmergeCell(): void { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection) || $isTableSelection(selection), + 'Expected a RangeSelection or TableSelection', + ); + const anchor = selection.anchor.getNode(); + const [cell, row, grid] = $getNodeTriplet(anchor); + const colSpan = cell.__colSpan; + const rowSpan = cell.__rowSpan; + if (colSpan > 1) { + for (let i = 1; i < colSpan; i++) { + cell.insertAfter( + $createTableCellNode(TableCellHeaderStates.NO_STATUS).append( + $createParagraphNode(), + ), + ); + } + cell.setColSpan(1); + } + if (rowSpan > 1) { + const [map, cellMap] = $computeTableMap(grid, cell, cell); + const {startColumn, startRow} = cellMap; + let currentRowNode; + for (let i = 1; i < rowSpan; i++) { + const currentRow = startRow + i; + const currentRowMap = map[currentRow]; + currentRowNode = (currentRowNode || row).getNextSibling(); + invariant( + $isTableRowNode(currentRowNode), + 'Expected row next sibling to be a row', + ); + let insertAfterCell: null | TableCellNode = null; + for (let column = 0; column < startColumn; column++) { + const currentCellMap = currentRowMap[column]; + const currentCell = currentCellMap.cell; + if (currentCellMap.startRow === currentRow) { + insertAfterCell = currentCell; + } + if (currentCell.__colSpan > 1) { + column += currentCell.__colSpan - 1; + } + } + if (insertAfterCell === null) { + for (let j = 0; j < colSpan; j++) { + $insertFirst( + currentRowNode, + $createTableCellNode(TableCellHeaderStates.NO_STATUS).append( + $createParagraphNode(), + ), + ); + } + } else { + for (let j = 0; j < colSpan; j++) { + insertAfterCell.insertAfter( + $createTableCellNode(TableCellHeaderStates.NO_STATUS).append( + $createParagraphNode(), + ), + ); + } + } + } + cell.setRowSpan(1); + } +} + +export function $computeTableMap( + grid: TableNode, + cellA: TableCellNode, + cellB: TableCellNode, +): [TableMapType, TableMapValueType, TableMapValueType] { + const [tableMap, cellAValue, cellBValue] = $computeTableMapSkipCellCheck( + grid, + cellA, + cellB, + ); + invariant(cellAValue !== null, 'Anchor not found in Grid'); + invariant(cellBValue !== null, 'Focus not found in Grid'); + return [tableMap, cellAValue, cellBValue]; +} + +export function $computeTableMapSkipCellCheck( + grid: TableNode, + cellA: null | TableCellNode, + cellB: null | TableCellNode, +): [TableMapType, TableMapValueType | null, TableMapValueType | null] { + const tableMap: TableMapType = []; + let cellAValue: null | TableMapValueType = null; + let cellBValue: null | TableMapValueType = null; + function write(startRow: number, startColumn: number, cell: TableCellNode) { + const value = { + cell, + startColumn, + startRow, + }; + const rowSpan = cell.__rowSpan; + const colSpan = cell.__colSpan; + for (let i = 0; i < rowSpan; i++) { + if (tableMap[startRow + i] === undefined) { + tableMap[startRow + i] = []; + } + for (let j = 0; j < colSpan; j++) { + tableMap[startRow + i][startColumn + j] = value; + } + } + if (cellA !== null && cellA.is(cell)) { + cellAValue = value; + } + if (cellB !== null && cellB.is(cell)) { + cellBValue = value; + } + } + function isEmpty(row: number, column: number) { + return tableMap[row] === undefined || tableMap[row][column] === undefined; + } + + const gridChildren = grid.getChildren(); + for (let i = 0; i < gridChildren.length; i++) { + const row = gridChildren[i]; + invariant( + $isTableRowNode(row), + 'Expected GridNode children to be TableRowNode', + ); + const rowChildren = row.getChildren(); + let j = 0; + for (const cell of rowChildren) { + invariant( + $isTableCellNode(cell), + 'Expected TableRowNode children to be TableCellNode', + ); + while (!isEmpty(i, j)) { + j++; + } + write(i, j, cell); + j += cell.__colSpan; + } + } + return [tableMap, cellAValue, cellBValue]; +} + +export function $getNodeTriplet( + source: PointType | LexicalNode | TableCellNode, +): [TableCellNode, TableRowNode, TableNode] { + let cell: TableCellNode; + if (source instanceof TableCellNode) { + cell = source; + } else if ('__type' in source) { + const cell_ = $findMatchingParent(source, $isTableCellNode); + invariant( + $isTableCellNode(cell_), + 'Expected to find a parent TableCellNode', + ); + cell = cell_; + } else { + const cell_ = $findMatchingParent(source.getNode(), $isTableCellNode); + invariant( + $isTableCellNode(cell_), + 'Expected to find a parent TableCellNode', + ); + cell = cell_; + } + const row = cell.getParent(); + invariant( + $isTableRowNode(row), + 'Expected TableCellNode to have a parent TableRowNode', + ); + const grid = row.getParent(); + invariant( + $isTableNode(grid), + 'Expected TableRowNode to have a parent GridNode', + ); + return [cell, row, grid]; +} + +export function $getTableCellNodeRect(tableCellNode: TableCellNode): { + rowIndex: number; + columnIndex: number; + rowSpan: number; + colSpan: number; +} | null { + const [cellNode, , gridNode] = $getNodeTriplet(tableCellNode); + const rows = gridNode.getChildren(); + const rowCount = rows.length; + const columnCount = rows[0].getChildren().length; + + // Create a matrix of the same size as the table to track the position of each cell + const cellMatrix = new Array(rowCount); + for (let i = 0; i < rowCount; i++) { + cellMatrix[i] = new Array(columnCount); + } + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row = rows[rowIndex]; + const cells = row.getChildren(); + let columnIndex = 0; + + for (let cellIndex = 0; cellIndex < cells.length; cellIndex++) { + // Find the next available position in the matrix, skip the position of merged cells + while (cellMatrix[rowIndex][columnIndex]) { + columnIndex++; + } + + const cell = cells[cellIndex]; + const rowSpan = cell.__rowSpan || 1; + const colSpan = cell.__colSpan || 1; + + // Put the cell into the corresponding position in the matrix + for (let i = 0; i < rowSpan; i++) { + for (let j = 0; j < colSpan; j++) { + cellMatrix[rowIndex + i][columnIndex + j] = cell; + } + } + + // Return to the original index, row span and column span of the cell. + if (cellNode === cell) { + return { + colSpan, + columnIndex, + rowIndex, + rowSpan, + }; + } + + columnIndex += colSpan; + } + } + + return null; +} diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts new file mode 100644 index 000000000..9c56db63b --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createTableCellNode, TableCellHeaderStates} from '@lexical/table'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + tableCell: 'test-table-cell-class', + }, +}); + +describe('LexicalTableCellNode tests', () => { + initializeUnitTest((testEnv) => { + test('TableCellNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const cellNode = $createTableCellNode(TableCellHeaderStates.NO_STATUS); + + expect(cellNode).not.toBe(null); + }); + + expect(() => + $createTableCellNode(TableCellHeaderStates.NO_STATUS), + ).toThrow(); + }); + + test('TableCellNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const cellNode = $createTableCellNode(TableCellHeaderStates.NO_STATUS); + expect(cellNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + + const headerCellNode = $createTableCellNode(TableCellHeaderStates.ROW); + expect(headerCellNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + + const colSpan = 2; + const cellWithRowSpanNode = $createTableCellNode( + TableCellHeaderStates.NO_STATUS, + colSpan, + ); + expect(cellWithRowSpanNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + + const cellWidth = 200; + const cellWithCustomWidthNode = $createTableCellNode( + TableCellHeaderStates.NO_STATUS, + undefined, + cellWidth, + ); + expect(cellWithCustomWidthNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx new file mode 100644 index 000000000..b11b99490 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx @@ -0,0 +1,351 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; +import { + $createTableNode, + $createTableNodeWithDimensions, + $createTableSelection, +} from '@lexical/table'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $selectAll, + $setSelection, + CUT_COMMAND, + ParagraphNode, +} from 'lexical'; +import { + DataTransferMock, + initializeUnitTest, + invariant, +} from 'lexical/src/__tests__/utils'; + +import {$getElementForTableNode, TableNode} from '../../LexicalTableNode'; + +export class ClipboardDataMock { + getData: jest.Mock; + setData: jest.Mock; + + constructor() { + this.getData = jest.fn(); + this.setData = jest.fn(); + } +} + +export class ClipboardEventMock extends Event { + clipboardData: ClipboardDataMock; + + constructor(type: string, options?: EventInit) { + super(type, options); + this.clipboardData = new ClipboardDataMock(); + } +} + +global.document.execCommand = function execCommandMock( + commandId: string, + showUI?: boolean, + value?: string, +): boolean { + return true; +}; +Object.defineProperty(window, 'ClipboardEvent', { + value: new ClipboardEventMock('cut'), +}); + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + table: 'test-table-class', + }, +}); + +describe('LexicalTableNode tests', () => { + initializeUnitTest( + (testEnv) => { + beforeEach(async () => { + const {editor} = testEnv; + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.select(); + }); + }); + + test('TableNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const tableNode = $createTableNode(); + + expect(tableNode).not.toBe(null); + }); + + expect(() => $createTableNode()).toThrow(); + }); + + test('TableNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const tableNode = $createTableNode(); + + expect(tableNode.createDOM(editorConfig).outerHTML).toBe( + `
                                          `, + ); + }); + }); + + test('Copy table from an external source', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '

                                          Hello there

                                          General Kenobi!

                                          Lexical is nice


                                          ', + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + // Make sure paragraph is inserted inside empty cells + const emptyCell = '


                                          '; + expect(testEnv.innerHTML).toBe( + `${emptyCell}

                                          Hello there

                                          General Kenobi!

                                          Lexical is nice

                                          `, + ); + }); + + test('Copy table from an external source like gdoc with formatting', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '
                                          SurfaceMWP_WORK_LS_COMPOSER77349
                                          LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
                                          ', + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expect(testEnv.innerHTML).toBe( + `

                                          Surface

                                          MWP_WORK_LS_COMPOSER

                                          77349

                                          Lexical

                                          XDS_RICH_TEXT_AREA

                                          sdvd sdfvsfs

                                          `, + ); + }); + + test('Cut table in the middle of a range selection', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + const beforeText = $createTextNode('text before the table'); + const table = $createTableNodeWithDimensions(4, 4, true); + const afterText = $createTextNode('text after the table'); + + paragraph?.append(beforeText); + paragraph?.append(table); + paragraph?.append(afterText); + }); + await editor.update(() => { + editor.focus(); + $selectAll(); + }); + await editor.update(() => { + editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); + }); + + expect(testEnv.innerHTML).toBe(`


                                          `); + }); + + test('Cut table as last node in range selection ', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + const beforeText = $createTextNode('text before the table'); + const table = $createTableNodeWithDimensions(4, 4, true); + + paragraph?.append(beforeText); + paragraph?.append(table); + }); + await editor.update(() => { + editor.focus(); + $selectAll(); + }); + await editor.update(() => { + editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); + }); + + expect(testEnv.innerHTML).toBe(`


                                          `); + }); + + test('Cut table as first node in range selection ', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const paragraph = root.getFirstChild(); + const table = $createTableNodeWithDimensions(4, 4, true); + const afterText = $createTextNode('text after the table'); + + paragraph?.append(table); + paragraph?.append(afterText); + }); + await editor.update(() => { + editor.focus(); + $selectAll(); + }); + await editor.update(() => { + editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); + }); + + expect(testEnv.innerHTML).toBe(`


                                          `); + }); + + test('Cut table is whole selection, should remove it', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + const DOMTable = $getElementForTableNode(editor, table); + if (DOMTable) { + table + ?.getCellNodeFromCords(0, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('some text')); + const selection = $createTableSelection(); + selection.set( + table.__key, + table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table?.getCellNodeFromCords(3, 3, DOMTable)?.__key || '', + ); + $setSelection(selection); + editor.dispatchCommand(CUT_COMMAND, { + preventDefault: () => {}, + stopPropagation: () => {}, + } as ClipboardEvent); + } + } + }); + + expect(testEnv.innerHTML).toBe(`


                                          `); + }); + + test('Cut subsection of table cells, should just clear contents', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + const DOMTable = $getElementForTableNode(editor, table); + if (DOMTable) { + table + ?.getCellNodeFromCords(0, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('some text')); + const selection = $createTableSelection(); + selection.set( + table.__key, + table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table?.getCellNodeFromCords(2, 2, DOMTable)?.__key || '', + ); + $setSelection(selection); + editor.dispatchCommand(CUT_COMMAND, { + preventDefault: () => {}, + stopPropagation: () => {}, + } as ClipboardEvent); + } + } + }); + + expect(testEnv.innerHTML).toBe( + `


















                                          `, + ); + }); + + test('Table plain text output validation', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 4, true); + root.append(table); + }); + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + if (table) { + const DOMTable = $getElementForTableNode(editor, table); + if (DOMTable) { + table + ?.getCellNodeFromCords(0, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('1')); + table + ?.getCellNodeFromCords(1, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('')); + table + ?.getCellNodeFromCords(2, 0, DOMTable) + ?.getLastChild() + ?.append($createTextNode('2')); + table + ?.getCellNodeFromCords(0, 1, DOMTable) + ?.getLastChild() + ?.append($createTextNode('3')); + table + ?.getCellNodeFromCords(1, 1, DOMTable) + ?.getLastChild() + ?.append($createTextNode('4')); + table + ?.getCellNodeFromCords(2, 1, DOMTable) + ?.getLastChild() + ?.append($createTextNode('')); + const selection = $createTableSelection(); + selection.set( + table.__key, + table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table?.getCellNodeFromCords(2, 1, DOMTable)?.__key || '', + ); + expect(selection.getTextContent()).toBe(`1\t\t2\n3\t4\t\n`); + } + } + }); + }); + }, + undefined, + , + ); +}); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts new file mode 100644 index 000000000..cf110634b --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createTableRowNode} from '@lexical/table'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + tableRow: 'test-table-row-class', + }, +}); + +describe('LexicalTableRowNode tests', () => { + initializeUnitTest((testEnv) => { + test('TableRowNode.constructor', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const rowNode = $createTableRowNode(); + + expect(rowNode).not.toBe(null); + }); + + expect(() => $createTableRowNode()).toThrow(); + }); + + test('TableRowNode.createDOM()', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const rowNode = $createTableRowNode(); + expect(rowNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + + const rowHeight = 36; + const rowWithCustomHeightNode = $createTableRowNode(36); + expect(rowWithCustomHeightNode.createDOM(editorConfig).outerHTML).toBe( + ``, + ); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx new file mode 100644 index 000000000..5eb631c31 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx @@ -0,0 +1,176 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createTableSelection} from '@lexical/table'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, + EditorState, + type LexicalEditor, + ParagraphNode, + RootNode, + TextNode, +} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; +import {createRef, useEffect, useMemo} from 'react'; +import {createRoot, Root} from 'react-dom/client'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +describe('table selection', () => { + let originalText: TextNode; + let parsedParagraph: ParagraphNode; + let parsedRoot: RootNode; + let parsedText: TextNode; + let paragraphKey: string; + let textKey: string; + let parsedEditorState: EditorState; + let reactRoot: Root; + let container: HTMLDivElement | null = null; + let editor: LexicalEditor | null = null; + + beforeEach(() => { + container = document.createElement('div'); + reactRoot = createRoot(container); + document.body.appendChild(container); + }); + + function useLexicalEditor( + rootElementRef: React.RefObject, + onError?: () => void, + ) { + const editorInHook = useMemo( + () => + createTestEditor({ + nodes: [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }), + [onError], + ); + + useEffect(() => { + const rootElement = rootElementRef.current; + + editorInHook.setRootElement(rootElement); + }, [rootElementRef, editorInHook]); + + return editorInHook; + } + + function init(onError?: () => void) { + const ref = createRef(); + + function TestBase() { + editor = useLexicalEditor(ref, onError); + + return
                                          ; + } + + ReactTestUtils.act(() => { + reactRoot.render(); + }); + } + + async function update(fn: () => void) { + editor!.update(fn); + + return Promise.resolve().then(); + } + + beforeEach(async () => { + init(); + + await update(() => { + const paragraph = $createParagraphNode(); + originalText = $createTextNode('Hello world'); + const selection = $createTableSelection(); + selection.set( + originalText.getKey(), + originalText.getKey(), + originalText.getKey(), + ); + $setSelection(selection); + paragraph.append(originalText); + $getRoot().append(paragraph); + }); + + const stringifiedEditorState = JSON.stringify( + editor!.getEditorState().toJSON(), + ); + + parsedEditorState = editor!.parseEditorState(stringifiedEditorState); + parsedEditorState.read(() => { + parsedRoot = $getRoot(); + parsedParagraph = parsedRoot.getFirstChild()!; + paragraphKey = parsedParagraph.getKey(); + parsedText = parsedParagraph.getFirstChild()!; + textKey = parsedText.getKey(); + }); + }); + + it('Parses the nodes of a stringified editor state', async () => { + expect(parsedRoot).toEqual({ + __cachedText: null, + __dir: 'ltr', + __first: paragraphKey, + __format: 0, + __indent: 0, + __key: 'root', + __last: paragraphKey, + __next: null, + __parent: null, + __prev: null, + __size: 1, + __style: '', + __type: 'root', + }); + expect(parsedParagraph).toEqual({ + __dir: 'ltr', + __first: textKey, + __format: 0, + __indent: 0, + __key: paragraphKey, + __last: textKey, + __next: null, + __parent: 'root', + __prev: null, + __size: 1, + __style: '', + __textFormat: 0, + __textStyle: '', + __type: 'paragraph', + }); + expect(parsedText).toEqual({ + __detail: 0, + __format: 0, + __key: textKey, + __mode: 0, + __next: null, + __parent: paragraphKey, + __prev: null, + __style: '', + __text: 'Hello world', + __type: 'text', + }); + }); + + it('Parses the text content of the editor state', async () => { + expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(null); + expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe( + 'Hello world', + ); + }); +}); diff --git a/resources/js/wysiwyg/lexical/table/constants.ts b/resources/js/wysiwyg/lexical/table/constants.ts new file mode 100644 index 000000000..ffa6ba1c3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/constants.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/; + +// .PlaygroundEditorTheme__tableCell width value from +// packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +export const COLUMN_WIDTH = 75; diff --git a/resources/js/wysiwyg/lexical/table/index.ts b/resources/js/wysiwyg/lexical/table/index.ts new file mode 100644 index 000000000..2429eb608 --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/index.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export type {SerializedTableCellNode} from './LexicalTableCellNode'; +export { + $createTableCellNode, + $isTableCellNode, + TableCellHeaderStates, + TableCellNode, +} from './LexicalTableCellNode'; +export type { + InsertTableCommandPayload, + InsertTableCommandPayloadHeaders, +} from './LexicalTableCommands'; +export {INSERT_TABLE_COMMAND} from './LexicalTableCommands'; +export type {SerializedTableNode} from './LexicalTableNode'; +export { + $createTableNode, + $getElementForTableNode, + $isTableNode, + TableNode, +} from './LexicalTableNode'; +export type {TableDOMCell} from './LexicalTableObserver'; +export {TableObserver} from './LexicalTableObserver'; +export type {SerializedTableRowNode} from './LexicalTableRowNode'; +export { + $createTableRowNode, + $isTableRowNode, + TableRowNode, +} from './LexicalTableRowNode'; +export type { + TableMapType, + TableMapValueType, + TableSelection, + TableSelectionShape, +} from './LexicalTableSelection'; +export { + $createTableSelection, + $isTableSelection, +} from './LexicalTableSelection'; +export type {HTMLTableElementWithWithTableSelectionState} from './LexicalTableSelectionHelpers'; +export { + $findCellNode, + $findTableNode, + applyTableHandlers, + getDOMCellFromTarget, + getTableObserverFromTableElement, +} from './LexicalTableSelectionHelpers'; +export { + $computeTableMap, + $computeTableMapSkipCellCheck, + $createTableNodeWithDimensions, + $deleteTableColumn, + $deleteTableColumn__EXPERIMENTAL, + $deleteTableRow__EXPERIMENTAL, + $getNodeTriplet, + $getTableCellNodeFromLexicalNode, + $getTableCellNodeRect, + $getTableColumnIndexFromTableCellNode, + $getTableNodeFromLexicalNodeOrThrow, + $getTableRowIndexFromTableCellNode, + $getTableRowNodeFromTableCellNodeOrThrow, + $insertTableColumn, + $insertTableColumn__EXPERIMENTAL, + $insertTableRow, + $insertTableRow__EXPERIMENTAL, + $removeTableRowAtIndex, + $unmergeCell, +} from './LexicalTableUtils'; diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts new file mode 100644 index 000000000..0bca8a9ea --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalElementHelpers.test.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + addClassNamesToElement, + removeClassNamesFromElement, +} from '@lexical/utils'; + +describe('LexicalElementHelpers tests', () => { + describe('addClassNamesToElement() and removeClassNamesFromElement()', () => { + test('basic', async () => { + const element = document.createElement('div'); + addClassNamesToElement(element, 'test-class'); + + expect(element.className).toEqual('test-class'); + + removeClassNamesFromElement(element, 'test-class'); + + expect(element.className).toEqual(''); + }); + + test('empty', async () => { + const element = document.createElement('div'); + addClassNamesToElement( + element, + null, + undefined, + false, + true, + '', + ' ', + ' \t\n', + ); + + expect(element.className).toEqual(''); + }); + + test('multiple', async () => { + const element = document.createElement('div'); + addClassNamesToElement(element, 'a', 'b', 'c'); + + expect(element.className).toEqual('a b c'); + + removeClassNamesFromElement(element, 'a', 'b', 'c'); + + expect(element.className).toEqual(''); + }); + + test('space separated', async () => { + const element = document.createElement('div'); + addClassNamesToElement(element, 'a b c'); + + expect(element.className).toEqual('a b c'); + + removeClassNamesFromElement(element, 'a b c'); + + expect(element.className).toEqual(''); + }); + }); + + test('multiple spaces', async () => { + const classNames = ' a b c \t\n '; + const element = document.createElement('div'); + addClassNamesToElement(element, classNames); + + expect(element.className).toEqual('a b c'); + + removeClassNamesFromElement(element, classNames); + + expect(element.className).toEqual(''); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx new file mode 100644 index 000000000..2b49e3bd7 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx @@ -0,0 +1,747 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {CodeHighlightNode, CodeNode} from '@lexical/code'; +import {HashtagNode} from '@lexical/hashtag'; +import {AutoLinkNode, LinkNode} from '@lexical/link'; +import {ListItemNode, ListNode} from '@lexical/list'; +import {OverflowNode} from '@lexical/overflow'; +import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import { + applySelectionInputs, + pasteHTML, +} from '@lexical/selection/src/__tests__/utils'; +import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; +import {LexicalEditor} from 'lexical'; +import {initializeClipboard, TestComposer} from 'lexical/src/__tests__/utils'; +import {createRoot} from 'react-dom/client'; +import * as ReactTestUtils from 'lexical/shared/react-test-utils'; + +jest.mock('lexical/shared/environment', () => { + const originalModule = jest.requireActual('lexical/shared/environment'); + return {...originalModule, IS_FIREFOX: true}; +}); + +Range.prototype.getBoundingClientRect = function (): DOMRect { + const rect = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + return { + ...rect, + toJSON() { + return rect; + }, + }; +}; + +initializeClipboard(); + +Range.prototype.getBoundingClientRect = function (): DOMRect { + const rect = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + }; + return { + ...rect, + toJSON() { + return rect; + }, + }; +}; + +describe('LexicalEventHelpers', () => { + let container: HTMLDivElement | null = null; + + beforeEach(async () => { + container = document.createElement('div'); + document.body.appendChild(container); + await init(); + }); + + afterEach(() => { + document.body.removeChild(container!); + container = null; + }); + + let editor: LexicalEditor | null = null; + + async function init() { + function TestBase() { + function TestPlugin(): null { + [editor] = useLexicalComposerContext(); + + return null; + } + + return ( + + + } + placeholder={null} + ErrorBoundary={LexicalErrorBoundary} + /> + + + + ); + } + + ReactTestUtils.act(() => { + createRoot(container!).render(); + }); + } + + async function update(fn: () => void) { + await ReactTestUtils.act(async () => { + await editor!.update(fn); + }); + + return Promise.resolve().then(); + } + + test('Expect initial output to be a block with no text', () => { + expect(container!.innerHTML).toBe( + '


                                          ', + ); + }); + + describe('onPasteForRichText', () => { + describe('baseline', () => { + const suite = [ + { + expectedHTML: + '

                                          Hello

                                          ', + inputs: [pasteHTML(`

                                          Hello

                                          `)], + name: 'should produce the correct editor state from a pasted HTML h1 element', + }, + { + expectedHTML: + '

                                          From

                                          ', + inputs: [pasteHTML(`

                                          From

                                          `)], + name: 'should produce the correct editor state from a pasted HTML h2 element', + }, + { + expectedHTML: + '

                                          The

                                          ', + inputs: [pasteHTML(`

                                          The

                                          `)], + name: 'should produce the correct editor state from a pasted HTML h3 element', + }, + { + expectedHTML: + '
                                          • Other side
                                          • I must have called
                                          ', + inputs: [ + pasteHTML( + `
                                          • Other side
                                          • I must have called
                                          `, + ), + ], + name: 'should produce the correct editor state from a pasted HTML ul element', + }, + { + expectedHTML: + '
                                          1. To tell you
                                          2. I’m sorry
                                          ', + inputs: [ + pasteHTML( + `
                                          1. To tell you
                                          2. I’m sorry
                                          `, + ), + ], + name: 'should produce the correct editor state from pasted HTML ol element', + }, + { + expectedHTML: + '

                                          A thousand times

                                          ', + inputs: [pasteHTML(`A thousand times`)], + name: 'should produce the correct editor state from pasted DOM Text Node', + }, + { + expectedHTML: + '

                                          Bold

                                          ', + inputs: [pasteHTML(`Bold`)], + name: 'should produce the correct editor state from a pasted HTML b element', + }, + { + expectedHTML: + '

                                          Italic

                                          ', + inputs: [pasteHTML(`Italic`)], + name: 'should produce the correct editor state from a pasted HTML i element', + }, + { + expectedHTML: + '

                                          Italic

                                          ', + inputs: [pasteHTML(`Italic`)], + name: 'should produce the correct editor state from a pasted HTML em element', + }, + { + expectedHTML: + '

                                          Underline

                                          ', + inputs: [pasteHTML(`Underline`)], + name: 'should produce the correct editor state from a pasted HTML u element', + }, + { + expectedHTML: + '

                                          Lyrics to Hello by Adele

                                          A thousand times

                                          ', + inputs: [ + pasteHTML( + `

                                          Lyrics to Hello by Adele

                                          A thousand times`, + ), + ], + name: 'should produce the correct editor state from pasted heading node followed by a DOM Text Node', + }, + { + expectedHTML: + '', + inputs: [ + pasteHTML( + `Facebook`, + ), + ], + name: 'should produce the correct editor state from a pasted HTML anchor element', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!`, + ), + ], + name: 'should produce the correct editor state from a pasted combination of an HTML text node followed by an anchor node', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + name: 'should produce the correct editor state from a pasted combination of HTML anchor elements and text nodes', + }, + { + expectedHTML: + '
                                          • Hello
                                          • from the other
                                          • side
                                          ', + inputs: [ + pasteHTML( + `
                                          • Hello
                                          • from the other
                                          • side
                                          `, + ), + ], + name: 'should ignore DOM node types that do not have transformers, but still process their children.', + }, + { + expectedHTML: + '
                                          • Hello
                                          • from the other
                                          • side
                                          ', + inputs: [ + pasteHTML( + `
                                          • Hello
                                          • from the other
                                          • side
                                          `, + ), + ], + name: 'should ignore multiple levels of DOM node types that do not have transformers, but still process their children.', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + name: 'should preserve formatting from HTML tags on deeply nested text nodes.', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + name: 'should preserve formatting from HTML tags on deeply nested and top level text nodes.', + }, + { + expectedHTML: + '

                                          Welcome toFacebook!We hope you like it here.

                                          ', + inputs: [ + pasteHTML( + `Welcome toFacebook!We hope you like it here.`, + ), + ], + name: 'should preserve multiple types of formatting on deeply nested text nodes and top level text nodes', + }, + ]; + + suite.forEach((testUnit, i) => { + const name = testUnit.name || 'Test case'; + + test(name + ` (#${i + 1})`, async () => { + await applySelectionInputs(testUnit.inputs, update, editor!); + + // Validate HTML matches + expect(container!.innerHTML).toBe(testUnit.expectedHTML); + }); + }); + }); + + describe('Google Docs', () => { + const suite = [ + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from Normal text', + }, + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from bold text', + }, + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from italic text', + }, + { + expectedHTML: + '

                                          Get schwifty!

                                          ', + inputs: [ + pasteHTML( + `Get schwifty!`, + ), + ], + name: 'should produce the correct editor state from strikethrough text', + }, + ]; + + suite.forEach((testUnit, i) => { + const name = testUnit.name || 'Test case'; + + test(name + ` (#${i + 1})`, async () => { + await applySelectionInputs(testUnit.inputs, update, editor!); + + // Validate HTML matches + expect(container!.innerHTML).toBe(testUnit.expectedHTML); + }); + }); + }); + + describe('W3 spacing', () => { + const suite = [ + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [pasteHTML('hello world')], + name: 'inline hello world', + }, + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [pasteHTML(' hello world ')], + name: 'inline hello world (2)', + }, + { + // MS Office got it right + expectedHTML: + '

                                          hello world

                                          ', + inputs: [ + pasteHTML(' hello world '), + ], + name: 'pre + inline (inline collapses with pre)', + }, + { + expectedHTML: + '

                                          a b\tc

                                          ', + inputs: [pasteHTML('

                                          a b\tc

                                          ')], + name: 'white-space: pre (1) (no touchy)', + }, + { + expectedHTML: + '

                                          a b c

                                          ', + inputs: [pasteHTML('

                                          \ta\tb c\t\t

                                          ')], + name: 'tabs are collapsed', + }, + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [ + pasteHTML(` +
                                          + hello + world +
                                          + `), + ], + name: 'remove beginning + end spaces on the block', + }, + { + expectedHTML: + '

                                          hello world

                                          ', + inputs: [ + pasteHTML(` +
                                          + + hello + world + +
                                          + `), + ], + name: 'remove beginning + end spaces on the block (2)', + }, + { + expectedHTML: + '

                                          a b c

                                          ', + inputs: [ + pasteHTML(` +
                                          + a + b + c +
                                          + `), + ], + name: 'remove beginning + end spaces on the block + anonymous inlines collapsible rules', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (1)', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (2)', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (3)', + }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [pasteHTML('
                                          a b
                                          ')], + name: 'collapsibles and neighbors (4)', + }, + { + expectedHTML: '


                                          ', + inputs: [ + pasteHTML(` +

                                          +

                                          + `), + ], + name: 'empty block', + }, + { + expectedHTML: + '

                                          a

                                          ', + inputs: [pasteHTML(' a')], + name: 'redundant inline at start', + }, + { + expectedHTML: + '

                                          a

                                          ', + inputs: [pasteHTML('a ')], + name: 'redundant inline at end', + }, + { + expectedHTML: + '

                                          a

                                          b

                                          ', + inputs: [ + pasteHTML(` +
                                          +

                                          + a +

                                          +

                                          + b +

                                          +
                                          + `), + ], + name: 'collapsible spaces with nested structures', + }, + // TODO no proper support for divs #4465 + // { + // expectedHTML: + // '

                                          a

                                          b

                                          ', + // inputs: [ + // pasteHTML(` + //
                                          + //
                                          + // a + //
                                          + //
                                          + // b + //
                                          + //
                                          + // `), + // ], + // name: 'collapsible spaces with nested structures (2)', + // }, + { + expectedHTML: + '

                                          a b

                                          ', + inputs: [ + pasteHTML(` +
                                          + + a + + + b + +
                                          + `), + ], + name: 'collapsible spaces with nested structures (3)', + }, + { + expectedHTML: + '

                                          a
                                          b

                                          ', + inputs: [ + pasteHTML(` +

                                          + a +
                                          + b +

                                          + `), + ], + name: 'forced line break should remain', + }, + { + expectedHTML: + '

                                          a
                                          b

                                          ', + inputs: [ + pasteHTML(` +

                                          + a + \t
                                          \t + b +

                                          + `), + ], + name: 'forced line break with tabs', + }, + // The 3 below are not correct, they're missing the first \n ->
                                          but that's a fault with + // the implementation of DOMParser, it works correctly in Safari + { + expectedHTML: + 'a
                                          b

                                          ', + inputs: [pasteHTML(`
                                          \na\r\nb\r\n
                                          `)], + name: 'pre (no touchy) (1)', + }, + { + expectedHTML: + 'a
                                          b

                                          ', + inputs: [ + pasteHTML(` +
                                          \na\r\nb\r\n
                                          + `), + ], + name: 'pre (no touchy) (2)', + }, + { + expectedHTML: + '


                                          a
                                          b

                                          ', + inputs: [ + pasteHTML(`\na\r\nb\r\n`), + ], + name: 'white-space: pre (no touchy) (2)', + }, + { + expectedHTML: + '

                                          paragraph1

                                          paragraph2

                                          ', + inputs: [ + pasteHTML( + '\n

                                          paragraph1

                                          \n

                                          paragraph2

                                          \n', + ), + ], + name: 'two Apple Notes paragraphs', + }, + { + expectedHTML: + '

                                          line 1
                                          line 2


                                          paragraph 1

                                          paragraph 2

                                          ', + inputs: [ + pasteHTML( + '\n

                                          line 1
                                          \nline 2

                                          \n


                                          \n

                                          paragraph 1

                                          \n

                                          paragraph 2

                                          \n', + ), + ], + name: 'two Apple Notes lines + two paragraphs separated by an empty paragraph', + }, + { + expectedHTML: + '

                                          line 1
                                          line 2


                                          paragraph 1

                                          paragraph 2

                                          ', + inputs: [ + pasteHTML( + '\n

                                          line 1
                                          \nline 2

                                          \n

                                          \n
                                          \n

                                          \n

                                          paragraph 1

                                          \n

                                          paragraph 2

                                          \n', + ), + ], + name: 'two lines + two paragraphs separated by an empty paragraph (2)', + }, + { + expectedHTML: + '

                                          line 1
                                          line 2

                                          ', + inputs: [ + pasteHTML( + '

                                          line 1
                                          line 2

                                          ', + ), + ], + name: 'two lines and br in spans', + }, + { + expectedHTML: + '
                                          1. 1
                                            2

                                          2. 3
                                          ', + inputs: [ + pasteHTML('
                                          1. 1
                                            2
                                          2. 3
                                          '), + ], + name: 'empty block node in li behaves like a line break', + }, + { + expectedHTML: + '

                                          1
                                          2

                                          ', + inputs: [pasteHTML('
                                          1
                                          2
                                          ')], + name: 'empty block node in div behaves like a line break', + }, + { + expectedHTML: + '

                                          12

                                          ', + inputs: [pasteHTML('
                                          12
                                          ')], + name: 'empty inline node does not behave like a line break', + }, + { + expectedHTML: + '

                                          1

                                          2

                                          ', + inputs: [pasteHTML('
                                          1
                                          2
                                          ')], + name: 'empty block node between non inline siblings does not behave like a line break', + }, + { + expectedHTML: + '

                                          a

                                          b b

                                          c

                                          z

                                          d e

                                          fg

                                          ', + inputs: [ + pasteHTML( + `
                                          a
                                          b b
                                          c
                                          z
                                          d e
                                          fg
                                          `, + ), + ], + name: 'nested divs', + }, + { + expectedHTML: + '
                                          1. 1

                                          2. 3
                                          ', + inputs: [pasteHTML('
                                          1. 1

                                          2. 3
                                          ')], + name: 'only br in a li', + }, + { + expectedHTML: + '

                                          1

                                          2

                                          3

                                          ', + inputs: [pasteHTML('1

                                          2

                                          3')], + name: 'last br in a block node is ignored', + }, + ]; + + suite.forEach((testUnit, i) => { + const name = testUnit.name || 'Test case'; + + // eslint-disable-next-line no-only-tests/no-only-tests, dot-notation + const test_ = 'only' in testUnit && testUnit['only'] ? test.only : test; + test_(name + ` (#${i + 1})`, async () => { + await applySelectionInputs(testUnit.inputs, update, editor!); + + // Validate HTML matches + expect((container!.firstChild as HTMLElement).innerHTML).toBe( + testUnit.expectedHTML, + ); + }); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts new file mode 100644 index 000000000..82d2dddf8 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts @@ -0,0 +1,236 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getNodeByKey, + $getRoot, + $isElementNode, + LexicalEditor, + NodeKey, +} from 'lexical'; +import { + $createTestElementNode, + initializeUnitTest, + invariant, +} from 'lexical/src/__tests__/utils'; + +import {$dfs} from '../..'; + +describe('LexicalNodeHelpers tests', () => { + initializeUnitTest((testEnv) => { + /** + * R + * P1 P2 + * B1 B2 T4 T5 B3 + * T1 T2 T3 T6 + * + * DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6 + */ + test('DFS node order', async () => { + const editor: LexicalEditor = testEnv.editor; + + let expectedKeys: Array<{ + depth: number; + node: NodeKey; + }> = []; + + await editor.update(() => { + const root = $getRoot(); + + const paragraph1 = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + + const block1 = $createTestElementNode(); + const block2 = $createTestElementNode(); + const block3 = $createTestElementNode(); + + const text1 = $createTextNode('text1'); + const text2 = $createTextNode('text2'); + const text3 = $createTextNode('text3'); + const text4 = $createTextNode('text4'); + const text5 = $createTextNode('text5'); + const text6 = $createTextNode('text6'); + + root.append(paragraph1, paragraph2); + paragraph1.append(block1, block2); + paragraph2.append(text4, text5); + + text5.toggleFormat('bold'); // Prevent from merging with text 4 + + paragraph2.append(block3); + block1.append(text1); + block2.append(text2, text3); + + text3.toggleFormat('bold'); // Prevent from merging with text2 + + block3.append(text6); + + expectedKeys = [ + { + depth: 0, + node: root.getKey(), + }, + { + depth: 1, + node: paragraph1.getKey(), + }, + { + depth: 2, + node: block1.getKey(), + }, + { + depth: 3, + node: text1.getKey(), + }, + { + depth: 2, + node: block2.getKey(), + }, + { + depth: 3, + node: text2.getKey(), + }, + { + depth: 3, + node: text3.getKey(), + }, + { + depth: 1, + node: paragraph2.getKey(), + }, + { + depth: 2, + node: text4.getKey(), + }, + { + depth: 2, + node: text5.getKey(), + }, + { + depth: 2, + node: block3.getKey(), + }, + { + depth: 3, + node: text6.getKey(), + }, + ]; + }); + + editor.getEditorState().read(() => { + const expectedNodes = expectedKeys.map(({depth, node: nodeKey}) => ({ + depth, + node: $getNodeByKey(nodeKey)!.getLatest(), + })); + + const first = expectedNodes[0]; + const second = expectedNodes[1]; + const last = expectedNodes[expectedNodes.length - 1]; + const secondToLast = expectedNodes[expectedNodes.length - 2]; + + expect($dfs(first.node, last.node)).toEqual(expectedNodes); + expect($dfs(second.node, secondToLast.node)).toEqual( + expectedNodes.slice(1, expectedNodes.length - 1), + ); + expect($dfs()).toEqual(expectedNodes); + expect($dfs($getRoot())).toEqual(expectedNodes); + }); + }); + + test('DFS triggers getLatest()', async () => { + const editor: LexicalEditor = testEnv.editor; + + let rootKey: string; + let paragraphKey: string; + let block1Key: string; + let block2Key: string; + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const block1 = $createTestElementNode(); + const block2 = $createTestElementNode(); + + rootKey = root.getKey(); + paragraphKey = paragraph.getKey(); + block1Key = block1.getKey(); + block2Key = block2.getKey(); + + root.append(paragraph); + paragraph.append(block1, block2); + }); + + await editor.update(() => { + const root = $getNodeByKey(rootKey); + const paragraph = $getNodeByKey(paragraphKey); + const block1 = $getNodeByKey(block1Key); + const block2 = $getNodeByKey(block2Key); + + const block3 = $createTestElementNode(); + invariant($isElementNode(block1)); + + block1.append(block3); + + expect($dfs(root!)).toEqual([ + { + depth: 0, + node: root!.getLatest(), + }, + { + depth: 1, + node: paragraph!.getLatest(), + }, + { + depth: 2, + node: block1.getLatest(), + }, + { + depth: 3, + node: block3.getLatest(), + }, + { + depth: 2, + node: block2!.getLatest(), + }, + ]); + }); + }); + + test('DFS of empty ParagraphNode returns only itself', async () => { + const editor: LexicalEditor = testEnv.editor; + + let paragraphKey: string; + + await editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const paragraph2 = $createParagraphNode(); + const text = $createTextNode('test'); + + paragraphKey = paragraph.getKey(); + + paragraph2.append(text); + root.append(paragraph, paragraph2); + }); + await editor.update(() => { + const paragraph = $getNodeByKey(paragraphKey)!; + + expect($dfs(paragraph ?? undefined)).toEqual([ + { + depth: 1, + node: paragraph?.getLatest(), + }, + ]); + }); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts new file mode 100644 index 000000000..070107583 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $isRootTextContentEmpty, + $isRootTextContentEmptyCurry, + $rootTextContent, +} from '@lexical/text'; +import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +describe('LexicalRootHelpers tests', () => { + initializeUnitTest((testEnv) => { + it('textContent', async () => { + const editor = testEnv.editor; + + expect(editor.getEditorState().read($rootTextContent)).toBe(''); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(text); + + expect($rootTextContent()).toBe('foo'); + }); + + expect(editor.getEditorState().read($rootTextContent)).toBe('foo'); + }); + + it('isBlank', async () => { + const editor = testEnv.editor; + + expect( + editor + .getEditorState() + .read($isRootTextContentEmptyCurry(editor.isComposing())), + ).toBe(true); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('foo'); + root.append(paragraph); + paragraph.append(text); + + expect($isRootTextContentEmpty(editor.isComposing())).toBe(false); + }); + + expect( + editor + .getEditorState() + .read($isRootTextContentEmptyCurry(editor.isComposing())), + ).toBe(false); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts new file mode 100644 index 000000000..b4b18ef01 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {objectKlassEquals} from '@lexical/utils'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; + +class MyEvent extends Event {} + +class MyEvent2 extends Event {} + +let MyEventShadow: typeof Event = MyEvent; + +{ + // eslint-disable-next-line no-shadow + class MyEvent extends Event {} + MyEventShadow = MyEvent; +} + +describe('LexicalUtilsKlassEqual tests', () => { + initializeUnitTest((testEnv) => { + it('objectKlassEquals', async () => { + const eventInstance = new MyEvent(''); + expect(eventInstance instanceof MyEvent).toBeTruthy(); + expect(objectKlassEquals(eventInstance, MyEvent)).toBeTruthy(); + expect(eventInstance instanceof MyEvent2).toBeFalsy(); + expect(objectKlassEquals(eventInstance, MyEvent2)).toBeFalsy(); + expect(eventInstance instanceof MyEventShadow).toBeFalsy(); + expect(objectKlassEquals(eventInstance, MyEventShadow)).toBeTruthy(); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx new file mode 100644 index 000000000..f3db39390 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {ElementNode, LexicalEditor} from 'lexical'; + +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import {$getRoot, $isElementNode} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; + +import {$splitNode} from '../../index'; + +describe('LexicalUtils#splitNode', () => { + let editor: LexicalEditor; + + const update = async (updateFn: () => void) => { + editor.update(updateFn); + await Promise.resolve(); + }; + + beforeEach(async () => { + editor = createTestEditor(); + editor._headless = true; + }); + + const testCases: Array<{ + _: string; + expectedHtml: string; + initialHtml: string; + splitPath: Array; + splitOffset: number; + only?: boolean; + }> = [ + { + _: 'split paragraph in between two text nodes', + expectedHtml: + '

                                          Hello

                                          world

                                          ', + initialHtml: '

                                          Helloworld

                                          ', + splitOffset: 1, + splitPath: [0], + }, + { + _: 'split paragraph before the first text node', + expectedHtml: + '


                                          Helloworld

                                          ', + initialHtml: '

                                          Helloworld

                                          ', + splitOffset: 0, + splitPath: [0], + }, + { + _: 'split paragraph after the last text node', + expectedHtml: + '

                                          Helloworld


                                          ', + initialHtml: '

                                          Helloworld

                                          ', + splitOffset: 2, // Any offset that is higher than children size + splitPath: [0], + }, + { + _: 'split list items between two text nodes', + expectedHtml: + '
                                          • Hello
                                          ' + + '
                                          • world
                                          ', + initialHtml: '
                                          • Helloworld
                                          ', + splitOffset: 1, // Any offset that is higher than children size + splitPath: [0, 0], + }, + { + _: 'split list items before the first text node', + expectedHtml: + '
                                          ' + + '
                                          • Helloworld
                                          ', + initialHtml: '
                                          • Helloworld
                                          ', + splitOffset: 0, // Any offset that is higher than children size + splitPath: [0, 0], + }, + { + _: 'split nested list items', + expectedHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Hello
                                          • ' + + '
                                          ' + + '
                                            ' + + '
                                            • world
                                          • ' + + '
                                          • After
                                          • ' + + '
                                          ', + initialHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Helloworld
                                            ' + + '
                                          • After
                                          • ' + + '
                                          ', + splitOffset: 1, // Any offset that is higher than children size + splitPath: [0, 1, 0, 0], + }, + ]; + + for (const testCase of testCases) { + it(testCase._, async () => { + await update(() => { + // Running init, update, assert in the same update loop + // to skip text nodes normalization (then separate text + // nodes will still be separate and represented by its own + // spans in html output) and make assertions more precise + const parser = new DOMParser(); + const dom = parser.parseFromString(testCase.initialHtml, 'text/html'); + const nodesToInsert = $generateNodesFromDOM(editor, dom); + $getRoot() + .clear() + .append(...nodesToInsert); + + let nodeToSplit: ElementNode = $getRoot(); + for (const index of testCase.splitPath) { + nodeToSplit = nodeToSplit.getChildAtIndex(index)!; + if (!$isElementNode(nodeToSplit)) { + throw new Error('Expected node to be element'); + } + } + + $splitNode(nodeToSplit, testCase.splitOffset); + + // Cleaning up list value attributes as it's not really needed in this test + // and it clutters expected output + const actualHtml = $generateHtmlFromNodes(editor).replace( + /\svalue="\d{1,}"/g, + '', + ); + expect(actualHtml).toEqual(testCase.expectedHtml); + }); + }); + } + + it('throws when splitting root', async () => { + await update(() => { + expect(() => $splitNode($getRoot(), 0)).toThrow(); + }); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx new file mode 100644 index 000000000..0e46573e7 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx @@ -0,0 +1,184 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor, LexicalNode} from 'lexical'; + +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import { + $createRangeSelection, + $getRoot, + $isElementNode, + $setSelection, +} from 'lexical'; +import { + $createTestDecoratorNode, + createTestEditor, +} from 'lexical/src/__tests__/utils'; + +import {$insertNodeToNearestRoot} from '../..'; + +describe('LexicalUtils#insertNodeToNearestRoot', () => { + let editor: LexicalEditor; + + const update = async (updateFn: () => void) => { + editor.update(updateFn); + await Promise.resolve(); + }; + + beforeEach(async () => { + editor = createTestEditor(); + editor._headless = true; + }); + + const testCases: Array<{ + _: string; + expectedHtml: string; + initialHtml: string; + selectionPath: Array; + selectionOffset: number; + only?: boolean; + }> = [ + { + _: 'insert into paragraph in between two text nodes', + expectedHtml: + '

                                          Hello

                                          world

                                          ', + initialHtml: '

                                          Helloworld

                                          ', + selectionOffset: 5, // Selection on text node after "Hello" world + selectionPath: [0, 0], + }, + { + _: 'insert into nested list items', + expectedHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Hello
                                          • ' + + '
                                          ' + + '' + + '
                                            ' + + '
                                            • world
                                          • ' + + '
                                          • After
                                          • ' + + '
                                          ', + initialHtml: + '
                                            ' + + '
                                          • Before
                                          • ' + + '
                                            • Helloworld
                                            ' + + '
                                          • After
                                          • ' + + '
                                          ', + selectionOffset: 5, // Selection on text node after "Hello" world + selectionPath: [0, 1, 0, 0, 0], + }, + { + _: 'insert into empty paragraph', + expectedHtml: '



                                          ', + initialHtml: '

                                          ', + selectionOffset: 0, // Selection on text node after "Hello" world + selectionPath: [0], + }, + { + _: 'insert in the end of paragraph', + expectedHtml: + '

                                          Hello world

                                          ' + + '' + + '


                                          ', + initialHtml: '

                                          Hello world

                                          ', + selectionOffset: 12, // Selection on text node after "Hello" world + selectionPath: [0, 0], + }, + { + _: 'insert in the beginning of paragraph', + expectedHtml: + '


                                          ' + + '' + + '

                                          Hello world

                                          ', + initialHtml: '

                                          Hello world

                                          ', + selectionOffset: 0, // Selection on text node after "Hello" world + selectionPath: [0, 0], + }, + { + _: 'insert with selection on root start', + expectedHtml: + '' + + '' + + '

                                          Before

                                          ' + + '

                                          After

                                          ', + initialHtml: + '' + + '

                                          Before

                                          ' + + '

                                          After

                                          ', + selectionOffset: 0, + selectionPath: [], + }, + { + _: 'insert with selection on root child', + expectedHtml: + '

                                          Before

                                          ' + + '' + + '

                                          After

                                          ', + initialHtml: '

                                          Before

                                          After

                                          ', + selectionOffset: 1, + selectionPath: [], + }, + { + _: 'insert with selection on root end', + expectedHtml: + '

                                          Before

                                          ' + + '', + initialHtml: '

                                          Before

                                          ', + selectionOffset: 1, + selectionPath: [], + }, + ]; + + for (const testCase of testCases) { + it(testCase._, async () => { + await update(() => { + // Running init, update, assert in the same update loop + // to skip text nodes normalization (then separate text + // nodes will still be separate and represented by its own + // spans in html output) and make assertions more precise + const parser = new DOMParser(); + const dom = parser.parseFromString(testCase.initialHtml, 'text/html'); + const nodesToInsert = $generateNodesFromDOM(editor, dom); + $getRoot() + .clear() + .append(...nodesToInsert); + + let selectionNode: LexicalNode = $getRoot(); + for (const index of testCase.selectionPath) { + if (!$isElementNode(selectionNode)) { + throw new Error( + 'Expected node to be element (to traverse the tree)', + ); + } + selectionNode = selectionNode.getChildAtIndex(index)!; + } + + // Calling selectionNode.select() would "normalize" selection and move it + // to text node (if available), while for the purpose of the test we'd want + // to use whatever was passed (e.g. keep selection on root node) + const selection = $createRangeSelection(); + const type = $isElementNode(selectionNode) ? 'element' : 'text'; + selection.anchor.key = selection.focus.key = selectionNode.getKey(); + selection.anchor.offset = selection.focus.offset = + testCase.selectionOffset; + selection.anchor.type = selection.focus.type = type; + $setSelection(selection); + + $insertNodeToNearestRoot($createTestDecoratorNode()); + + // Cleaning up list value attributes as it's not really needed in this test + // and it clutters expected output + const actualHtml = $generateHtmlFromNodes(editor).replace( + /\svalue="\d{1,}"/g, + '', + ); + expect(actualHtml).toEqual(testCase.expectedHtml); + }); + }); + } +}); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts new file mode 100644 index 000000000..01228f629 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/mergeRegister.test.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {mergeRegister} from '@lexical/utils'; + +describe('mergeRegister', () => { + it('calls all of the clean-up functions', () => { + const cleanup = jest.fn(); + mergeRegister(cleanup, cleanup)(); + expect(cleanup).toHaveBeenCalledTimes(2); + }); + it('calls the clean-up functions in reverse order', () => { + const cleanup = jest.fn(); + mergeRegister(cleanup.bind(null, 1), cleanup.bind(null, 2))(); + expect(cleanup.mock.calls.map(([v]) => v)).toEqual([2, 1]); + }); +}); diff --git a/resources/js/wysiwyg/lexical/utils/index.ts b/resources/js/wysiwyg/lexical/utils/index.ts new file mode 100644 index 000000000..7984126e3 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/index.ts @@ -0,0 +1,607 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $cloneWithProperties, + $createParagraphNode, + $getPreviousSelection, + $getRoot, + $getSelection, + $isElementNode, + $isRangeSelection, + $isRootOrShadowRoot, + $isTextNode, + $setSelection, + $splitNode, + EditorState, + ElementNode, + Klass, + LexicalEditor, + LexicalNode, +} from 'lexical'; +// This underscore postfixing is used as a hotfix so we do not +// export shared types from this module #5918 +import {CAN_USE_DOM as CAN_USE_DOM_} from 'lexical/shared/canUseDOM'; +import { + CAN_USE_BEFORE_INPUT as CAN_USE_BEFORE_INPUT_, + IS_ANDROID as IS_ANDROID_, + IS_ANDROID_CHROME as IS_ANDROID_CHROME_, + IS_APPLE as IS_APPLE_, + IS_APPLE_WEBKIT as IS_APPLE_WEBKIT_, + IS_CHROME as IS_CHROME_, + IS_FIREFOX as IS_FIREFOX_, + IS_IOS as IS_IOS_, + IS_SAFARI as IS_SAFARI_, +} from 'lexical/shared/environment'; +import invariant from 'lexical/shared/invariant'; +import normalizeClassNames from 'lexical/shared/normalizeClassNames'; + +export {default as markSelection} from './markSelection'; +export {default as mergeRegister} from './mergeRegister'; +export {default as positionNodeOnRange} from './positionNodeOnRange'; +export { + $splitNode, + isBlockDomNode, + isHTMLAnchorElement, + isHTMLElement, + isInlineDomNode, +} from 'lexical'; +// Hotfix to export these with inlined types #5918 +export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_; +export const CAN_USE_DOM: boolean = CAN_USE_DOM_; +export const IS_ANDROID: boolean = IS_ANDROID_; +export const IS_ANDROID_CHROME: boolean = IS_ANDROID_CHROME_; +export const IS_APPLE: boolean = IS_APPLE_; +export const IS_APPLE_WEBKIT: boolean = IS_APPLE_WEBKIT_; +export const IS_CHROME: boolean = IS_CHROME_; +export const IS_FIREFOX: boolean = IS_FIREFOX_; +export const IS_IOS: boolean = IS_IOS_; +export const IS_SAFARI: boolean = IS_SAFARI_; + +export type DFSNode = Readonly<{ + depth: number; + node: LexicalNode; +}>; + +/** + * Takes an HTML element and adds the classNames passed within an array, + * ignoring any non-string types. A space can be used to add multiple classes + * eg. addClassNamesToElement(element, ['element-inner active', true, null]) + * will add both 'element-inner' and 'active' as classes to that element. + * @param element - The element in which the classes are added + * @param classNames - An array defining the class names to add to the element + */ +export function addClassNamesToElement( + element: HTMLElement, + ...classNames: Array +): void { + const classesToAdd = normalizeClassNames(...classNames); + if (classesToAdd.length > 0) { + element.classList.add(...classesToAdd); + } +} + +/** + * Takes an HTML element and removes the classNames passed within an array, + * ignoring any non-string types. A space can be used to remove multiple classes + * eg. removeClassNamesFromElement(element, ['active small', true, null]) + * will remove both the 'active' and 'small' classes from that element. + * @param element - The element in which the classes are removed + * @param classNames - An array defining the class names to remove from the element + */ +export function removeClassNamesFromElement( + element: HTMLElement, + ...classNames: Array +): void { + const classesToRemove = normalizeClassNames(...classNames); + if (classesToRemove.length > 0) { + element.classList.remove(...classesToRemove); + } +} + +/** + * Returns true if the file type matches the types passed within the acceptableMimeTypes array, false otherwise. + * The types passed must be strings and are CASE-SENSITIVE. + * eg. if file is of type 'text' and acceptableMimeTypes = ['TEXT', 'IMAGE'] the function will return false. + * @param file - The file you want to type check. + * @param acceptableMimeTypes - An array of strings of types which the file is checked against. + * @returns true if the file is an acceptable mime type, false otherwise. + */ +export function isMimeType( + file: File, + acceptableMimeTypes: Array, +): boolean { + for (const acceptableType of acceptableMimeTypes) { + if (file.type.startsWith(acceptableType)) { + return true; + } + } + return false; +} + +/** + * Lexical File Reader with: + * 1. MIME type support + * 2. batched results (HistoryPlugin compatibility) + * 3. Order aware (respects the order when multiple Files are passed) + * + * const filesResult = await mediaFileReader(files, ['image/']); + * filesResult.forEach(file => editor.dispatchCommand('INSERT_IMAGE', \\{ + * src: file.result, + * \\})); + */ +export function mediaFileReader( + files: Array, + acceptableMimeTypes: Array, +): Promise> { + const filesIterator = files[Symbol.iterator](); + return new Promise((resolve, reject) => { + const processed: Array<{file: File; result: string}> = []; + const handleNextFile = () => { + const {done, value: file} = filesIterator.next(); + if (done) { + return resolve(processed); + } + const fileReader = new FileReader(); + fileReader.addEventListener('error', reject); + fileReader.addEventListener('load', () => { + const result = fileReader.result; + if (typeof result === 'string') { + processed.push({file, result}); + } + handleNextFile(); + }); + if (isMimeType(file, acceptableMimeTypes)) { + fileReader.readAsDataURL(file); + } else { + handleNextFile(); + } + }; + handleNextFile(); + }); +} + +/** + * "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end + * before backtracking and finding a new path. Consider solving a maze by hugging either wall, moving down a + * branch until you hit a dead-end (leaf) and backtracking to find the nearest branching path and repeat. + * It will then return all the nodes found in the search in an array of objects. + * @param startingNode - The node to start the search, if ommitted, it will start at the root node. + * @param endingNode - The node to end the search, if ommitted, it will find all descendants of the startingNode. + * @returns An array of objects of all the nodes found by the search, including their depth into the tree. + * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the ending node) so long as it exists + */ +export function $dfs( + startingNode?: LexicalNode, + endingNode?: LexicalNode, +): Array { + const nodes = []; + const start = (startingNode || $getRoot()).getLatest(); + const end = + endingNode || + ($isElementNode(start) ? start.getLastDescendant() || start : start); + let node: LexicalNode | null = start; + let depth = $getDepth(node); + + while (node !== null && !node.is(end)) { + nodes.push({depth, node}); + + if ($isElementNode(node) && node.getChildrenSize() > 0) { + node = node.getFirstChild(); + depth++; + } else { + // Find immediate sibling or nearest parent sibling + let sibling = null; + + while (sibling === null && node !== null) { + sibling = node.getNextSibling(); + + if (sibling === null) { + node = node.getParent(); + depth--; + } else { + node = sibling; + } + } + } + } + + if (node !== null && node.is(end)) { + nodes.push({depth, node}); + } + + return nodes; +} + +function $getDepth(node: LexicalNode): number { + let innerNode: LexicalNode | null = node; + let depth = 0; + + while ((innerNode = innerNode.getParent()) !== null) { + depth++; + } + + return depth; +} + +/** + * Performs a right-to-left preorder tree traversal. + * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path. + * It will return the next node in traversal sequence after the startingNode. + * The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right. + * @param startingNode - The node to start the search. + * @returns The next node in pre-order right to left traversal sequence or `null`, if the node does not exist + */ +export function $getNextRightPreorderNode( + startingNode: LexicalNode, +): LexicalNode | null { + let node: LexicalNode | null = startingNode; + + if ($isElementNode(node) && node.getChildrenSize() > 0) { + node = node.getLastChild(); + } else { + let sibling = null; + + while (sibling === null && node !== null) { + sibling = node.getPreviousSibling(); + + if (sibling === null) { + node = node.getParent(); + } else { + node = sibling; + } + } + } + return node; +} + +/** + * Takes a node and traverses up its ancestors (toward the root node) + * in order to find a specific type of node. + * @param node - the node to begin searching. + * @param klass - an instance of the type of node to look for. + * @returns the node of type klass that was passed, or null if none exist. + */ +export function $getNearestNodeOfType( + node: LexicalNode, + klass: Klass, +): T | null { + let parent: ElementNode | LexicalNode | null = node; + + while (parent != null) { + if (parent instanceof klass) { + return parent as T; + } + + parent = parent.getParent(); + } + + return null; +} + +/** + * Returns the element node of the nearest ancestor, otherwise throws an error. + * @param startNode - The starting node of the search + * @returns The ancestor node found + */ +export function $getNearestBlockElementAncestorOrThrow( + startNode: LexicalNode, +): ElementNode { + const blockNode = $findMatchingParent( + startNode, + (node) => $isElementNode(node) && !node.isInline(), + ); + if (!$isElementNode(blockNode)) { + invariant( + false, + 'Expected node %s to have closest block element node.', + startNode.__key, + ); + } + return blockNode; +} + +export type DOMNodeToLexicalConversion = (element: Node) => LexicalNode; + +export type DOMNodeToLexicalConversionMap = Record< + string, + DOMNodeToLexicalConversion +>; + +/** + * Starts with a node and moves up the tree (toward the root node) to find a matching node based on + * the search parameters of the findFn. (Consider JavaScripts' .find() function where a testing function must be + * passed as an argument. eg. if( (node) => node.__type === 'div') ) return true; otherwise return false + * @param startingNode - The node where the search starts. + * @param findFn - A testing function that returns true if the current node satisfies the testing parameters. + * @returns A parent node that matches the findFn parameters, or null if one wasn't found. + */ +export const $findMatchingParent: { + ( + startingNode: LexicalNode, + findFn: (node: LexicalNode) => node is T, + ): T | null; + ( + startingNode: LexicalNode, + findFn: (node: LexicalNode) => boolean, + ): LexicalNode | null; +} = ( + startingNode: LexicalNode, + findFn: (node: LexicalNode) => boolean, +): LexicalNode | null => { + let curr: ElementNode | LexicalNode | null = startingNode; + + while (curr !== $getRoot() && curr != null) { + if (findFn(curr)) { + return curr; + } + + curr = curr.getParent(); + } + + return null; +}; + +/** + * Attempts to resolve nested element nodes of the same type into a single node of that type. + * It is generally used for marks/commenting + * @param editor - The lexical editor + * @param targetNode - The target for the nested element to be extracted from. + * @param cloneNode - See {@link $createMarkNode} + * @param handleOverlap - Handles any overlap between the node to extract and the targetNode + * @returns The lexical editor + */ +export function registerNestedElementResolver( + editor: LexicalEditor, + targetNode: Klass, + cloneNode: (from: N) => N, + handleOverlap: (from: N, to: N) => void, +): () => void { + const $isTargetNode = (node: LexicalNode | null | undefined): node is N => { + return node instanceof targetNode; + }; + + const $findMatch = (node: N): {child: ElementNode; parent: N} | null => { + // First validate we don't have any children that are of the target, + // as we need to handle them first. + const children = node.getChildren(); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + if ($isTargetNode(child)) { + return null; + } + } + + let parentNode: N | null = node; + let childNode = node; + + while (parentNode !== null) { + childNode = parentNode; + parentNode = parentNode.getParent(); + + if ($isTargetNode(parentNode)) { + return {child: childNode, parent: parentNode}; + } + } + + return null; + }; + + const $elementNodeTransform = (node: N) => { + const match = $findMatch(node); + + if (match !== null) { + const {child, parent} = match; + + // Simple path, we can move child out and siblings into a new parent. + + if (child.is(node)) { + handleOverlap(parent, node); + const nextSiblings = child.getNextSiblings(); + const nextSiblingsLength = nextSiblings.length; + parent.insertAfter(child); + + if (nextSiblingsLength !== 0) { + const newParent = cloneNode(parent); + child.insertAfter(newParent); + + for (let i = 0; i < nextSiblingsLength; i++) { + newParent.append(nextSiblings[i]); + } + } + + if (!parent.canBeEmpty() && parent.getChildrenSize() === 0) { + parent.remove(); + } + } else { + // Complex path, we have a deep node that isn't a child of the + // target parent. + // TODO: implement this functionality + } + } + }; + + return editor.registerNodeTransform(targetNode, $elementNodeTransform); +} + +/** + * Clones the editor and marks it as dirty to be reconciled. If there was a selection, + * it would be set back to its previous state, or null otherwise. + * @param editor - The lexical editor + * @param editorState - The editor's state + */ +export function $restoreEditorState( + editor: LexicalEditor, + editorState: EditorState, +): void { + const FULL_RECONCILE = 2; + const nodeMap = new Map(); + const activeEditorState = editor._pendingEditorState; + + for (const [key, node] of editorState._nodeMap) { + nodeMap.set(key, $cloneWithProperties(node)); + } + + if (activeEditorState) { + activeEditorState._nodeMap = nodeMap; + } + + editor._dirtyType = FULL_RECONCILE; + const selection = editorState._selection; + $setSelection(selection === null ? null : selection.clone()); +} + +/** + * If the selected insertion area is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}), + * the node will be appended there, otherwise, it will be inserted before the insertion area. + * If there is no selection where the node is to be inserted, it will be appended after any current nodes + * within the tree, as a child of the root node. A paragraph node will then be added after the inserted node and selected. + * @param node - The node to be inserted + * @returns The node after its insertion + */ +export function $insertNodeToNearestRoot(node: T): T { + const selection = $getSelection() || $getPreviousSelection(); + + if ($isRangeSelection(selection)) { + const {focus} = selection; + const focusNode = focus.getNode(); + const focusOffset = focus.offset; + + if ($isRootOrShadowRoot(focusNode)) { + const focusChild = focusNode.getChildAtIndex(focusOffset); + if (focusChild == null) { + focusNode.append(node); + } else { + focusChild.insertBefore(node); + } + node.selectNext(); + } else { + let splitNode: ElementNode; + let splitOffset: number; + if ($isTextNode(focusNode)) { + splitNode = focusNode.getParentOrThrow(); + splitOffset = focusNode.getIndexWithinParent(); + if (focusOffset > 0) { + splitOffset += 1; + focusNode.splitText(focusOffset); + } + } else { + splitNode = focusNode; + splitOffset = focusOffset; + } + const [, rightTree] = $splitNode(splitNode, splitOffset); + rightTree.insertBefore(node); + rightTree.selectStart(); + } + } else { + if (selection != null) { + const nodes = selection.getNodes(); + nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node); + } else { + const root = $getRoot(); + root.append(node); + } + const paragraphNode = $createParagraphNode(); + node.insertAfter(paragraphNode); + paragraphNode.select(); + } + return node.getLatest(); +} + +/** + * Wraps the node into another node created from a createElementNode function, eg. $createParagraphNode + * @param node - Node to be wrapped. + * @param createElementNode - Creates a new lexical element to wrap the to-be-wrapped node and returns it. + * @returns A new lexical element with the previous node appended within (as a child, including its children). + */ +export function $wrapNodeInElement( + node: LexicalNode, + createElementNode: () => ElementNode, +): ElementNode { + const elementNode = createElementNode(); + node.replace(elementNode); + elementNode.append(node); + return elementNode; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ObjectKlass = new (...args: any[]) => T; + +/** + * @param object = The instance of the type + * @param objectClass = The class of the type + * @returns Whether the object is has the same Klass of the objectClass, ignoring the difference across window (e.g. different iframs) + */ +export function objectKlassEquals( + object: unknown, + objectClass: ObjectKlass, +): boolean { + return object !== null + ? Object.getPrototypeOf(object).constructor.name === objectClass.name + : false; +} + +/** + * Filter the nodes + * @param nodes Array of nodes that needs to be filtered + * @param filterFn A filter function that returns node if the current node satisfies the condition otherwise null + * @returns Array of filtered nodes + */ + +export function $filter( + nodes: Array, + filterFn: (node: LexicalNode) => null | T, +): Array { + const result: T[] = []; + for (let i = 0; i < nodes.length; i++) { + const node = filterFn(nodes[i]); + if (node !== null) { + result.push(node); + } + } + return result; +} +/** + * Appends the node before the first child of the parent node + * @param parent A parent node + * @param node Node that needs to be appended + */ +export function $insertFirst(parent: ElementNode, node: LexicalNode): void { + const firstChild = parent.getFirstChild(); + if (firstChild !== null) { + firstChild.insertBefore(node); + } else { + parent.append(node); + } +} + +/** + * Calculates the zoom level of an element as a result of using + * css zoom property. + * @param element + */ +export function calculateZoomLevel(element: Element | null): number { + if (IS_FIREFOX) { + return 1; + } + let zoom = 1; + while (element) { + zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom')); + element = element.parentElement; + } + return zoom; +} + +/** + * Checks if the editor is a nested editor created by LexicalNestedComposer + */ +export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean { + return editor._parentEditor !== null; +} diff --git a/resources/js/wysiwyg/lexical/utils/markSelection.ts b/resources/js/wysiwyg/lexical/utils/markSelection.ts new file mode 100644 index 000000000..b1359c6df --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/markSelection.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $getSelection, + $isRangeSelection, + type EditorState, + ElementNode, + type LexicalEditor, + TextNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import mergeRegister from './mergeRegister'; +import positionNodeOnRange from './positionNodeOnRange'; +import px from './px'; + +export default function markSelection( + editor: LexicalEditor, + onReposition?: (node: Array) => void, +): () => void { + let previousAnchorNode: null | TextNode | ElementNode = null; + let previousAnchorOffset: null | number = null; + let previousFocusNode: null | TextNode | ElementNode = null; + let previousFocusOffset: null | number = null; + let removeRangeListener: () => void = () => {}; + function compute(editorState: EditorState) { + editorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + // TODO + previousAnchorNode = null; + previousAnchorOffset = null; + previousFocusNode = null; + previousFocusOffset = null; + removeRangeListener(); + removeRangeListener = () => {}; + return; + } + const {anchor, focus} = selection; + const currentAnchorNode = anchor.getNode(); + const currentAnchorNodeKey = currentAnchorNode.getKey(); + const currentAnchorOffset = anchor.offset; + const currentFocusNode = focus.getNode(); + const currentFocusNodeKey = currentFocusNode.getKey(); + const currentFocusOffset = focus.offset; + const currentAnchorNodeDOM = editor.getElementByKey(currentAnchorNodeKey); + const currentFocusNodeDOM = editor.getElementByKey(currentFocusNodeKey); + const differentAnchorDOM = + previousAnchorNode === null || + currentAnchorNodeDOM === null || + currentAnchorOffset !== previousAnchorOffset || + currentAnchorNodeKey !== previousAnchorNode.getKey() || + (currentAnchorNode !== previousAnchorNode && + (!(previousAnchorNode instanceof TextNode) || + currentAnchorNode.updateDOM( + previousAnchorNode, + currentAnchorNodeDOM, + editor._config, + ))); + const differentFocusDOM = + previousFocusNode === null || + currentFocusNodeDOM === null || + currentFocusOffset !== previousFocusOffset || + currentFocusNodeKey !== previousFocusNode.getKey() || + (currentFocusNode !== previousFocusNode && + (!(previousFocusNode instanceof TextNode) || + currentFocusNode.updateDOM( + previousFocusNode, + currentFocusNodeDOM, + editor._config, + ))); + if (differentAnchorDOM || differentFocusDOM) { + const anchorHTMLElement = editor.getElementByKey( + anchor.getNode().getKey(), + ); + const focusHTMLElement = editor.getElementByKey( + focus.getNode().getKey(), + ); + // TODO handle selection beyond the common TextNode + if ( + anchorHTMLElement !== null && + focusHTMLElement !== null && + anchorHTMLElement.tagName === 'SPAN' && + focusHTMLElement.tagName === 'SPAN' + ) { + const range = document.createRange(); + let firstHTMLElement; + let firstOffset; + let lastHTMLElement; + let lastOffset; + if (focus.isBefore(anchor)) { + firstHTMLElement = focusHTMLElement; + firstOffset = focus.offset; + lastHTMLElement = anchorHTMLElement; + lastOffset = anchor.offset; + } else { + firstHTMLElement = anchorHTMLElement; + firstOffset = anchor.offset; + lastHTMLElement = focusHTMLElement; + lastOffset = focus.offset; + } + const firstTextNode = firstHTMLElement.firstChild; + invariant( + firstTextNode !== null, + 'Expected text node to be first child of span', + ); + const lastTextNode = lastHTMLElement.firstChild; + invariant( + lastTextNode !== null, + 'Expected text node to be first child of span', + ); + range.setStart(firstTextNode, firstOffset); + range.setEnd(lastTextNode, lastOffset); + removeRangeListener(); + removeRangeListener = positionNodeOnRange( + editor, + range, + (domNodes) => { + for (const domNode of domNodes) { + const domNodeStyle = domNode.style; + if (domNodeStyle.background !== 'Highlight') { + domNodeStyle.background = 'Highlight'; + } + if (domNodeStyle.color !== 'HighlightText') { + domNodeStyle.color = 'HighlightText'; + } + if (domNodeStyle.zIndex !== '-1') { + domNodeStyle.zIndex = '-1'; + } + if (domNodeStyle.pointerEvents !== 'none') { + domNodeStyle.pointerEvents = 'none'; + } + if (domNodeStyle.marginTop !== px(-1.5)) { + domNodeStyle.marginTop = px(-1.5); + } + if (domNodeStyle.paddingTop !== px(4)) { + domNodeStyle.paddingTop = px(4); + } + if (domNodeStyle.paddingBottom !== px(0)) { + domNodeStyle.paddingBottom = px(0); + } + } + if (onReposition !== undefined) { + onReposition(domNodes); + } + }, + ); + } + } + previousAnchorNode = currentAnchorNode; + previousAnchorOffset = currentAnchorOffset; + previousFocusNode = currentFocusNode; + previousFocusOffset = currentFocusOffset; + }); + } + compute(editor.getEditorState()); + return mergeRegister( + editor.registerUpdateListener(({editorState}) => compute(editorState)), + removeRangeListener, + () => { + removeRangeListener(); + }, + ); +} diff --git a/resources/js/wysiwyg/lexical/utils/mergeRegister.ts b/resources/js/wysiwyg/lexical/utils/mergeRegister.ts new file mode 100644 index 000000000..0d1a19255 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/mergeRegister.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +type Func = () => void; + +/** + * Returns a function that will execute all functions passed when called. It is generally used + * to register multiple lexical listeners and then tear them down with a single function call, such + * as React's useEffect hook. + * @example + * ```ts + * useEffect(() => { + * return mergeRegister( + * editor.registerCommand(...registerCommand1 logic), + * editor.registerCommand(...registerCommand2 logic), + * editor.registerCommand(...registerCommand3 logic) + * ) + * }, [editor]) + * ``` + * In this case, useEffect is returning the function returned by mergeRegister as a cleanup + * function to be executed after either the useEffect runs again (due to one of its dependencies + * updating) or the component it resides in unmounts. + * Note the functions don't neccesarily need to be in an array as all arguments + * are considered to be the func argument and spread from there. + * The order of cleanup is the reverse of the argument order. Generally it is + * expected that the first "acquire" will be "released" last (LIFO order), + * because a later step may have some dependency on an earlier one. + * @param func - An array of cleanup functions meant to be executed by the returned function. + * @returns the function which executes all the passed cleanup functions. + */ +export default function mergeRegister(...func: Array): () => void { + return () => { + for (let i = func.length - 1; i >= 0; i--) { + func[i](); + } + // Clean up the references and make future calls a no-op + func.length = 0; + }; +} diff --git a/resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts b/resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts new file mode 100644 index 000000000..468d25c08 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/positionNodeOnRange.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor} from 'lexical'; + +import {createRectsFromDOMRange} from '@lexical/selection'; +import invariant from 'lexical/shared/invariant'; + +import px from './px'; + +const mutationObserverConfig = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; + +export default function positionNodeOnRange( + editor: LexicalEditor, + range: Range, + onReposition: (node: Array) => void, +): () => void { + let rootDOMNode: null | HTMLElement = null; + let parentDOMNode: null | HTMLElement = null; + let observer: null | MutationObserver = null; + let lastNodes: Array = []; + const wrapperNode = document.createElement('div'); + + function position(): void { + invariant(rootDOMNode !== null, 'Unexpected null rootDOMNode'); + invariant(parentDOMNode !== null, 'Unexpected null parentDOMNode'); + const {left: rootLeft, top: rootTop} = rootDOMNode.getBoundingClientRect(); + const parentDOMNode_ = parentDOMNode; + const rects = createRectsFromDOMRange(editor, range); + if (!wrapperNode.isConnected) { + parentDOMNode_.append(wrapperNode); + } + let hasRepositioned = false; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + // Try to reuse the previously created Node when possible, no need to + // remove/create on the most common case reposition case + const rectNode = lastNodes[i] || document.createElement('div'); + const rectNodeStyle = rectNode.style; + if (rectNodeStyle.position !== 'absolute') { + rectNodeStyle.position = 'absolute'; + hasRepositioned = true; + } + const left = px(rect.left - rootLeft); + if (rectNodeStyle.left !== left) { + rectNodeStyle.left = left; + hasRepositioned = true; + } + const top = px(rect.top - rootTop); + if (rectNodeStyle.top !== top) { + rectNode.style.top = top; + hasRepositioned = true; + } + const width = px(rect.width); + if (rectNodeStyle.width !== width) { + rectNode.style.width = width; + hasRepositioned = true; + } + const height = px(rect.height); + if (rectNodeStyle.height !== height) { + rectNode.style.height = height; + hasRepositioned = true; + } + if (rectNode.parentNode !== wrapperNode) { + wrapperNode.append(rectNode); + hasRepositioned = true; + } + lastNodes[i] = rectNode; + } + while (lastNodes.length > rects.length) { + lastNodes.pop(); + } + if (hasRepositioned) { + onReposition(lastNodes); + } + } + + function stop(): void { + parentDOMNode = null; + rootDOMNode = null; + if (observer !== null) { + observer.disconnect(); + } + observer = null; + wrapperNode.remove(); + for (const node of lastNodes) { + node.remove(); + } + lastNodes = []; + } + + function restart(): void { + const currentRootDOMNode = editor.getRootElement(); + if (currentRootDOMNode === null) { + return stop(); + } + const currentParentDOMNode = currentRootDOMNode.parentElement; + if (!(currentParentDOMNode instanceof HTMLElement)) { + return stop(); + } + stop(); + rootDOMNode = currentRootDOMNode; + parentDOMNode = currentParentDOMNode; + observer = new MutationObserver((mutations) => { + const nextRootDOMNode = editor.getRootElement(); + const nextParentDOMNode = + nextRootDOMNode && nextRootDOMNode.parentElement; + if ( + nextRootDOMNode !== rootDOMNode || + nextParentDOMNode !== parentDOMNode + ) { + return restart(); + } + for (const mutation of mutations) { + if (!wrapperNode.contains(mutation.target)) { + // TODO throttle + return position(); + } + } + }); + observer.observe(currentParentDOMNode, mutationObserverConfig); + position(); + } + + const removeRootListener = editor.registerRootListener(restart); + + return () => { + removeRootListener(); + stop(); + }; +} diff --git a/resources/js/wysiwyg/lexical/utils/px.ts b/resources/js/wysiwyg/lexical/utils/px.ts new file mode 100644 index 000000000..c306cc7d6 --- /dev/null +++ b/resources/js/wysiwyg/lexical/utils/px.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function px(value: number) { + return `${value}px`; +} diff --git a/resources/js/wysiwyg/lexical/yjs/Bindings.ts b/resources/js/wysiwyg/lexical/yjs/Bindings.ts new file mode 100644 index 000000000..4d3ac01f4 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/Bindings.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {CollabDecoratorNode} from './CollabDecoratorNode'; +import type {CollabElementNode} from './CollabElementNode'; +import type {CollabLineBreakNode} from './CollabLineBreakNode'; +import type {CollabTextNode} from './CollabTextNode'; +import type {Cursor} from './SyncCursors'; +import type {LexicalEditor, NodeKey} from 'lexical'; +import type {Doc} from 'yjs'; + +import {Klass, LexicalNode} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import {XmlText} from 'yjs'; + +import {Provider} from '.'; +import {$createCollabElementNode} from './CollabElementNode'; + +export type ClientID = number; +export type Binding = { + clientID: number; + collabNodeMap: Map< + NodeKey, + | CollabElementNode + | CollabTextNode + | CollabDecoratorNode + | CollabLineBreakNode + >; + cursors: Map; + cursorsContainer: null | HTMLElement; + doc: Doc; + docMap: Map; + editor: LexicalEditor; + id: string; + nodeProperties: Map>; + root: CollabElementNode; + excludedProperties: ExcludedProperties; +}; +export type ExcludedProperties = Map, Set>; + +export function createBinding( + editor: LexicalEditor, + provider: Provider, + id: string, + doc: Doc | null | undefined, + docMap: Map, + excludedProperties?: ExcludedProperties, +): Binding { + invariant( + doc !== undefined && doc !== null, + 'createBinding: doc is null or undefined', + ); + const rootXmlText = doc.get('root', XmlText) as XmlText; + const root: CollabElementNode = $createCollabElementNode( + rootXmlText, + null, + 'root', + ); + root._key = 'root'; + return { + clientID: doc.clientID, + collabNodeMap: new Map(), + cursors: new Map(), + cursorsContainer: null, + doc, + docMap, + editor, + excludedProperties: excludedProperties || new Map(), + id, + nodeProperties: new Map(), + root, + }; +} diff --git a/resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts new file mode 100644 index 000000000..3578ed7f5 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabDecoratorNode.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding} from '.'; +import type {CollabElementNode} from './CollabElementNode'; +import type {DecoratorNode, NodeKey, NodeMap} from 'lexical'; +import type {XmlElement} from 'yjs'; + +import {$getNodeByKey, $isDecoratorNode} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {syncPropertiesFromLexical, syncPropertiesFromYjs} from './Utils'; + +export class CollabDecoratorNode { + _xmlElem: XmlElement; + _key: NodeKey; + _parent: CollabElementNode; + _type: string; + + constructor(xmlElem: XmlElement, parent: CollabElementNode, type: string) { + this._key = ''; + this._xmlElem = xmlElem; + this._parent = parent; + this._type = type; + } + + getPrevNode(nodeMap: null | NodeMap): null | DecoratorNode { + if (nodeMap === null) { + return null; + } + + const node = nodeMap.get(this._key); + return $isDecoratorNode(node) ? node : null; + } + + getNode(): null | DecoratorNode { + const node = $getNodeByKey(this._key); + return $isDecoratorNode(node) ? node : null; + } + + getSharedType(): XmlElement { + return this._xmlElem; + } + + getType(): string { + return this._type; + } + + getKey(): NodeKey { + return this._key; + } + + getSize(): number { + return 1; + } + + getOffset(): number { + const collabElementNode = this._parent; + return collabElementNode.getChildOffset(this); + } + + syncPropertiesFromLexical( + binding: Binding, + nextLexicalNode: DecoratorNode, + prevNodeMap: null | NodeMap, + ): void { + const prevLexicalNode = this.getPrevNode(prevNodeMap); + const xmlElem = this._xmlElem; + + syncPropertiesFromLexical( + binding, + xmlElem, + prevLexicalNode, + nextLexicalNode, + ); + } + + syncPropertiesFromYjs( + binding: Binding, + keysChanged: null | Set, + ): void { + const lexicalNode = this.getNode(); + invariant( + lexicalNode !== null, + 'syncPropertiesFromYjs: could not find decorator node', + ); + const xmlElem = this._xmlElem; + syncPropertiesFromYjs(binding, xmlElem, lexicalNode, keysChanged); + } + + destroy(binding: Binding): void { + const collabNodeMap = binding.collabNodeMap; + collabNodeMap.delete(this._key); + } +} + +export function $createCollabDecoratorNode( + xmlElem: XmlElement, + parent: CollabElementNode, + type: string, +): CollabDecoratorNode { + const collabNode = new CollabDecoratorNode(xmlElem, parent, type); + xmlElem._collabNode = collabNode; + return collabNode; +} diff --git a/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts new file mode 100644 index 000000000..b3866043d --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts @@ -0,0 +1,666 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding} from '.'; +import type {ElementNode, NodeKey, NodeMap} from 'lexical'; +import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs'; + +import {$createChildrenArray} from '@lexical/offset'; +import { + $getNodeByKey, + $isDecoratorNode, + $isElementNode, + $isTextNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; + +import {CollabDecoratorNode} from './CollabDecoratorNode'; +import {CollabLineBreakNode} from './CollabLineBreakNode'; +import {CollabTextNode} from './CollabTextNode'; +import { + $createCollabNodeFromLexicalNode, + $getNodeByKeyOrThrow, + $getOrInitCollabNodeFromSharedType, + createLexicalNodeFromCollabNode, + getPositionFromElementAndOffset, + removeFromParent, + spliceString, + syncPropertiesFromLexical, + syncPropertiesFromYjs, +} from './Utils'; + +type IntentionallyMarkedAsDirtyElement = boolean; + +export class CollabElementNode { + _key: NodeKey; + _children: Array< + | CollabElementNode + | CollabTextNode + | CollabDecoratorNode + | CollabLineBreakNode + >; + _xmlText: XmlText; + _type: string; + _parent: null | CollabElementNode; + + constructor( + xmlText: XmlText, + parent: null | CollabElementNode, + type: string, + ) { + this._key = ''; + this._children = []; + this._xmlText = xmlText; + this._type = type; + this._parent = parent; + } + + getPrevNode(nodeMap: null | NodeMap): null | ElementNode { + if (nodeMap === null) { + return null; + } + + const node = nodeMap.get(this._key); + return $isElementNode(node) ? node : null; + } + + getNode(): null | ElementNode { + const node = $getNodeByKey(this._key); + return $isElementNode(node) ? node : null; + } + + getSharedType(): XmlText { + return this._xmlText; + } + + getType(): string { + return this._type; + } + + getKey(): NodeKey { + return this._key; + } + + isEmpty(): boolean { + return this._children.length === 0; + } + + getSize(): number { + return 1; + } + + getOffset(): number { + const collabElementNode = this._parent; + invariant( + collabElementNode !== null, + 'getOffset: could not find collab element node', + ); + + return collabElementNode.getChildOffset(this); + } + + syncPropertiesFromYjs( + binding: Binding, + keysChanged: null | Set, + ): void { + const lexicalNode = this.getNode(); + invariant( + lexicalNode !== null, + 'syncPropertiesFromYjs: could not find element node', + ); + syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged); + } + + applyChildrenYjsDelta( + binding: Binding, + deltas: Array<{ + insert?: string | object | AbstractType; + delete?: number; + retain?: number; + attributes?: { + [x: string]: unknown; + }; + }>, + ): void { + const children = this._children; + let currIndex = 0; + + for (let i = 0; i < deltas.length; i++) { + const delta = deltas[i]; + const insertDelta = delta.insert; + const deleteDelta = delta.delete; + + if (delta.retain != null) { + currIndex += delta.retain; + } else if (typeof deleteDelta === 'number') { + let deletionSize = deleteDelta; + + while (deletionSize > 0) { + const {node, nodeIndex, offset, length} = + getPositionFromElementAndOffset(this, currIndex, false); + + if ( + node instanceof CollabElementNode || + node instanceof CollabLineBreakNode || + node instanceof CollabDecoratorNode + ) { + children.splice(nodeIndex, 1); + deletionSize -= 1; + } else if (node instanceof CollabTextNode) { + const delCount = Math.min(deletionSize, length); + const prevCollabNode = + nodeIndex !== 0 ? children[nodeIndex - 1] : null; + const nodeSize = node.getSize(); + + if ( + offset === 0 && + delCount === 1 && + nodeIndex > 0 && + prevCollabNode instanceof CollabTextNode && + length === nodeSize && + // If the node has no keys, it's been deleted + Array.from(node._map.keys()).length === 0 + ) { + // Merge the text node with previous. + prevCollabNode._text += node._text; + children.splice(nodeIndex, 1); + } else if (offset === 0 && delCount === nodeSize) { + // The entire thing needs removing + children.splice(nodeIndex, 1); + } else { + node._text = spliceString(node._text, offset, delCount, ''); + } + + deletionSize -= delCount; + } else { + // Can occur due to the deletion from the dangling text heuristic below. + break; + } + } + } else if (insertDelta != null) { + if (typeof insertDelta === 'string') { + const {node, offset} = getPositionFromElementAndOffset( + this, + currIndex, + true, + ); + + if (node instanceof CollabTextNode) { + node._text = spliceString(node._text, offset, 0, insertDelta); + } else { + // TODO: maybe we can improve this by keeping around a redundant + // text node map, rather than removing all the text nodes, so there + // never can be dangling text. + + // We have a conflict where there was likely a CollabTextNode and + // an Lexical TextNode too, but they were removed in a merge. So + // let's just ignore the text and trigger a removal for it from our + // shared type. + this._xmlText.delete(offset, insertDelta.length); + } + + currIndex += insertDelta.length; + } else { + const sharedType = insertDelta; + const {nodeIndex} = getPositionFromElementAndOffset( + this, + currIndex, + false, + ); + const collabNode = $getOrInitCollabNodeFromSharedType( + binding, + sharedType as XmlText | YMap | XmlElement, + this, + ); + children.splice(nodeIndex, 0, collabNode); + currIndex += 1; + } + } else { + throw new Error('Unexpected delta format'); + } + } + } + + syncChildrenFromYjs(binding: Binding): void { + // Now diff the children of the collab node with that of our existing Lexical node. + const lexicalNode = this.getNode(); + invariant( + lexicalNode !== null, + 'syncChildrenFromYjs: could not find element node', + ); + + const key = lexicalNode.__key; + const prevLexicalChildrenKeys = $createChildrenArray(lexicalNode, null); + const nextLexicalChildrenKeys: Array = []; + const lexicalChildrenKeysLength = prevLexicalChildrenKeys.length; + const collabChildren = this._children; + const collabChildrenLength = collabChildren.length; + const collabNodeMap = binding.collabNodeMap; + const visitedKeys = new Set(); + let collabKeys; + let writableLexicalNode; + let prevIndex = 0; + let prevChildNode = null; + + if (collabChildrenLength !== lexicalChildrenKeysLength) { + writableLexicalNode = lexicalNode.getWritable(); + } + + for (let i = 0; i < collabChildrenLength; i++) { + const lexicalChildKey = prevLexicalChildrenKeys[prevIndex]; + const childCollabNode = collabChildren[i]; + const collabLexicalChildNode = childCollabNode.getNode(); + const collabKey = childCollabNode._key; + + if (collabLexicalChildNode !== null && lexicalChildKey === collabKey) { + const childNeedsUpdating = $isTextNode(collabLexicalChildNode); + // Update + visitedKeys.add(lexicalChildKey); + + if (childNeedsUpdating) { + childCollabNode._key = lexicalChildKey; + + if (childCollabNode instanceof CollabElementNode) { + const xmlText = childCollabNode._xmlText; + childCollabNode.syncPropertiesFromYjs(binding, null); + childCollabNode.applyChildrenYjsDelta(binding, xmlText.toDelta()); + childCollabNode.syncChildrenFromYjs(binding); + } else if (childCollabNode instanceof CollabTextNode) { + childCollabNode.syncPropertiesAndTextFromYjs(binding, null); + } else if (childCollabNode instanceof CollabDecoratorNode) { + childCollabNode.syncPropertiesFromYjs(binding, null); + } else if (!(childCollabNode instanceof CollabLineBreakNode)) { + invariant( + false, + 'syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node', + ); + } + } + + nextLexicalChildrenKeys[i] = lexicalChildKey; + prevChildNode = collabLexicalChildNode; + prevIndex++; + } else { + if (collabKeys === undefined) { + collabKeys = new Set(); + + for (let s = 0; s < collabChildrenLength; s++) { + const child = collabChildren[s]; + const childKey = child._key; + + if (childKey !== '') { + collabKeys.add(childKey); + } + } + } + + if ( + collabLexicalChildNode !== null && + lexicalChildKey !== undefined && + !collabKeys.has(lexicalChildKey) + ) { + const nodeToRemove = $getNodeByKeyOrThrow(lexicalChildKey); + removeFromParent(nodeToRemove); + i--; + prevIndex++; + continue; + } + + writableLexicalNode = lexicalNode.getWritable(); + // Create/Replace + const lexicalChildNode = createLexicalNodeFromCollabNode( + binding, + childCollabNode, + key, + ); + const childKey = lexicalChildNode.__key; + collabNodeMap.set(childKey, childCollabNode); + nextLexicalChildrenKeys[i] = childKey; + if (prevChildNode === null) { + const nextSibling = writableLexicalNode.getFirstChild(); + writableLexicalNode.__first = childKey; + if (nextSibling !== null) { + const writableNextSibling = nextSibling.getWritable(); + writableNextSibling.__prev = childKey; + lexicalChildNode.__next = writableNextSibling.__key; + } + } else { + const writablePrevChildNode = prevChildNode.getWritable(); + const nextSibling = prevChildNode.getNextSibling(); + writablePrevChildNode.__next = childKey; + lexicalChildNode.__prev = prevChildNode.__key; + if (nextSibling !== null) { + const writableNextSibling = nextSibling.getWritable(); + writableNextSibling.__prev = childKey; + lexicalChildNode.__next = writableNextSibling.__key; + } + } + if (i === collabChildrenLength - 1) { + writableLexicalNode.__last = childKey; + } + writableLexicalNode.__size++; + prevChildNode = lexicalChildNode; + } + } + + for (let i = 0; i < lexicalChildrenKeysLength; i++) { + const lexicalChildKey = prevLexicalChildrenKeys[i]; + + if (!visitedKeys.has(lexicalChildKey)) { + // Remove + const lexicalChildNode = $getNodeByKeyOrThrow(lexicalChildKey); + const collabNode = binding.collabNodeMap.get(lexicalChildKey); + + if (collabNode !== undefined) { + collabNode.destroy(binding); + } + removeFromParent(lexicalChildNode); + } + } + } + + syncPropertiesFromLexical( + binding: Binding, + nextLexicalNode: ElementNode, + prevNodeMap: null | NodeMap, + ): void { + syncPropertiesFromLexical( + binding, + this._xmlText, + this.getPrevNode(prevNodeMap), + nextLexicalNode, + ); + } + + _syncChildFromLexical( + binding: Binding, + index: number, + key: NodeKey, + prevNodeMap: null | NodeMap, + dirtyElements: null | Map, + dirtyLeaves: null | Set, + ): void { + const childCollabNode = this._children[index]; + // Update + const nextChildNode = $getNodeByKeyOrThrow(key); + + if ( + childCollabNode instanceof CollabElementNode && + $isElementNode(nextChildNode) + ) { + childCollabNode.syncPropertiesFromLexical( + binding, + nextChildNode, + prevNodeMap, + ); + childCollabNode.syncChildrenFromLexical( + binding, + nextChildNode, + prevNodeMap, + dirtyElements, + dirtyLeaves, + ); + } else if ( + childCollabNode instanceof CollabTextNode && + $isTextNode(nextChildNode) + ) { + childCollabNode.syncPropertiesAndTextFromLexical( + binding, + nextChildNode, + prevNodeMap, + ); + } else if ( + childCollabNode instanceof CollabDecoratorNode && + $isDecoratorNode(nextChildNode) + ) { + childCollabNode.syncPropertiesFromLexical( + binding, + nextChildNode, + prevNodeMap, + ); + } + } + + syncChildrenFromLexical( + binding: Binding, + nextLexicalNode: ElementNode, + prevNodeMap: null | NodeMap, + dirtyElements: null | Map, + dirtyLeaves: null | Set, + ): void { + const prevLexicalNode = this.getPrevNode(prevNodeMap); + const prevChildren = + prevLexicalNode === null + ? [] + : $createChildrenArray(prevLexicalNode, prevNodeMap); + const nextChildren = $createChildrenArray(nextLexicalNode, null); + const prevEndIndex = prevChildren.length - 1; + const nextEndIndex = nextChildren.length - 1; + const collabNodeMap = binding.collabNodeMap; + let prevChildrenSet: Set | undefined; + let nextChildrenSet: Set | undefined; + let prevIndex = 0; + let nextIndex = 0; + + while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) { + const prevKey = prevChildren[prevIndex]; + const nextKey = nextChildren[nextIndex]; + + if (prevKey === nextKey) { + // Nove move, create or remove + this._syncChildFromLexical( + binding, + nextIndex, + nextKey, + prevNodeMap, + dirtyElements, + dirtyLeaves, + ); + + prevIndex++; + nextIndex++; + } else { + if (prevChildrenSet === undefined) { + prevChildrenSet = new Set(prevChildren); + } + + if (nextChildrenSet === undefined) { + nextChildrenSet = new Set(nextChildren); + } + + const nextHasPrevKey = nextChildrenSet.has(prevKey); + const prevHasNextKey = prevChildrenSet.has(nextKey); + + if (!nextHasPrevKey) { + // Remove + this.splice(binding, nextIndex, 1); + prevIndex++; + } else { + // Create or replace + const nextChildNode = $getNodeByKeyOrThrow(nextKey); + const collabNode = $createCollabNodeFromLexicalNode( + binding, + nextChildNode, + this, + ); + collabNodeMap.set(nextKey, collabNode); + + if (prevHasNextKey) { + this.splice(binding, nextIndex, 1, collabNode); + prevIndex++; + nextIndex++; + } else { + this.splice(binding, nextIndex, 0, collabNode); + nextIndex++; + } + } + } + } + + const appendNewChildren = prevIndex > prevEndIndex; + const removeOldChildren = nextIndex > nextEndIndex; + + if (appendNewChildren && !removeOldChildren) { + for (; nextIndex <= nextEndIndex; ++nextIndex) { + const key = nextChildren[nextIndex]; + const nextChildNode = $getNodeByKeyOrThrow(key); + const collabNode = $createCollabNodeFromLexicalNode( + binding, + nextChildNode, + this, + ); + this.append(collabNode); + collabNodeMap.set(key, collabNode); + } + } else if (removeOldChildren && !appendNewChildren) { + for (let i = this._children.length - 1; i >= nextIndex; i--) { + this.splice(binding, i, 1); + } + } + } + + append( + collabNode: + | CollabElementNode + | CollabDecoratorNode + | CollabTextNode + | CollabLineBreakNode, + ): void { + const xmlText = this._xmlText; + const children = this._children; + const lastChild = children[children.length - 1]; + const offset = + lastChild !== undefined ? lastChild.getOffset() + lastChild.getSize() : 0; + + if (collabNode instanceof CollabElementNode) { + xmlText.insertEmbed(offset, collabNode._xmlText); + } else if (collabNode instanceof CollabTextNode) { + const map = collabNode._map; + + if (map.parent === null) { + xmlText.insertEmbed(offset, map); + } + + xmlText.insert(offset + 1, collabNode._text); + } else if (collabNode instanceof CollabLineBreakNode) { + xmlText.insertEmbed(offset, collabNode._map); + } else if (collabNode instanceof CollabDecoratorNode) { + xmlText.insertEmbed(offset, collabNode._xmlElem); + } + + this._children.push(collabNode); + } + + splice( + binding: Binding, + index: number, + delCount: number, + collabNode?: + | CollabElementNode + | CollabDecoratorNode + | CollabTextNode + | CollabLineBreakNode, + ): void { + const children = this._children; + const child = children[index]; + + if (child === undefined) { + invariant( + collabNode !== undefined, + 'splice: could not find collab element node', + ); + this.append(collabNode); + return; + } + + const offset = child.getOffset(); + invariant(offset !== -1, 'splice: expected offset to be greater than zero'); + + const xmlText = this._xmlText; + + if (delCount !== 0) { + // What if we delete many nodes, don't we need to get all their + // sizes? + xmlText.delete(offset, child.getSize()); + } + + if (collabNode instanceof CollabElementNode) { + xmlText.insertEmbed(offset, collabNode._xmlText); + } else if (collabNode instanceof CollabTextNode) { + const map = collabNode._map; + + if (map.parent === null) { + xmlText.insertEmbed(offset, map); + } + + xmlText.insert(offset + 1, collabNode._text); + } else if (collabNode instanceof CollabLineBreakNode) { + xmlText.insertEmbed(offset, collabNode._map); + } else if (collabNode instanceof CollabDecoratorNode) { + xmlText.insertEmbed(offset, collabNode._xmlElem); + } + + if (delCount !== 0) { + const childrenToDelete = children.slice(index, index + delCount); + + for (let i = 0; i < childrenToDelete.length; i++) { + childrenToDelete[i].destroy(binding); + } + } + + if (collabNode !== undefined) { + children.splice(index, delCount, collabNode); + } else { + children.splice(index, delCount); + } + } + + getChildOffset( + collabNode: + | CollabElementNode + | CollabTextNode + | CollabDecoratorNode + | CollabLineBreakNode, + ): number { + let offset = 0; + const children = this._children; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + if (child === collabNode) { + return offset; + } + + offset += child.getSize(); + } + + return -1; + } + + destroy(binding: Binding): void { + const collabNodeMap = binding.collabNodeMap; + const children = this._children; + + for (let i = 0; i < children.length; i++) { + children[i].destroy(binding); + } + + collabNodeMap.delete(this._key); + } +} + +export function $createCollabElementNode( + xmlText: XmlText, + parent: null | CollabElementNode, + type: string, +): CollabElementNode { + const collabNode = new CollabElementNode(xmlText, parent, type); + xmlText._collabNode = collabNode; + return collabNode; +} diff --git a/resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts new file mode 100644 index 000000000..6d1267f8e --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabLineBreakNode.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding} from '.'; +import type {CollabElementNode} from './CollabElementNode'; +import type {LineBreakNode, NodeKey} from 'lexical'; +import type {Map as YMap} from 'yjs'; + +import {$getNodeByKey, $isLineBreakNode} from 'lexical'; + +export class CollabLineBreakNode { + _map: YMap; + _key: NodeKey; + _parent: CollabElementNode; + _type: 'linebreak'; + + constructor(map: YMap, parent: CollabElementNode) { + this._key = ''; + this._map = map; + this._parent = parent; + this._type = 'linebreak'; + } + + getNode(): null | LineBreakNode { + const node = $getNodeByKey(this._key); + return $isLineBreakNode(node) ? node : null; + } + + getKey(): NodeKey { + return this._key; + } + + getSharedType(): YMap { + return this._map; + } + + getType(): string { + return this._type; + } + + getSize(): number { + return 1; + } + + getOffset(): number { + const collabElementNode = this._parent; + return collabElementNode.getChildOffset(this); + } + + destroy(binding: Binding): void { + const collabNodeMap = binding.collabNodeMap; + collabNodeMap.delete(this._key); + } +} + +export function $createCollabLineBreakNode( + map: YMap, + parent: CollabElementNode, +): CollabLineBreakNode { + const collabNode = new CollabLineBreakNode(map, parent); + map._collabNode = collabNode; + return collabNode; +} diff --git a/resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts b/resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts new file mode 100644 index 000000000..86caf91f2 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts @@ -0,0 +1,178 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding} from '.'; +import type {CollabElementNode} from './CollabElementNode'; +import type {NodeKey, NodeMap, TextNode} from 'lexical'; +import type {Map as YMap} from 'yjs'; + +import { + $getNodeByKey, + $getSelection, + $isRangeSelection, + $isTextNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import simpleDiffWithCursor from 'lexical/shared/simpleDiffWithCursor'; + +import {syncPropertiesFromLexical, syncPropertiesFromYjs} from './Utils'; + +function $diffTextContentAndApplyDelta( + collabNode: CollabTextNode, + key: NodeKey, + prevText: string, + nextText: string, +): void { + const selection = $getSelection(); + let cursorOffset = nextText.length; + + if ($isRangeSelection(selection) && selection.isCollapsed()) { + const anchor = selection.anchor; + + if (anchor.key === key) { + cursorOffset = anchor.offset; + } + } + + const diff = simpleDiffWithCursor(prevText, nextText, cursorOffset); + collabNode.spliceText(diff.index, diff.remove, diff.insert); +} + +export class CollabTextNode { + _map: YMap; + _key: NodeKey; + _parent: CollabElementNode; + _text: string; + _type: string; + _normalized: boolean; + + constructor( + map: YMap, + text: string, + parent: CollabElementNode, + type: string, + ) { + this._key = ''; + this._map = map; + this._parent = parent; + this._text = text; + this._type = type; + this._normalized = false; + } + + getPrevNode(nodeMap: null | NodeMap): null | TextNode { + if (nodeMap === null) { + return null; + } + + const node = nodeMap.get(this._key); + return $isTextNode(node) ? node : null; + } + + getNode(): null | TextNode { + const node = $getNodeByKey(this._key); + return $isTextNode(node) ? node : null; + } + + getSharedType(): YMap { + return this._map; + } + + getType(): string { + return this._type; + } + + getKey(): NodeKey { + return this._key; + } + + getSize(): number { + return this._text.length + (this._normalized ? 0 : 1); + } + + getOffset(): number { + const collabElementNode = this._parent; + return collabElementNode.getChildOffset(this); + } + + spliceText(index: number, delCount: number, newText: string): void { + const collabElementNode = this._parent; + const xmlText = collabElementNode._xmlText; + const offset = this.getOffset() + 1 + index; + + if (delCount !== 0) { + xmlText.delete(offset, delCount); + } + + if (newText !== '') { + xmlText.insert(offset, newText); + } + } + + syncPropertiesAndTextFromLexical( + binding: Binding, + nextLexicalNode: TextNode, + prevNodeMap: null | NodeMap, + ): void { + const prevLexicalNode = this.getPrevNode(prevNodeMap); + const nextText = nextLexicalNode.__text; + + syncPropertiesFromLexical( + binding, + this._map, + prevLexicalNode, + nextLexicalNode, + ); + + if (prevLexicalNode !== null) { + const prevText = prevLexicalNode.__text; + + if (prevText !== nextText) { + const key = nextLexicalNode.__key; + $diffTextContentAndApplyDelta(this, key, prevText, nextText); + this._text = nextText; + } + } + } + + syncPropertiesAndTextFromYjs( + binding: Binding, + keysChanged: null | Set, + ): void { + const lexicalNode = this.getNode(); + invariant( + lexicalNode !== null, + 'syncPropertiesAndTextFromYjs: could not find decorator node', + ); + + syncPropertiesFromYjs(binding, this._map, lexicalNode, keysChanged); + + const collabText = this._text; + + if (lexicalNode.__text !== collabText) { + const writable = lexicalNode.getWritable(); + writable.__text = collabText; + } + } + + destroy(binding: Binding): void { + const collabNodeMap = binding.collabNodeMap; + collabNodeMap.delete(this._key); + } +} + +export function $createCollabTextNode( + map: YMap, + text: string, + parent: CollabElementNode, + type: string, +): CollabTextNode { + const collabNode = new CollabTextNode(map, text, parent, type); + map._collabNode = collabNode; + return collabNode; +} diff --git a/resources/js/wysiwyg/lexical/yjs/SyncCursors.ts b/resources/js/wysiwyg/lexical/yjs/SyncCursors.ts new file mode 100644 index 000000000..721fbb68f --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/SyncCursors.ts @@ -0,0 +1,536 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding} from './Bindings'; +import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical'; +import type {AbsolutePosition, RelativePosition} from 'yjs'; + +import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection'; +import { + $getNodeByKey, + $getSelection, + $isElementNode, + $isLineBreakNode, + $isRangeSelection, + $isTextNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import { + compareRelativePositions, + createAbsolutePositionFromRelativePosition, + createRelativePositionFromTypeIndex, +} from 'yjs'; + +import {Provider} from '.'; +import {CollabDecoratorNode} from './CollabDecoratorNode'; +import {CollabElementNode} from './CollabElementNode'; +import {CollabLineBreakNode} from './CollabLineBreakNode'; +import {CollabTextNode} from './CollabTextNode'; +import {getPositionFromElementAndOffset} from './Utils'; + +export type CursorSelection = { + anchor: { + key: NodeKey; + offset: number; + }; + caret: HTMLElement; + color: string; + focus: { + key: NodeKey; + offset: number; + }; + name: HTMLSpanElement; + selections: Array; +}; +export type Cursor = { + color: string; + name: string; + selection: null | CursorSelection; +}; + +function createRelativePosition( + point: Point, + binding: Binding, +): null | RelativePosition { + const collabNodeMap = binding.collabNodeMap; + const collabNode = collabNodeMap.get(point.key); + + if (collabNode === undefined) { + return null; + } + + let offset = point.offset; + let sharedType = collabNode.getSharedType(); + + if (collabNode instanceof CollabTextNode) { + sharedType = collabNode._parent._xmlText; + const currentOffset = collabNode.getOffset(); + + if (currentOffset === -1) { + return null; + } + + offset = currentOffset + 1 + offset; + } else if ( + collabNode instanceof CollabElementNode && + point.type === 'element' + ) { + const parent = point.getNode(); + invariant($isElementNode(parent), 'Element point must be an element node'); + let accumulatedOffset = 0; + let i = 0; + let node = parent.getFirstChild(); + while (node !== null && i++ < offset) { + if ($isTextNode(node)) { + accumulatedOffset += node.getTextContentSize() + 1; + } else { + accumulatedOffset++; + } + node = node.getNextSibling(); + } + offset = accumulatedOffset; + } + + return createRelativePositionFromTypeIndex(sharedType, offset); +} + +function createAbsolutePosition( + relativePosition: RelativePosition, + binding: Binding, +): AbsolutePosition | null { + return createAbsolutePositionFromRelativePosition( + relativePosition, + binding.doc, + ); +} + +function shouldUpdatePosition( + currentPos: RelativePosition | null | undefined, + pos: RelativePosition | null | undefined, +): boolean { + if (currentPos == null) { + if (pos != null) { + return true; + } + } else if (pos == null || !compareRelativePositions(currentPos, pos)) { + return true; + } + + return false; +} + +function createCursor(name: string, color: string): Cursor { + return { + color: color, + name: name, + selection: null, + }; +} + +function destroySelection(binding: Binding, selection: CursorSelection) { + const cursorsContainer = binding.cursorsContainer; + + if (cursorsContainer !== null) { + const selections = selection.selections; + const selectionsLength = selections.length; + + for (let i = 0; i < selectionsLength; i++) { + cursorsContainer.removeChild(selections[i]); + } + } +} + +function destroyCursor(binding: Binding, cursor: Cursor) { + const selection = cursor.selection; + + if (selection !== null) { + destroySelection(binding, selection); + } +} + +function createCursorSelection( + cursor: Cursor, + anchorKey: NodeKey, + anchorOffset: number, + focusKey: NodeKey, + focusOffset: number, +): CursorSelection { + const color = cursor.color; + const caret = document.createElement('span'); + caret.style.cssText = `position:absolute;top:0;bottom:0;right:-1px;width:1px;background-color:${color};z-index:10;`; + const name = document.createElement('span'); + name.textContent = cursor.name; + name.style.cssText = `position:absolute;left:-2px;top:-16px;background-color:${color};color:#fff;line-height:12px;font-size:12px;padding:2px;font-family:Arial;font-weight:bold;white-space:nowrap;`; + caret.appendChild(name); + return { + anchor: { + key: anchorKey, + offset: anchorOffset, + }, + caret, + color, + focus: { + key: focusKey, + offset: focusOffset, + }, + name, + selections: [], + }; +} + +function updateCursor( + binding: Binding, + cursor: Cursor, + nextSelection: null | CursorSelection, + nodeMap: NodeMap, +): void { + const editor = binding.editor; + const rootElement = editor.getRootElement(); + const cursorsContainer = binding.cursorsContainer; + + if (cursorsContainer === null || rootElement === null) { + return; + } + + const cursorsContainerOffsetParent = cursorsContainer.offsetParent; + if (cursorsContainerOffsetParent === null) { + return; + } + + const containerRect = cursorsContainerOffsetParent.getBoundingClientRect(); + const prevSelection = cursor.selection; + + if (nextSelection === null) { + if (prevSelection === null) { + return; + } else { + cursor.selection = null; + destroySelection(binding, prevSelection); + return; + } + } else { + cursor.selection = nextSelection; + } + + const caret = nextSelection.caret; + const color = nextSelection.color; + const selections = nextSelection.selections; + const anchor = nextSelection.anchor; + const focus = nextSelection.focus; + const anchorKey = anchor.key; + const focusKey = focus.key; + const anchorNode = nodeMap.get(anchorKey); + const focusNode = nodeMap.get(focusKey); + + if (anchorNode == null || focusNode == null) { + return; + } + let selectionRects: Array; + + // In the case of a collapsed selection on a linebreak, we need + // to improvise as the browser will return nothing here as
                                          + // apparantly take up no visual space :/ + // This won't work in all cases, but it's better than just showing + // nothing all the time. + if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) { + const brRect = ( + editor.getElementByKey(anchorKey) as HTMLElement + ).getBoundingClientRect(); + selectionRects = [brRect]; + } else { + const range = createDOMRange( + editor, + anchorNode, + anchor.offset, + focusNode, + focus.offset, + ); + + if (range === null) { + return; + } + selectionRects = createRectsFromDOMRange(editor, range); + } + + const selectionsLength = selections.length; + const selectionRectsLength = selectionRects.length; + + for (let i = 0; i < selectionRectsLength; i++) { + const selectionRect = selectionRects[i]; + let selection = selections[i]; + + if (selection === undefined) { + selection = document.createElement('span'); + selections[i] = selection; + const selectionBg = document.createElement('span'); + selection.appendChild(selectionBg); + cursorsContainer.appendChild(selection); + } + + const top = selectionRect.top - containerRect.top; + const left = selectionRect.left - containerRect.left; + const style = `position:absolute;top:${top}px;left:${left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;pointer-events:none;z-index:5;`; + selection.style.cssText = style; + + ( + selection.firstChild as HTMLSpanElement + ).style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`; + + if (i === selectionRectsLength - 1) { + if (caret.parentNode !== selection) { + selection.appendChild(caret); + } + } + } + + for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) { + const selection = selections[i]; + cursorsContainer.removeChild(selection); + selections.pop(); + } +} + +export function $syncLocalCursorPosition( + binding: Binding, + provider: Provider, +): void { + const awareness = provider.awareness; + const localState = awareness.getLocalState(); + + if (localState === null) { + return; + } + + const anchorPos = localState.anchorPos; + const focusPos = localState.focusPos; + + if (anchorPos !== null && focusPos !== null) { + const anchorAbsPos = createAbsolutePosition(anchorPos, binding); + const focusAbsPos = createAbsolutePosition(focusPos, binding); + + if (anchorAbsPos !== null && focusAbsPos !== null) { + const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset( + anchorAbsPos.type, + anchorAbsPos.index, + ); + const [focusCollabNode, focusOffset] = getCollabNodeAndOffset( + focusAbsPos.type, + focusAbsPos.index, + ); + + if (anchorCollabNode !== null && focusCollabNode !== null) { + const anchorKey = anchorCollabNode.getKey(); + const focusKey = focusCollabNode.getKey(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + const anchor = selection.anchor; + const focus = selection.focus; + + $setPoint(anchor, anchorKey, anchorOffset); + $setPoint(focus, focusKey, focusOffset); + } + } + } +} + +function $setPoint(point: Point, key: NodeKey, offset: number): void { + if (point.key !== key || point.offset !== offset) { + let anchorNode = $getNodeByKey(key); + if ( + anchorNode !== null && + !$isElementNode(anchorNode) && + !$isTextNode(anchorNode) + ) { + const parent = anchorNode.getParentOrThrow(); + key = parent.getKey(); + offset = anchorNode.getIndexWithinParent(); + anchorNode = parent; + } + point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text'); + } +} + +function getCollabNodeAndOffset( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sharedType: any, + offset: number, +): [ + ( + | null + | CollabDecoratorNode + | CollabElementNode + | CollabTextNode + | CollabLineBreakNode + ), + number, +] { + const collabNode = sharedType._collabNode; + + if (collabNode === undefined) { + return [null, 0]; + } + + if (collabNode instanceof CollabElementNode) { + const {node, offset: collabNodeOffset} = getPositionFromElementAndOffset( + collabNode, + offset, + true, + ); + + if (node === null) { + return [collabNode, 0]; + } else { + return [node, collabNodeOffset]; + } + } + + return [null, 0]; +} + +export function syncCursorPositions( + binding: Binding, + provider: Provider, +): void { + const awarenessStates = Array.from(provider.awareness.getStates()); + const localClientID = binding.clientID; + const cursors = binding.cursors; + const editor = binding.editor; + const nodeMap = editor._editorState._nodeMap; + const visitedClientIDs = new Set(); + + for (let i = 0; i < awarenessStates.length; i++) { + const awarenessState = awarenessStates[i]; + const [clientID, awareness] = awarenessState; + + if (clientID !== localClientID) { + visitedClientIDs.add(clientID); + const {anchorPos, focusPos, name, color, focusing} = awareness; + let selection = null; + + let cursor = cursors.get(clientID); + + if (cursor === undefined) { + cursor = createCursor(name, color); + cursors.set(clientID, cursor); + } + + if (anchorPos !== null && focusPos !== null && focusing) { + const anchorAbsPos = createAbsolutePosition(anchorPos, binding); + const focusAbsPos = createAbsolutePosition(focusPos, binding); + + if (anchorAbsPos !== null && focusAbsPos !== null) { + const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset( + anchorAbsPos.type, + anchorAbsPos.index, + ); + const [focusCollabNode, focusOffset] = getCollabNodeAndOffset( + focusAbsPos.type, + focusAbsPos.index, + ); + + if (anchorCollabNode !== null && focusCollabNode !== null) { + const anchorKey = anchorCollabNode.getKey(); + const focusKey = focusCollabNode.getKey(); + selection = cursor.selection; + + if (selection === null) { + selection = createCursorSelection( + cursor, + anchorKey, + anchorOffset, + focusKey, + focusOffset, + ); + } else { + const anchor = selection.anchor; + const focus = selection.focus; + anchor.key = anchorKey; + anchor.offset = anchorOffset; + focus.key = focusKey; + focus.offset = focusOffset; + } + } + } + } + + updateCursor(binding, cursor, selection, nodeMap); + } + } + + const allClientIDs = Array.from(cursors.keys()); + + for (let i = 0; i < allClientIDs.length; i++) { + const clientID = allClientIDs[i]; + + if (!visitedClientIDs.has(clientID)) { + const cursor = cursors.get(clientID); + + if (cursor !== undefined) { + destroyCursor(binding, cursor); + cursors.delete(clientID); + } + } + } +} + +export function syncLexicalSelectionToYjs( + binding: Binding, + provider: Provider, + prevSelection: null | BaseSelection, + nextSelection: null | BaseSelection, +): void { + const awareness = provider.awareness; + const localState = awareness.getLocalState(); + + if (localState === null) { + return; + } + + const { + anchorPos: currentAnchorPos, + focusPos: currentFocusPos, + name, + color, + focusing, + awarenessData, + } = localState; + let anchorPos = null; + let focusPos = null; + + if ( + nextSelection === null || + (currentAnchorPos !== null && !nextSelection.is(prevSelection)) + ) { + if (prevSelection === null) { + return; + } + } + + if ($isRangeSelection(nextSelection)) { + anchorPos = createRelativePosition(nextSelection.anchor, binding); + focusPos = createRelativePosition(nextSelection.focus, binding); + } + + if ( + shouldUpdatePosition(currentAnchorPos, anchorPos) || + shouldUpdatePosition(currentFocusPos, focusPos) + ) { + awareness.setLocalState({ + anchorPos, + awarenessData, + color, + focusPos, + focusing, + name, + }); + } +} diff --git a/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts b/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts new file mode 100644 index 000000000..c2dd07748 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/SyncEditorStates.ts @@ -0,0 +1,247 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {EditorState, NodeKey} from 'lexical'; + +import { + $createParagraphNode, + $getNodeByKey, + $getRoot, + $getSelection, + $isRangeSelection, + $isTextNode, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs'; + +import {Binding, Provider} from '.'; +import {CollabDecoratorNode} from './CollabDecoratorNode'; +import {CollabElementNode} from './CollabElementNode'; +import {CollabTextNode} from './CollabTextNode'; +import { + $syncLocalCursorPosition, + syncCursorPositions, + syncLexicalSelectionToYjs, +} from './SyncCursors'; +import { + $getOrInitCollabNodeFromSharedType, + $moveSelectionToPreviousNode, + doesSelectionNeedRecovering, + syncWithTransaction, +} from './Utils'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function $syncEvent(binding: Binding, event: any): void { + const {target} = event; + const collabNode = $getOrInitCollabNodeFromSharedType(binding, target); + + if (collabNode instanceof CollabElementNode && event instanceof YTextEvent) { + // @ts-expect-error We need to access the private property of the class + const {keysChanged, childListChanged, delta} = event; + + // Update + if (keysChanged.size > 0) { + collabNode.syncPropertiesFromYjs(binding, keysChanged); + } + + if (childListChanged) { + collabNode.applyChildrenYjsDelta(binding, delta); + collabNode.syncChildrenFromYjs(binding); + } + } else if ( + collabNode instanceof CollabTextNode && + event instanceof YMapEvent + ) { + const {keysChanged} = event; + + // Update + if (keysChanged.size > 0) { + collabNode.syncPropertiesAndTextFromYjs(binding, keysChanged); + } + } else if ( + collabNode instanceof CollabDecoratorNode && + event instanceof YXmlEvent + ) { + const {attributesChanged} = event; + + // Update + if (attributesChanged.size > 0) { + collabNode.syncPropertiesFromYjs(binding, attributesChanged); + } + } else { + invariant(false, 'Expected text, element, or decorator event'); + } +} + +export function syncYjsChangesToLexical( + binding: Binding, + provider: Provider, + events: Array>, + isFromUndoManger: boolean, +): void { + const editor = binding.editor; + const currentEditorState = editor._editorState; + + // This line precompute the delta before editor update. The reason is + // delta is computed when it is accessed. Note that this can only be + // safely computed during the event call. If it is accessed after event + // call it might result in unexpected behavior. + // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132 + events.forEach((event) => event.delta); + + editor.update( + () => { + for (let i = 0; i < events.length; i++) { + const event = events[i]; + $syncEvent(binding, event); + } + + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (doesSelectionNeedRecovering(selection)) { + const prevSelection = currentEditorState._selection; + + if ($isRangeSelection(prevSelection)) { + $syncLocalCursorPosition(binding, provider); + if (doesSelectionNeedRecovering(selection)) { + // If the selected node is deleted, move the selection to the previous or parent node. + const anchorNodeKey = selection.anchor.key; + $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState); + } + } + + syncLexicalSelectionToYjs( + binding, + provider, + prevSelection, + $getSelection(), + ); + } else { + $syncLocalCursorPosition(binding, provider); + } + } + }, + { + onUpdate: () => { + syncCursorPositions(binding, provider); + // If there was a collision on the top level paragraph + // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients, + // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'. + editor.update(() => { + if ($getRoot().getChildrenSize() === 0) { + $getRoot().append($createParagraphNode()); + } + }); + }, + skipTransforms: true, + tag: isFromUndoManger ? 'historic' : 'collaboration', + }, + ); +} + +function $handleNormalizationMergeConflicts( + binding: Binding, + normalizedNodes: Set, +): void { + // We handle the merge operations here + const normalizedNodesKeys = Array.from(normalizedNodes); + const collabNodeMap = binding.collabNodeMap; + const mergedNodes = []; + + for (let i = 0; i < normalizedNodesKeys.length; i++) { + const nodeKey = normalizedNodesKeys[i]; + const lexicalNode = $getNodeByKey(nodeKey); + const collabNode = collabNodeMap.get(nodeKey); + + if (collabNode instanceof CollabTextNode) { + if ($isTextNode(lexicalNode)) { + // We mutate the text collab nodes after removing + // all the dead nodes first, otherwise offsets break. + mergedNodes.push([collabNode, lexicalNode.__text]); + } else { + const offset = collabNode.getOffset(); + + if (offset === -1) { + continue; + } + + const parent = collabNode._parent; + collabNode._normalized = true; + + parent._xmlText.delete(offset, 1); + + collabNodeMap.delete(nodeKey); + const parentChildren = parent._children; + const index = parentChildren.indexOf(collabNode); + parentChildren.splice(index, 1); + } + } + } + + for (let i = 0; i < mergedNodes.length; i++) { + const [collabNode, text] = mergedNodes[i]; + if (collabNode instanceof CollabTextNode && typeof text === 'string') { + collabNode._text = text; + } + } +} + +type IntentionallyMarkedAsDirtyElement = boolean; + +export function syncLexicalUpdateToYjs( + binding: Binding, + provider: Provider, + prevEditorState: EditorState, + currEditorState: EditorState, + dirtyElements: Map, + dirtyLeaves: Set, + normalizedNodes: Set, + tags: Set, +): void { + syncWithTransaction(binding, () => { + currEditorState.read(() => { + // We check if the update has come from a origin where the origin + // was the collaboration binding previously. This can help us + // prevent unnecessarily re-diffing and possible re-applying + // the same change editor state again. For example, if a user + // types a character and we get it, we don't want to then insert + // the same character again. The exception to this heuristic is + // when we need to handle normalization merge conflicts. + if (tags.has('collaboration') || tags.has('historic')) { + if (normalizedNodes.size > 0) { + $handleNormalizationMergeConflicts(binding, normalizedNodes); + } + + return; + } + + if (dirtyElements.has('root')) { + const prevNodeMap = prevEditorState._nodeMap; + const nextLexicalRoot = $getRoot(); + const collabRoot = binding.root; + collabRoot.syncPropertiesFromLexical( + binding, + nextLexicalRoot, + prevNodeMap, + ); + collabRoot.syncChildrenFromLexical( + binding, + nextLexicalRoot, + prevNodeMap, + dirtyElements, + dirtyLeaves, + ); + } + + const selection = $getSelection(); + const prevSelection = prevEditorState._selection; + syncLexicalSelectionToYjs(binding, provider, prevSelection, selection); + }); + }); +} diff --git a/resources/js/wysiwyg/lexical/yjs/Utils.ts b/resources/js/wysiwyg/lexical/yjs/Utils.ts new file mode 100644 index 000000000..c0e6bc96d --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/Utils.ts @@ -0,0 +1,560 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding, YjsNode} from '.'; +import type { + DecoratorNode, + EditorState, + ElementNode, + LexicalNode, + RangeSelection, + TextNode, +} from 'lexical'; + +import { + $getNodeByKey, + $getRoot, + $isDecoratorNode, + $isElementNode, + $isLineBreakNode, + $isRootNode, + $isTextNode, + createEditor, + NodeKey, +} from 'lexical'; +import invariant from 'lexical/shared/invariant'; +import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs'; + +import { + $createCollabDecoratorNode, + CollabDecoratorNode, +} from './CollabDecoratorNode'; +import {$createCollabElementNode, CollabElementNode} from './CollabElementNode'; +import { + $createCollabLineBreakNode, + CollabLineBreakNode, +} from './CollabLineBreakNode'; +import {$createCollabTextNode, CollabTextNode} from './CollabTextNode'; + +const baseExcludedProperties = new Set([ + '__key', + '__parent', + '__next', + '__prev', +]); +const elementExcludedProperties = new Set([ + '__first', + '__last', + '__size', +]); +const rootExcludedProperties = new Set(['__cachedText']); +const textExcludedProperties = new Set(['__text']); + +function isExcludedProperty( + name: string, + node: LexicalNode, + binding: Binding, +): boolean { + if (baseExcludedProperties.has(name)) { + return true; + } + + if ($isTextNode(node)) { + if (textExcludedProperties.has(name)) { + return true; + } + } else if ($isElementNode(node)) { + if ( + elementExcludedProperties.has(name) || + ($isRootNode(node) && rootExcludedProperties.has(name)) + ) { + return true; + } + } + + const nodeKlass = node.constructor; + const excludedProperties = binding.excludedProperties.get(nodeKlass); + return excludedProperties != null && excludedProperties.has(name); +} + +export function getIndexOfYjsNode( + yjsParentNode: YjsNode, + yjsNode: YjsNode, +): number { + let node = yjsParentNode.firstChild; + let i = -1; + + if (node === null) { + return -1; + } + + do { + i++; + + if (node === yjsNode) { + return i; + } + + // @ts-expect-error Sibling exists but type is not available from YJS. + node = node.nextSibling; + + if (node === null) { + return -1; + } + } while (node !== null); + + return i; +} + +export function $getNodeByKeyOrThrow(key: NodeKey): LexicalNode { + const node = $getNodeByKey(key); + invariant(node !== null, 'could not find node by key'); + return node; +} + +export function $createCollabNodeFromLexicalNode( + binding: Binding, + lexicalNode: LexicalNode, + parent: CollabElementNode, +): + | CollabElementNode + | CollabTextNode + | CollabLineBreakNode + | CollabDecoratorNode { + const nodeType = lexicalNode.__type; + let collabNode; + + if ($isElementNode(lexicalNode)) { + const xmlText = new XmlText(); + collabNode = $createCollabElementNode(xmlText, parent, nodeType); + collabNode.syncPropertiesFromLexical(binding, lexicalNode, null); + collabNode.syncChildrenFromLexical(binding, lexicalNode, null, null, null); + } else if ($isTextNode(lexicalNode)) { + // TODO create a token text node for token, segmented nodes. + const map = new YMap(); + collabNode = $createCollabTextNode( + map, + lexicalNode.__text, + parent, + nodeType, + ); + collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null); + } else if ($isLineBreakNode(lexicalNode)) { + const map = new YMap(); + map.set('__type', 'linebreak'); + collabNode = $createCollabLineBreakNode(map, parent); + } else if ($isDecoratorNode(lexicalNode)) { + const xmlElem = new XmlElement(); + collabNode = $createCollabDecoratorNode(xmlElem, parent, nodeType); + collabNode.syncPropertiesFromLexical(binding, lexicalNode, null); + } else { + invariant(false, 'Expected text, element, decorator, or linebreak node'); + } + + collabNode._key = lexicalNode.__key; + return collabNode; +} + +function getNodeTypeFromSharedType( + sharedType: XmlText | YMap | XmlElement, +): string { + const type = + sharedType instanceof YMap + ? sharedType.get('__type') + : sharedType.getAttribute('__type'); + invariant(type != null, 'Expected shared type to include type attribute'); + return type; +} + +export function $getOrInitCollabNodeFromSharedType( + binding: Binding, + sharedType: XmlText | YMap | XmlElement, + parent?: CollabElementNode, +): + | CollabElementNode + | CollabTextNode + | CollabLineBreakNode + | CollabDecoratorNode { + const collabNode = sharedType._collabNode; + + if (collabNode === undefined) { + const registeredNodes = binding.editor._nodes; + const type = getNodeTypeFromSharedType(sharedType); + const nodeInfo = registeredNodes.get(type); + invariant(nodeInfo !== undefined, 'Node %s is not registered', type); + + const sharedParent = sharedType.parent; + const targetParent = + parent === undefined && sharedParent !== null + ? $getOrInitCollabNodeFromSharedType( + binding, + sharedParent as XmlText | YMap | XmlElement, + ) + : parent || null; + + invariant( + targetParent instanceof CollabElementNode, + 'Expected parent to be a collab element node', + ); + + if (sharedType instanceof XmlText) { + return $createCollabElementNode(sharedType, targetParent, type); + } else if (sharedType instanceof YMap) { + if (type === 'linebreak') { + return $createCollabLineBreakNode(sharedType, targetParent); + } + return $createCollabTextNode(sharedType, '', targetParent, type); + } else if (sharedType instanceof XmlElement) { + return $createCollabDecoratorNode(sharedType, targetParent, type); + } + } + + return collabNode; +} + +export function createLexicalNodeFromCollabNode( + binding: Binding, + collabNode: + | CollabElementNode + | CollabTextNode + | CollabDecoratorNode + | CollabLineBreakNode, + parentKey: NodeKey, +): LexicalNode { + const type = collabNode.getType(); + const registeredNodes = binding.editor._nodes; + const nodeInfo = registeredNodes.get(type); + invariant(nodeInfo !== undefined, 'Node %s is not registered', type); + const lexicalNode: + | DecoratorNode + | TextNode + | ElementNode + | LexicalNode = new nodeInfo.klass(); + lexicalNode.__parent = parentKey; + collabNode._key = lexicalNode.__key; + + if (collabNode instanceof CollabElementNode) { + const xmlText = collabNode._xmlText; + collabNode.syncPropertiesFromYjs(binding, null); + collabNode.applyChildrenYjsDelta(binding, xmlText.toDelta()); + collabNode.syncChildrenFromYjs(binding); + } else if (collabNode instanceof CollabTextNode) { + collabNode.syncPropertiesAndTextFromYjs(binding, null); + } else if (collabNode instanceof CollabDecoratorNode) { + collabNode.syncPropertiesFromYjs(binding, null); + } + + binding.collabNodeMap.set(lexicalNode.__key, collabNode); + return lexicalNode; +} + +export function syncPropertiesFromYjs( + binding: Binding, + sharedType: XmlText | YMap | XmlElement, + lexicalNode: LexicalNode, + keysChanged: null | Set, +): void { + const properties = + keysChanged === null + ? sharedType instanceof YMap + ? Array.from(sharedType.keys()) + : Object.keys(sharedType.getAttributes()) + : Array.from(keysChanged); + let writableNode; + + for (let i = 0; i < properties.length; i++) { + const property = properties[i]; + if (isExcludedProperty(property, lexicalNode, binding)) { + continue; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prevValue = (lexicalNode as any)[property]; + let nextValue = + sharedType instanceof YMap + ? sharedType.get(property) + : sharedType.getAttribute(property); + + if (prevValue !== nextValue) { + if (nextValue instanceof Doc) { + const yjsDocMap = binding.docMap; + + if (prevValue instanceof Doc) { + yjsDocMap.delete(prevValue.guid); + } + + const nestedEditor = createEditor(); + const key = nextValue.guid; + nestedEditor._key = key; + yjsDocMap.set(key, nextValue); + + nextValue = nestedEditor; + } + + if (writableNode === undefined) { + writableNode = lexicalNode.getWritable(); + } + + writableNode[property as keyof typeof writableNode] = nextValue; + } + } +} + +export function syncPropertiesFromLexical( + binding: Binding, + sharedType: XmlText | YMap | XmlElement, + prevLexicalNode: null | LexicalNode, + nextLexicalNode: LexicalNode, +): void { + const type = nextLexicalNode.__type; + const nodeProperties = binding.nodeProperties; + let properties = nodeProperties.get(type); + if (properties === undefined) { + properties = Object.keys(nextLexicalNode).filter((property) => { + return !isExcludedProperty(property, nextLexicalNode, binding); + }); + nodeProperties.set(type, properties); + } + + const EditorClass = binding.editor.constructor; + + for (let i = 0; i < properties.length; i++) { + const property = properties[i]; + const prevValue = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prevLexicalNode === null ? undefined : (prevLexicalNode as any)[property]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let nextValue = (nextLexicalNode as any)[property]; + + if (prevValue !== nextValue) { + if (nextValue instanceof EditorClass) { + const yjsDocMap = binding.docMap; + let prevDoc; + + if (prevValue instanceof EditorClass) { + const prevKey = prevValue._key; + prevDoc = yjsDocMap.get(prevKey); + yjsDocMap.delete(prevKey); + } + + // If we already have a document, use it. + const doc = prevDoc || new Doc(); + const key = doc.guid; + nextValue._key = key; + yjsDocMap.set(key, doc); + nextValue = doc; + // Mark the node dirty as we've assigned a new key to it + binding.editor.update(() => { + nextLexicalNode.markDirty(); + }); + } + + if (sharedType instanceof YMap) { + sharedType.set(property, nextValue); + } else { + sharedType.setAttribute(property, nextValue); + } + } + } +} + +export function spliceString( + str: string, + index: number, + delCount: number, + newText: string, +): string { + return str.slice(0, index) + newText + str.slice(index + delCount); +} + +export function getPositionFromElementAndOffset( + node: CollabElementNode, + offset: number, + boundaryIsEdge: boolean, +): { + length: number; + node: + | CollabElementNode + | CollabTextNode + | CollabDecoratorNode + | CollabLineBreakNode + | null; + nodeIndex: number; + offset: number; +} { + let index = 0; + let i = 0; + const children = node._children; + const childrenLength = children.length; + + for (; i < childrenLength; i++) { + const child = children[i]; + const childOffset = index; + const size = child.getSize(); + index += size; + const exceedsBoundary = boundaryIsEdge ? index >= offset : index > offset; + + if (exceedsBoundary && child instanceof CollabTextNode) { + let textOffset = offset - childOffset - 1; + + if (textOffset < 0) { + textOffset = 0; + } + + const diffLength = index - offset; + return { + length: diffLength, + node: child, + nodeIndex: i, + offset: textOffset, + }; + } + + if (index > offset) { + return { + length: 0, + node: child, + nodeIndex: i, + offset: childOffset, + }; + } else if (i === childrenLength - 1) { + return { + length: 0, + node: null, + nodeIndex: i + 1, + offset: childOffset + 1, + }; + } + } + + return { + length: 0, + node: null, + nodeIndex: 0, + offset: 0, + }; +} + +export function doesSelectionNeedRecovering( + selection: RangeSelection, +): boolean { + const anchor = selection.anchor; + const focus = selection.focus; + let recoveryNeeded = false; + + try { + const anchorNode = anchor.getNode(); + const focusNode = focus.getNode(); + + if ( + // We might have removed a node that no longer exists + !anchorNode.isAttached() || + !focusNode.isAttached() || + // If we've split a node, then the offset might not be right + ($isTextNode(anchorNode) && + anchor.offset > anchorNode.getTextContentSize()) || + ($isTextNode(focusNode) && focus.offset > focusNode.getTextContentSize()) + ) { + recoveryNeeded = true; + } + } catch (e) { + // Sometimes checking nor a node via getNode might trigger + // an error, so we need recovery then too. + recoveryNeeded = true; + } + + return recoveryNeeded; +} + +export function syncWithTransaction(binding: Binding, fn: () => void): void { + binding.doc.transact(fn, binding); +} + +export function removeFromParent(node: LexicalNode): void { + const oldParent = node.getParent(); + if (oldParent !== null) { + const writableNode = node.getWritable(); + const writableParent = oldParent.getWritable(); + const prevSibling = node.getPreviousSibling(); + const nextSibling = node.getNextSibling(); + // TODO: this function duplicates a bunch of operations, can be simplified. + if (prevSibling === null) { + if (nextSibling !== null) { + const writableNextSibling = nextSibling.getWritable(); + writableParent.__first = nextSibling.__key; + writableNextSibling.__prev = null; + } else { + writableParent.__first = null; + } + } else { + const writablePrevSibling = prevSibling.getWritable(); + if (nextSibling !== null) { + const writableNextSibling = nextSibling.getWritable(); + writableNextSibling.__prev = writablePrevSibling.__key; + writablePrevSibling.__next = writableNextSibling.__key; + } else { + writablePrevSibling.__next = null; + } + writableNode.__prev = null; + } + if (nextSibling === null) { + if (prevSibling !== null) { + const writablePrevSibling = prevSibling.getWritable(); + writableParent.__last = prevSibling.__key; + writablePrevSibling.__next = null; + } else { + writableParent.__last = null; + } + } else { + const writableNextSibling = nextSibling.getWritable(); + if (prevSibling !== null) { + const writablePrevSibling = prevSibling.getWritable(); + writablePrevSibling.__next = writableNextSibling.__key; + writableNextSibling.__prev = writablePrevSibling.__key; + } else { + writableNextSibling.__prev = null; + } + writableNode.__next = null; + } + writableParent.__size--; + writableNode.__parent = null; + } +} + +export function $moveSelectionToPreviousNode( + anchorNodeKey: string, + currentEditorState: EditorState, +) { + const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey); + if (!anchorNode) { + $getRoot().selectStart(); + return; + } + // Get previous node + const prevNodeKey = anchorNode.__prev; + let prevNode: ElementNode | null = null; + if (prevNodeKey) { + prevNode = $getNodeByKey(prevNodeKey); + } + + // If previous node not found, get parent node + if (prevNode === null && anchorNode.__parent !== null) { + prevNode = $getNodeByKey(anchorNode.__parent); + } + if (prevNode === null) { + $getRoot().selectStart(); + return; + } + + if (prevNode !== null && prevNode.isAttached()) { + prevNode.selectEnd(); + return; + } else { + // If the found node is also deleted, select the next one + $moveSelectionToPreviousNode(prevNode.__key, currentEditorState); + } +} diff --git a/resources/js/wysiwyg/lexical/yjs/index.ts b/resources/js/wysiwyg/lexical/yjs/index.ts new file mode 100644 index 000000000..248e34426 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/index.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Binding} from './Bindings'; +import type {LexicalCommand} from 'lexical'; +import type {Doc, RelativePosition, UndoManager, XmlText} from 'yjs'; + +import {createCommand} from 'lexical'; +import {UndoManager as YjsUndoManager} from 'yjs'; + +export type UserState = { + anchorPos: null | RelativePosition; + color: string; + focusing: boolean; + focusPos: null | RelativePosition; + name: string; + awarenessData: object; +}; +export const CONNECTED_COMMAND: LexicalCommand = + createCommand('CONNECTED_COMMAND'); +export const TOGGLE_CONNECT_COMMAND: LexicalCommand = createCommand( + 'TOGGLE_CONNECT_COMMAND', +); +export type ProviderAwareness = { + getLocalState: () => UserState | null; + getStates: () => Map; + off: (type: 'update', cb: () => void) => void; + on: (type: 'update', cb: () => void) => void; + setLocalState: (arg0: UserState) => void; +}; +declare interface Provider { + awareness: ProviderAwareness; + connect(): void | Promise; + disconnect(): void; + off(type: 'sync', cb: (isSynced: boolean) => void): void; + off(type: 'update', cb: (arg0: unknown) => void): void; + off(type: 'status', cb: (arg0: {status: string}) => void): void; + off(type: 'reload', cb: (doc: Doc) => void): void; + on(type: 'sync', cb: (isSynced: boolean) => void): void; + on(type: 'status', cb: (arg0: {status: string}) => void): void; + on(type: 'update', cb: (arg0: unknown) => void): void; + on(type: 'reload', cb: (doc: Doc) => void): void; +} +export type Operation = { + attributes: { + __type: string; + }; + insert: string | Record; +}; +export type Delta = Array; +export type YjsNode = Record; +export type YjsEvent = Record; +export type {Provider}; +export type {Binding, ClientID, ExcludedProperties} from './Bindings'; +export {createBinding} from './Bindings'; + +export function createUndoManager( + binding: Binding, + root: XmlText, +): UndoManager { + return new YjsUndoManager(root, { + trackedOrigins: new Set([binding, null]), + }); +} + +export function initLocalState( + provider: Provider, + name: string, + color: string, + focusing: boolean, + awarenessData: object, +): void { + provider.awareness.setLocalState({ + anchorPos: null, + awarenessData, + color, + focusPos: null, + focusing: focusing, + name, + }); +} + +export function setLocalStateFocus( + provider: Provider, + name: string, + color: string, + focusing: boolean, + awarenessData: object, +): void { + const {awareness} = provider; + let localState = awareness.getLocalState(); + + if (localState === null) { + localState = { + anchorPos: null, + awarenessData, + color, + focusPos: null, + focusing: focusing, + name, + }; + } + + localState.focusing = focusing; + awareness.setLocalState(localState); +} +export {syncCursorPositions} from './SyncCursors'; +export { + syncLexicalUpdateToYjs, + syncYjsChangesToLexical, +} from './SyncEditorStates'; diff --git a/resources/js/wysiwyg/lexical/yjs/types.ts b/resources/js/wysiwyg/lexical/yjs/types.ts new file mode 100644 index 000000000..d8807a288 --- /dev/null +++ b/resources/js/wysiwyg/lexical/yjs/types.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {CollabDecoratorNode} from './src/CollabDecoratorNode'; +import {CollabElementNode} from './src/CollabElementNode'; +import {CollabLineBreakNode} from './src/CollabLineBreakNode'; +import {CollabTextNode} from './src/CollabTextNode'; + +declare module 'yjs' { + interface XmlElement { + _collabNode: CollabDecoratorNode; + } + + interface XmlText { + _collabNode: CollabElementNode; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Map { + _collabNode: CollabLineBreakNode | CollabTextNode; + } +} diff --git a/tsconfig.json b/tsconfig.json index 0be5421c7..4026872ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,112 +1,21 @@ { "include": ["resources/js/**/*"], "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ - "rootDir": "./resources/js/", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ - "@icons/*": ["./resources/icons/*"] + "target": "es2019", + "module": "commonjs", + "rootDir": "./resources/js/", + "baseUrl": "./", + "paths": { + "@icons/*": ["resources/icons/*"], + "lexical": ["resources/js/wysiwyg/lexical/core/index.ts"], + "lexical/*": ["resources/js/wysiwyg/lexical/core/*"], + "@lexical/*": ["resources/js/wysiwyg/lexical/*"] }, - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "resolveJsonModule": true, + "allowJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true } } From ccd486f2a9b96527742a556988a4cbf168b5f4ff Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 18 Sep 2024 17:31:51 +0100 Subject: [PATCH 097/107] Lexical: Got a range of Editor tests working --- jest.config.ts | 7 +- .../js/wysiwyg/lexical/core/LexicalEditor.ts | 7 + .../core/__tests__/unit/CodeBlock.test.ts | 144 -------- .../__tests__/unit/HTMLCopyAndPaste.test.ts | 2 +- .../core/__tests__/unit/LexicalEditor.test.ts | 348 ++++-------------- .../__tests__/unit/LexicalListPlugin.test.tsx | 2 +- .../lexical/core/__tests__/utils/index.ts | 54 +-- .../unit/LexicalAutoLinkNode.test.ts | 2 +- .../__tests__/unit/LexicalLinkNode.test.ts | 2 +- .../unit/LexicalListItemNode.test.ts | 2 +- .../__tests__/unit/LexicalListNode.test.ts | 2 +- .../lexical/list/__tests__/unit/utils.test.ts | 2 +- .../__tests__/unit/LexicalHeadingNode.test.ts | 2 +- .../__tests__/unit/LexicalQuoteNode.test.ts | 2 +- .../__tests__/unit/LexicalSelection.test.tsx | 2 +- .../unit/LexicalSelectionHelpers.test.ts | 2 +- .../unit/LexicalTableCellNode.test.ts | 2 +- .../__tests__/unit/LexicalTableNode.test.tsx | 2 +- .../unit/LexicalTableRowNode.test.ts | 2 +- .../unit/LexicalTableSelection.test.tsx | 2 +- .../unit/LexicalEventHelpers.test.tsx | 2 +- .../__tests__/unit/LexicalNodeHelpers.test.ts | 2 +- .../__tests__/unit/LexicalRootHelpers.test.ts | 2 +- .../unit/LexicalUtilsKlassEqual.test.ts | 2 +- .../unit/LexicalUtilsSplitNode.test.tsx | 2 +- ...xlcaiUtilsInsertNodeToNearestRoot.test.tsx | 2 +- 26 files changed, 116 insertions(+), 486 deletions(-) delete mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts diff --git a/jest.config.ts b/jest.config.ts index 0243b39cd..11a86c672 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -93,10 +93,13 @@ const config: Config = { // "node" // ], - modulePaths: ['/home/dan/web/bookstack/'], + modulePaths: ['./'], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), + moduleNameMapper: { + 'lexical/shared/invariant': 'resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant', + ...pathsToModuleNameMapper(compilerOptions.paths), + }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts index b0b90002e..092429156 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -1236,6 +1236,13 @@ export class LexicalEditor { } } + /** + * Commits any currently pending updates scheduled for the editor. + */ + commitUpdates(): void { + $commitPendingUpdates(this); + } + /** * Removes focus from the editor. */ diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts deleted file mode 100644 index 5d6a9311b..000000000 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/CodeBlock.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {$insertDataTransferForRichText} from '@lexical/clipboard'; -import { - $createParagraphNode, - $getRoot, - $getSelection, - $isRangeSelection, -} from 'lexical'; -import { - DataTransferMock, - initializeUnitTest, - invariant, -} from 'lexical/__tests__/utils'; - -describe('CodeBlock tests', () => { - initializeUnitTest( - (testEnv) => { - beforeEach(async () => { - const {editor} = testEnv; - await editor.update(() => { - const root = $getRoot(); - const paragraph = $createParagraphNode(); - root.append(paragraph); - paragraph.select(); - }); - }); - - /** - * Code example for tests: - * - * function run() { - * return [null, undefined, 2, ""]; - * } - * - */ - const EXPECTED_HTML = `function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `; - - const CODE_PASTING_TESTS = [ - { - expectedHTML: EXPECTED_HTML, - name: 'VS Code', - pastedHTML: `
                                          function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `, - }, - { - expectedHTML: EXPECTED_HTML, - name: 'Quip', - pastedHTML: `
                                          function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `, - }, - { - expectedHTML: EXPECTED_HTML, - name: 'WebStorm / Idea', - pastedHTML: `
                                          function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `, - }, - { - expectedHTML: `function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `, - name: 'Postman IDE', - pastedHTML: `
                                          function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `, - }, - { - expectedHTML: EXPECTED_HTML, - name: 'Slack message', - pastedHTML: `
                                          function run() {\n  return [null, undefined, 2, ""];\n}
                                          `, - }, - { - expectedHTML: `const Lexical = requireCond('gk', 'runtime_is_dev', {
                                          true: 'Lexical.dev',
                                          false: 'Lexical.prod',
                                          });
                                          `, - name: 'CodeHub', - pastedHTML: `
                                          const Lexical = requireCond('gk', 'runtime_is_dev', {
                                          true: 'Lexical.dev',
                                          false: 'Lexical.prod',
                                          });
                                          `, - }, - { - expectedHTML: EXPECTED_HTML, - name: 'GitHub / Gist', - pastedHTML: `
                                          function run() {
                                          return [null, undefined, 2, ""];
                                          }
                                          `, - }, - { - expectedHTML: `

                                          12

                                          `, - name: 'Single line ', - pastedHTML: `12`, - }, - { - expectedHTML: `1
                                          2
                                          `, - name: 'Multiline ', - // TODO This is not correct. This resembles how Lexical exports code right now but - // semantically it should be wrapped in a pre - pastedHTML: `1
                                          2
                                          `, - }, - { - expectedHTML: `

                                          Hello World Lexical

                                          `, - name: 'Multiple text formats', - pastedHTML: `Hello World Lexical`, - }, - { - expectedHTML: `

                                          My document

                                          `, - name: 'Title from Google Docs', - pastedHTML: `My document`, - }, - { - expectedHTML: `

                                          My document

                                          `, - name: 'Title from Google Docs Wrapped in Paragraph', - pastedHTML: `

                                          My document

                                          `, - }, - { - expectedHTML: `

                                          subscript and superscript

                                          `, - name: 'Subscript and Superscript', - pastedHTML: `subscript and superscript`, - }, - ]; - - CODE_PASTING_TESTS.forEach((testCase, i) => { - test(`Code block html paste: ${testCase.name}`, async () => { - const {editor} = testEnv; - - const dataTransfer = new DataTransferMock(); - dataTransfer.setData('text/html', testCase.pastedHTML); - await editor.update(() => { - const selection = $getSelection(); - invariant( - $isRangeSelection(selection), - 'isRangeSelection(selection)', - ); - $insertDataTransferForRichText(dataTransfer, selection, editor); - }); - expect(testEnv.innerHTML).toBe(testCase.expectedHTML); - }); - }); - }, - { - namespace: 'test', - theme: { - text: { - bold: 'editor-text-bold', - italic: 'editor-text-italic', - underline: 'editor-text-underline', - }, - }, - }, - ); -}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts index b14654838..056de19e4 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -17,7 +17,7 @@ import { DataTransferMock, initializeUnitTest, invariant, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; describe('HTMLCopyAndPaste tests', () => { initializeUnitTest( diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts index 4ca6b77c8..4e3e622ce 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -57,11 +57,13 @@ import { describe('LexicalEditor tests', () => { let container: HTMLElement; - let reactRoot: Root; + function setContainerChild(el: HTMLElement) { + container.innerHTML = ''; + container.append(el); + } beforeEach(() => { container = document.createElement('div'); - reactRoot = createRoot(container); document.body.appendChild(container); }); @@ -74,49 +76,33 @@ describe('LexicalEditor tests', () => { }); function useLexicalEditor( - rootElementRef: React.RefObject, + rootElement: HTMLDivElement, onError?: (error: Error) => void, nodes?: ReadonlyArray | LexicalNodeReplacement>, ) { - const editor = useMemo( - () => - createTestEditor({ - nodes: nodes ?? [], - onError: onError || jest.fn(), - theme: { - text: { - bold: 'editor-text-bold', - italic: 'editor-text-italic', - underline: 'editor-text-underline', - }, - }, - }), - [onError, nodes], - ); - - useEffect(() => { - const rootElement = rootElementRef.current; - - editor.setRootElement(rootElement); - }, [rootElementRef, editor]); - + const editor = createTestEditor({ + nodes: nodes ?? [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }); + editor.setRootElement(rootElement); return editor; } let editor: LexicalEditor; function init(onError?: (error: Error) => void) { - const ref = createRef(); + const edContainer = document.createElement('div'); + edContainer.setAttribute('contenteditable', 'true'); - function TestBase() { - editor = useLexicalEditor(ref, onError); - - return
                                          ; - } - - ReactTestUtils.act(() => { - reactRoot.render(); - }); + setContainerChild(edContainer); + editor = useLexicalEditor(edContainer, onError); } async function update(fn: () => void) { @@ -870,21 +856,12 @@ describe('LexicalEditor tests', () => { }); it('Should be able to update an editor state without a root element', () => { - const ref = createRef(); + const element = document.createElement('div'); + element.setAttribute('contenteditable', 'true'); + setContainerChild(element); - function TestBase({element}: {element: HTMLElement | null}) { - editor = useMemo(() => createTestEditor(), []); + editor = createTestEditor(); - useEffect(() => { - editor.setRootElement(element); - }, [element]); - - return
                                          ; - } - - ReactTestUtils.act(() => { - reactRoot.render(); - }); editor.update(() => { const root = $getRoot(); const paragraph = $createParagraphNode(); @@ -895,9 +872,7 @@ describe('LexicalEditor tests', () => { expect(container.innerHTML).toBe('
                                          '); - ReactTestUtils.act(() => { - reactRoot.render(); - }); + editor.setRootElement(element); expect(container.innerHTML).toBe( '

                                          This works!

                                          ', @@ -945,57 +920,48 @@ describe('LexicalEditor tests', () => { const rootListener = jest.fn(); const updateListener = jest.fn(); - function TestBase({changeElement}: {changeElement: boolean}) { - editor = useMemo(() => createTestEditor(), []); + let editorInstance = createTestEditor(); + editorInstance.registerRootListener(rootListener); + editorInstance.registerUpdateListener(updateListener); - useEffect(() => { - editor.update(() => { - const root = $getRoot(); - const firstChild = root.getFirstChild() as ParagraphNode | null; - const text = changeElement ? 'Change successful' : 'Not changed'; + let edContainer: HTMLElement = document.createElement('div'); + edContainer.setAttribute('contenteditable', 'true'); + setContainerChild(edContainer); + editorInstance.setRootElement(edContainer); - if (firstChild === null) { - const paragraph = $createParagraphNode(); - const textNode = $createTextNode(text); - paragraph.append(textNode); - root.append(paragraph); - } else { - const textNode = firstChild.getFirstChild() as TextNode; - textNode.setTextContent(text); - } - }); - }, [changeElement]); + function runUpdate(changeElement: boolean) { + editorInstance.update(() => { + const root = $getRoot(); + const firstChild = root.getFirstChild() as ParagraphNode | null; + const text = changeElement ? 'Change successful' : 'Not changed'; - useEffect(() => { - return editor.registerRootListener(rootListener); - }, []); - - useEffect(() => { - return editor.registerUpdateListener(updateListener); - }, []); - - const ref = useCallback((node: HTMLElement | null) => { - editor.setRootElement(node); - }, []); - - return changeElement ? ( - - ) : ( -
                                          - ); + if (firstChild === null) { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraph.append(textNode); + root.append(paragraph); + } else { + const textNode = firstChild.getFirstChild() as TextNode; + textNode.setTextContent(text); + } + }); } - await ReactTestUtils.act(() => { - reactRoot.render(); - }); + setContainerChild(edContainer); + editorInstance.setRootElement(edContainer); + runUpdate(false); + editorInstance.commitUpdates(); expect(container.innerHTML).toBe( '

                                          Not changed

                                          ', ); - await ReactTestUtils.act(() => { - reactRoot.render(); - }); + edContainer = document.createElement('span'); + edContainer.setAttribute('contenteditable', 'true'); + runUpdate(true); + editorInstance.setRootElement(edContainer); + setContainerChild(edContainer); + editorInstance.commitUpdates(); expect(rootListener).toHaveBeenCalledTimes(3); expect(updateListener).toHaveBeenCalledTimes(3); @@ -1026,178 +992,6 @@ describe('LexicalEditor tests', () => { }); } - describe('With node decorators', () => { - function useDecorators() { - const [decorators, setDecorators] = useState(() => - editor.getDecorators(), - ); - - // Subscribe to changes - useEffect(() => { - return editor.registerDecoratorListener((nextDecorators) => { - setDecorators(nextDecorators); - }); - }, []); - - const decoratedPortals = useMemo( - () => - Object.keys(decorators).map((nodeKey) => { - const reactDecorator = decorators[nodeKey]; - const element = editor.getElementByKey(nodeKey)!; - - return createPortal(reactDecorator, element); - }), - [decorators], - ); - - return decoratedPortals; - } - - afterEach(async () => { - // Clean up so we are not calling setState outside of act - await ReactTestUtils.act(async () => { - reactRoot.render(null); - await Promise.resolve().then(); - }); - }); - - it('Should correctly render React component into Lexical node #1', async () => { - const listener = jest.fn(); - - function Test() { - editor = useMemo(() => createTestEditor(), []); - - useEffect(() => { - editor.registerRootListener(listener); - }, []); - - const ref = useCallback((node: HTMLDivElement | null) => { - editor.setRootElement(node); - }, []); - - const decorators = useDecorators(); - - return ( - <> -
                                          - {decorators} - - ); - } - - ReactTestUtils.act(() => { - reactRoot.render(); - }); - // Update the editor with the decorator - await ReactTestUtils.act(async () => { - await editor.update(() => { - const paragraph = $createParagraphNode(); - const test = $createTestDecoratorNode(); - paragraph.append(test); - $getRoot().append(paragraph); - }); - }); - - expect(listener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( - '

                                          ' + - 'Hello world

                                          ', - ); - }); - - it('Should correctly render React component into Lexical node #2', async () => { - const listener = jest.fn(); - - function Test({divKey}: {divKey: number}): JSX.Element { - function TestPlugin() { - [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerRootListener(listener); - }, []); - - return null; - } - - return ( - - - } - placeholder={null} - ErrorBoundary={LexicalErrorBoundary} - /> - - - ); - } - - await ReactTestUtils.act(async () => { - reactRoot.render(); - // Wait for update to complete - await Promise.resolve().then(); - }); - - expect(listener).toHaveBeenCalledTimes(1); - expect(container.innerHTML).toBe( - '


                                          ', - ); - - await ReactTestUtils.act(async () => { - reactRoot.render(); - // Wait for update to complete - await Promise.resolve().then(); - }); - - expect(listener).toHaveBeenCalledTimes(5); - expect(container.innerHTML).toBe( - '


                                          ', - ); - - // Wait for update to complete - await Promise.resolve().then(); - - editor.getEditorState().read(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild()!; - expect(root).toEqual({ - __cachedText: '', - __dir: null, - __first: paragraph.getKey(), - __format: 0, - __indent: 0, - __key: 'root', - __last: paragraph.getKey(), - __next: null, - __parent: null, - __prev: null, - __size: 1, - __style: '', - __type: 'root', - }); - expect(paragraph).toEqual({ - __dir: null, - __first: null, - __format: 0, - __indent: 0, - __key: paragraph.getKey(), - __last: null, - __next: null, - __parent: 'root', - __prev: null, - __size: 0, - __style: '', - __textFormat: 0, - __textStyle: '', - __type: 'paragraph', - }); - }); - }); - }); - describe('parseEditorState()', () => { let originalText: TextNode; let parsedParagraph: ParagraphNode; @@ -1872,10 +1666,12 @@ describe('LexicalEditor tests', () => { }); it('mutation listener set for original node should work with the replaced node', async () => { - const ref = createRef(); function TestBase() { - editor = useLexicalEditor(ref, undefined, [ + const edContainer = document.createElement('div'); + edContainer.contentEditable = 'true'; + + editor = useLexicalEditor(edContainer, undefined, [ TestTextNode, { replace: TextNode, @@ -1884,12 +1680,10 @@ describe('LexicalEditor tests', () => { }, ]); - return
                                          ; + return edContainer; } - ReactTestUtils.act(() => { - reactRoot.render(); - }); + setContainerChild(TestBase()); const textNodeMutations = jest.fn(); const textNodeMutationsB = jest.fn(); @@ -1969,10 +1763,12 @@ describe('LexicalEditor tests', () => { }); it('mutation listener should work with the replaced node', async () => { - const ref = createRef(); function TestBase() { - editor = useLexicalEditor(ref, undefined, [ + const edContainer = document.createElement('div'); + edContainer.contentEditable = 'true'; + + editor = useLexicalEditor(edContainer, undefined, [ TestTextNode, { replace: TextNode, @@ -1981,12 +1777,10 @@ describe('LexicalEditor tests', () => { }, ]); - return
                                          ; + return edContainer; } - ReactTestUtils.act(() => { - reactRoot.render(); - }); + setContainerChild(TestBase()); const textNodeMutations = jest.fn(); const textNodeMutationsB = jest.fn(); @@ -2581,6 +2375,8 @@ describe('LexicalEditor tests', () => { expect(false).toBe('unreachable'); }); + newEditor.commitUpdates(); + expect(onError).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringMatching(/TestTextNode.*re-use key.*TextNode/), diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx index a2968c259..5493b6962 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx @@ -20,7 +20,7 @@ import { expectHtmlToBeEqual, html, TestComposer, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import {createRoot, Root} from 'react-dom/client'; import * as ReactTestUtils from 'lexical/shared/react-test-utils'; diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index b7ccfab1e..090ace10d 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -29,7 +29,6 @@ import { SerializedTextNode, TextNode, } from 'lexical'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; import { CreateEditorArgs, @@ -89,38 +88,13 @@ export function initializeUnitTest( testEnv.container = document.createElement('div'); document.body.appendChild(testEnv.container); - const useLexicalEditor = ( - rootElementRef: React.RefObject, - ) => { - const lexicalEditor = React.useMemo(() => { - const lexical = createTestEditor(editorConfig); - return lexical; - }, []); + const editorEl = document.createElement('div'); + editorEl.contentEditable = 'true'; + testEnv.container.append(editorEl); - React.useEffect(() => { - const rootElement = rootElementRef.current; - lexicalEditor.setRootElement(rootElement); - }, [rootElementRef, lexicalEditor]); - return lexicalEditor; - }; - - const Editor = () => { - testEnv.editor = useLexicalEditor(ref); - const context = createLexicalComposerContext( - null, - editorConfig?.theme ?? {}, - ); - return ( - -
                                          - {plugins} - - ); - }; - - ReactTestUtils.act(() => { - createRoot(testEnv.container).render(); - }); + const lexicalEditor = createTestEditor(editorConfig); + lexicalEditor.setRootElement(editorEl); + testEnv.editor = lexicalEditor; }); afterEach(() => { @@ -381,7 +355,7 @@ export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElem export type SerializedTestDecoratorNode = SerializedLexicalNode; -export class TestDecoratorNode extends DecoratorNode { +export class TestDecoratorNode extends DecoratorNode { static getType(): string { return 'test_decorator'; } @@ -433,32 +407,26 @@ export class TestDecoratorNode extends DecoratorNode { } decorate() { - return ; + const decorator = document.createElement('span'); + decorator.textContent = 'Hello world'; + return decorator; } } -function Decorator({text}: {text: string}): JSX.Element { - return {text}; -} - export function $createTestDecoratorNode(): TestDecoratorNode { return new TestDecoratorNode(); } -const DEFAULT_NODES: NonNullable = [ +const DEFAULT_NODES: NonNullable | LexicalNodeReplacement>> = [ HeadingNode, ListNode, ListItemNode, QuoteNode, - CodeNode, TableNode, TableCellNode, TableRowNode, - HashtagNode, - CodeHighlightNode, AutoLinkNode, LinkNode, - OverflowNode, TestElementNode, TestSegmentedNode, TestExcludeFromCopyElementNode, diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts index 8ef2aa051..ffcefd7c8 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -20,7 +20,7 @@ import { SerializedParagraphNode, TextNode, } from 'lexical/src'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts index 3ad6cbad8..fe978849b 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts @@ -20,7 +20,7 @@ import { SerializedParagraphNode, TextNode, } from 'lexical/src'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index d36b8f1cb..a1ccd5020 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -16,7 +16,7 @@ import { expectHtmlToBeEqual, html, initializeUnitTest, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import { $createListItemNode, diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts index 6abcbbd4c..497e096b1 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts @@ -6,7 +6,7 @@ * */ import {ParagraphNode, TextNode} from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; import { $createListItemNode, diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts index 1fa327379..ba3971289 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/utils.test.ts @@ -6,7 +6,7 @@ * */ import {$createParagraphNode, $getRoot} from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; import {$createListItemNode, $createListNode} from '../..'; import {$getListDepth, $getTopListNode, $isLastItemInList} from '../../utils'; diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts index 057999ba0..dcbd62ab3 100644 --- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts @@ -18,7 +18,7 @@ import { ParagraphNode, RangeSelection, } from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts index e64c41880..66374bf5f 100644 --- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts @@ -8,7 +8,7 @@ import {$createQuoteNode, QuoteNode} from '@lexical/rich-text'; import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx index e60867831..68e9dcab5 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx @@ -50,7 +50,7 @@ import { initializeClipboard, invariant, TestComposer, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import {createRoot, Root} from 'react-dom/client'; import * as ReactTestUtils from 'lexical/shared/react-test-utils'; diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts index 01390ed71..7b5bef451 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -41,7 +41,7 @@ import { createTestHeadlessEditor, invariant, TestDecoratorNode, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import {$setAnchorPoint, $setFocusPoint} from '../utils'; diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts index 9c56db63b..70b327866 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableCellNode.test.ts @@ -7,7 +7,7 @@ */ import {$createTableCellNode, TableCellHeaderStates} from '@lexical/table'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx index b11b99490..37049e598 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx @@ -28,7 +28,7 @@ import { DataTransferMock, initializeUnitTest, invariant, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import {$getElementForTableNode, TableNode} from '../../LexicalTableNode'; diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts index cf110634b..285d587bf 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts @@ -7,7 +7,7 @@ */ import {$createTableRowNode} from '@lexical/table'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx index 5eb631c31..9e9dbac81 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx @@ -18,7 +18,7 @@ import { RootNode, TextNode, } from 'lexical'; -import {createTestEditor} from 'lexical/src/__tests__/utils'; +import {createTestEditor} from 'lexical/__tests__/utils'; import {createRef, useEffect, useMemo} from 'react'; import {createRoot, Root} from 'react-dom/client'; import * as ReactTestUtils from 'lexical/shared/react-test-utils'; diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx index 2b49e3bd7..2d5db2c69 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx @@ -23,7 +23,7 @@ import { } from '@lexical/selection/src/__tests__/utils'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import {LexicalEditor} from 'lexical'; -import {initializeClipboard, TestComposer} from 'lexical/src/__tests__/utils'; +import {initializeClipboard, TestComposer} from 'lexical/__tests__/utils'; import {createRoot} from 'react-dom/client'; import * as ReactTestUtils from 'lexical/shared/react-test-utils'; diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts index 82d2dddf8..1d994e140 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalNodeHelpers.test.ts @@ -19,7 +19,7 @@ import { $createTestElementNode, initializeUnitTest, invariant, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import {$dfs} from '../..'; diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts index 070107583..369caaea4 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts @@ -12,7 +12,7 @@ import { $rootTextContent, } from '@lexical/text'; import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; describe('LexicalRootHelpers tests', () => { initializeUnitTest((testEnv) => { diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts index b4b18ef01..a62b7bae1 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsKlassEqual.test.ts @@ -7,7 +7,7 @@ */ import {objectKlassEquals} from '@lexical/utils'; -import {initializeUnitTest} from 'lexical/src/__tests__/utils'; +import {initializeUnitTest} from 'lexical/__tests__/utils'; class MyEvent extends Event {} diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx index f3db39390..f04bb5d2e 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx @@ -10,7 +10,7 @@ import type {ElementNode, LexicalEditor} from 'lexical'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; import {$getRoot, $isElementNode} from 'lexical'; -import {createTestEditor} from 'lexical/src/__tests__/utils'; +import {createTestEditor} from 'lexical/__tests__/utils'; import {$splitNode} from '../../index'; diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx index 0e46573e7..9664b2d80 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx @@ -18,7 +18,7 @@ import { import { $createTestDecoratorNode, createTestEditor, -} from 'lexical/src/__tests__/utils'; +} from 'lexical/__tests__/utils'; import {$insertNodeToNearestRoot} from '../..'; From 787e06e3d8bc0dbf79cf01db20bc2de845732529 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 20 Sep 2024 13:05:29 +0100 Subject: [PATCH 098/107] Lexical: Adapted a range of further existing tests --- .../__tests__/unit/LexicalListPlugin.test.tsx | 212 ------------------ ....test.tsx => LexicalNormalization.test.ts} | 0 .../unit/LexicalSerialization.test.ts | 8 +- .../lexical/core/__tests__/utils/index.ts | 2 +- ...de.test.tsx => LexicalElementNode.test.ts} | 32 +-- .../{LexicalGC.test.tsx => LexicalGC.test.ts} | 0 ...abNode.test.tsx => LexicalTabNode.test.ts} | 129 ----------- ...tNode.test.tsx => LexicalTextNode.test.ts} | 32 +-- .../html/__tests__/unit/LexicalHtml.test.ts | 1 - 9 files changed, 17 insertions(+), 399 deletions(-) delete mode 100644 resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx rename resources/js/wysiwyg/lexical/core/__tests__/unit/{LexicalNormalization.test.tsx => LexicalNormalization.test.ts} (100%) rename resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/{LexicalElementNode.test.tsx => LexicalElementNode.test.ts} (95%) rename resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/{LexicalGC.test.tsx => LexicalGC.test.ts} (100%) rename resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/{LexicalTabNode.test.tsx => LexicalTabNode.test.ts} (58%) rename resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/{LexicalTextNode.test.tsx => LexicalTextNode.test.ts} (97%) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx deleted file mode 100644 index 5493b6962..000000000 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalListPlugin.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import {ListItemNode, ListNode} from '@lexical/list'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {ContentEditable} from '@lexical/react/LexicalContentEditable'; -import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; -import {ListPlugin} from '@lexical/react/LexicalListPlugin'; -import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; -import { - INDENT_CONTENT_COMMAND, - LexicalEditor, - OUTDENT_CONTENT_COMMAND, -} from 'lexical'; -import { - expectHtmlToBeEqual, - html, - TestComposer, -} from 'lexical/__tests__/utils'; -import {createRoot, Root} from 'react-dom/client'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; - -import { - INSERT_UNORDERED_LIST_COMMAND, - REMOVE_LIST_COMMAND, -} from '../../../../lexical-list/src/index'; - -describe('@lexical/list tests', () => { - let container: HTMLDivElement; - let reactRoot: Root; - - beforeEach(() => { - container = document.createElement('div'); - reactRoot = createRoot(container); - document.body.appendChild(container); - }); - - afterEach(() => { - container.remove(); - // @ts-ignore - container = null; - - jest.restoreAllMocks(); - }); - - // Shared instance across tests - let editor: LexicalEditor; - - function Test(): JSX.Element { - function TestPlugin() { - // Plugin used just to get our hands on the Editor object - [editor] = useLexicalComposerContext(); - return null; - } - - return ( - - } - placeholder={ -
                                          Enter some text...
                                          - } - ErrorBoundary={LexicalErrorBoundary} - /> - - -
                                          - ); - } - - test('Toggle an empty list on/off', async () => { - ReactTestUtils.act(() => { - reactRoot.render(); - }); - - await ReactTestUtils.act(async () => { - await editor.update(() => { - editor.focus(); - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - }); - }); - - expectHtmlToBeEqual( - container.innerHTML, - html` -
                                          -
                                            -
                                          • -
                                            -
                                          • -
                                          -
                                          - `, - ); - - await ReactTestUtils.act(async () => { - await editor.update(() => { - editor.focus(); - editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); - }); - }); - - expectHtmlToBeEqual( - container.innerHTML, - html` -
                                          -

                                          -
                                          -

                                          -
                                          -
                                          Enter some text...
                                          - `, - ); - }); - - test('Can create a list and indent/outdent it', async () => { - ReactTestUtils.act(() => { - reactRoot.render(); - }); - - await ReactTestUtils.act(async () => { - await editor.update(() => { - editor.focus(); - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - }); - }); - - expectHtmlToBeEqual( - container.innerHTML, - html` -
                                          -
                                            -
                                          • -
                                            -
                                          • -
                                          -
                                          - `, - ); - - await ReactTestUtils.act(async () => { - await editor.update(() => { - editor.focus(); - editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); - }); - }); - - expectHtmlToBeEqual( - container.innerHTML, - html` -
                                          -
                                            -
                                          • -
                                              -

                                            • -
                                            -
                                          • -
                                          -
                                          - `, - ); - - await ReactTestUtils.act(async () => { - await editor.update(() => { - editor.focus(); - editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); - }); - }); - - expectHtmlToBeEqual( - container.innerHTML, - html` -
                                          -
                                            -
                                          • -
                                            -
                                          • -
                                          -
                                          - `, - ); - }); -}); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.ts similarity index 100% rename from resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.tsx rename to resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNormalization.test.ts diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts index 9237bc9d3..02231f8bf 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -6,7 +6,6 @@ * */ -import {$createCodeHighlightNode, $createCodeNode} from '@lexical/code'; import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text'; @@ -92,9 +91,6 @@ function $createEditorContent() { ), ); root.append(paragraph4); - const codeBlock = $createCodeNode('javascript'); - codeBlock.append($createCodeHighlightNode('const lexical = "awesome"')); - root.append(codeBlock); const table = $createTableNodeWithDimensions(5, 5, true); root.append(table); } @@ -110,7 +106,7 @@ describe('LexicalSerialization tests', () => { }); const stringifiedEditorState = JSON.stringify(editor.getEditorState()); - const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"const lexical = \\"awesome\\"","type":"code-highlight","version":1}],"direction":"ltr","format":"","indent":0,"type":"code","version":1,"language":"javascript"},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1}],"direction":"ltr","format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; + const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1}],"direction":"ltr","format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; expect(stringifiedEditorState).toBe(expectedStringifiedEditorState); @@ -119,7 +115,7 @@ describe('LexicalSerialization tests', () => { const otherStringifiedEditorState = JSON.stringify(editorState); expect(otherStringifiedEditorState).toBe( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"const lexical = \\"awesome\\"","type":"code-highlight","version":1}],"direction":"ltr","format":"","indent":0,"type":"code","version":1,"language":"javascript"},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, ); }); }); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 090ace10d..30b0bc8c8 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -89,7 +89,7 @@ export function initializeUnitTest( document.body.appendChild(testEnv.container); const editorEl = document.createElement('div'); - editorEl.contentEditable = 'true'; + editorEl.setAttribute('contenteditable', 'true'); testEnv.container.append(editorEl); const lexicalEditor = createTestEditor(editorConfig); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts similarity index 95% rename from resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx rename to resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts index e165df7a9..fb5c98f8a 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.tsx +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts @@ -16,10 +16,6 @@ import { LexicalNode, TextNode, } from 'lexical'; -import * as React from 'react'; -import {createRef, useEffect} from 'react'; -import {createRoot} from 'react-dom/client'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; import { $createTestElementNode, @@ -44,34 +40,20 @@ describe('LexicalElementNode tests', () => { async function update(fn: () => void) { editor.update(fn); + editor.commitUpdates(); return Promise.resolve().then(); } - function useLexicalEditor(rootElementRef: React.RefObject) { - const editor = React.useMemo(() => createTestEditor(), []); - - useEffect(() => { - const rootElement = rootElementRef.current; - editor.setRootElement(rootElement); - }, [rootElementRef, editor]); - - return editor; - } - let editor: LexicalEditor; async function init() { - const ref = createRef(); + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.innerHTML = ''; + container.appendChild(root); - function TestBase() { - editor = useLexicalEditor(ref); - - return
                                          ; - } - - ReactTestUtils.act(() => { - createRoot(container).render(); - }); + editor = createTestEditor(); + editor.setRootElement(root); // Insert initial block await update(() => { diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.ts similarity index 100% rename from resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.tsx rename to resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalGC.test.ts diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts similarity index 58% rename from resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx rename to resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts index 0c06273ec..2d751f5fd 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.tsx +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts @@ -11,7 +11,6 @@ import { $insertDataTransferForRichText, } from '@lexical/clipboard'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {registerTabIndentation} from '@lexical/react/LexicalTabIndentationPlugin'; import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; import { $createParagraphNode, @@ -112,134 +111,6 @@ describe('LexicalTabNode tests', () => { ); }); - test('element indents when selection at the start of the block', async () => { - const {editor} = testEnv; - registerRichText(editor); - registerTabIndentation(editor); - await editor.update(() => { - const selection = $getSelection()!; - selection.insertText('foo'); - $getRoot().selectStart(); - }); - await editor.dispatchCommand( - KEY_TAB_COMMAND, - new KeyboardEvent('keydown'), - ); - expect(testEnv.innerHTML).toBe( - '

                                          foo

                                          ', - ); - }); - - test('elements indent when selection spans across multiple blocks', async () => { - const {editor} = testEnv; - registerRichText(editor); - registerTabIndentation(editor); - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - invariant($isElementNode(paragraph)); - const heading = $createHeadingNode('h1'); - const list = $createListNode('number'); - const listItem = $createListItemNode(); - const paragraphText = $createTextNode('foo'); - const headingText = $createTextNode('bar'); - const listItemText = $createTextNode('xyz'); - root.append(heading, list); - paragraph.append(paragraphText); - heading.append(headingText); - list.append(listItem); - listItem.append(listItemText); - const selection = $createRangeSelection(); - selection.focus.set(paragraphText.getKey(), 1, 'text'); - selection.anchor.set(listItemText.getKey(), 1, 'text'); - $setSelection(selection); - }); - await editor.dispatchCommand( - KEY_TAB_COMMAND, - new KeyboardEvent('keydown'), - ); - expect(testEnv.innerHTML).toBe( - '

                                          foo

                                          bar

                                            1. xyz
                                          ', - ); - }); - - test('element tabs when selection is not at the start (1)', async () => { - const {editor} = testEnv; - registerRichText(editor); - registerTabIndentation(editor); - await editor.update(() => { - $getSelection()!.insertText('foo'); - }); - await editor.dispatchCommand( - KEY_TAB_COMMAND, - new KeyboardEvent('keydown'), - ); - expect(testEnv.innerHTML).toBe( - '

                                          foo\t

                                          ', - ); - }); - - test('element tabs when selection is not at the start (2)', async () => { - const {editor} = testEnv; - registerRichText(editor); - registerTabIndentation(editor); - await editor.update(() => { - $getSelection()!.insertText('foo'); - const textNode = $getRoot().getLastDescendant(); - invariant($isTextNode(textNode)); - textNode.select(1, 1); - }); - await editor.dispatchCommand( - KEY_TAB_COMMAND, - new KeyboardEvent('keydown'), - ); - expect(testEnv.innerHTML).toBe( - '

                                          f\too

                                          ', - ); - }); - - test('element tabs when selection is not at the start (3)', async () => { - const {editor} = testEnv; - registerRichText(editor); - registerTabIndentation(editor); - await editor.update(() => { - $getSelection()!.insertText('foo'); - const textNode = $getRoot().getLastDescendant(); - invariant($isTextNode(textNode)); - textNode.select(1, 2); - }); - await editor.dispatchCommand( - KEY_TAB_COMMAND, - new KeyboardEvent('keydown'), - ); - expect(testEnv.innerHTML).toBe( - '

                                          f\to

                                          ', - ); - }); - - test('elements tabs when selection is not at the start and overlaps another tab', async () => { - const {editor} = testEnv; - registerRichText(editor); - registerTabIndentation(editor); - await editor.update(() => { - $getSelection()!.insertRawText('hello\tworld'); - const root = $getRoot(); - const firstTextNode = root.getFirstDescendant(); - const lastTextNode = root.getLastDescendant(); - const selection = $createRangeSelection(); - selection.anchor.set(firstTextNode!.getKey(), 'hell'.length, 'text'); - selection.focus.set(lastTextNode!.getKey(), 'wo'.length, 'text'); - $setSelection(selection); - }); - await editor.dispatchCommand( - KEY_TAB_COMMAND, - new KeyboardEvent('keydown'), - ); - expect(testEnv.innerHTML).toBe( - '

                                          hell\trld

                                          ', - ); - }); - test('can type between two (leaf nodes) canInsertBeforeAfter false', async () => { const {editor} = testEnv; await editor.update(() => { diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts similarity index 97% rename from resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx rename to resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts index 7fc509dfd..337c96a41 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.tsx +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts @@ -21,9 +21,6 @@ import { TextModeType, TextNode, } from 'lexical'; -import * as React from 'react'; -import {createRef, useEffect, useMemo} from 'react'; -import {createRoot} from 'react-dom/client'; import * as ReactTestUtils from 'lexical/shared/react-test-utils'; import { @@ -78,35 +75,20 @@ describe('LexicalTextNode tests', () => { async function update(fn: () => void) { editor.update(fn); + editor.commitUpdates(); return Promise.resolve().then(); } - function useLexicalEditor(rootElementRef: React.RefObject) { - const editor = useMemo(() => createTestEditor(editorConfig), []); - - useEffect(() => { - const rootElement = rootElementRef.current; - - editor.setRootElement(rootElement); - }, [rootElementRef, editor]); - - return editor; - } - let editor: LexicalEditor; async function init() { - const ref = createRef(); + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.innerHTML = ''; + container.appendChild(root); - function TestBase() { - editor = useLexicalEditor(ref); - - return
                                          ; - } - - ReactTestUtils.act(() => { - createRoot(container).render(); - }); + editor = createTestEditor(); + editor.setRootElement(root); // Insert initial block await update(() => { diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts index 55d120bdd..3dbe5da8b 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -112,7 +112,6 @@ describe('HTML', () => { ListNode, ListItemNode, QuoteNode, - CodeNode, LinkNode, ], }); From dba8ab947fef64ce4d482045941aa8f6ed153818 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 20 Sep 2024 15:31:19 +0100 Subject: [PATCH 099/107] Lexical: Finished conversion/update of test files --- jest.config.ts | 9 +- .../lexical/core/__tests__/utils/index.ts | 8 + .../__tests__/unit/LexicalTextNode.test.ts | 1 - .../lexical/core/shared/react-test-utils.ts | 18 - .../unit/LexicalAutoLinkNode.test.ts | 2 +- .../__tests__/unit/LexicalLinkNode.test.ts | 2 +- .../unit/LexicalListItemNode.test.ts | 182 ++- .../wysiwyg/lexical/list/__tests__/utils.ts | 33 - ...tion.test.tsx => LexicalSelection.test.ts} | 1074 ++++++----------- ...Node.test.tsx => LexicalTableNode.test.ts} | 206 ---- ...test.tsx => LexicalTableSelection.test.ts} | 61 +- ...s.test.tsx => LexicalEventHelpers.test.ts} | 191 +-- .../__tests__/unit/LexicalRootHelpers.test.ts | 35 +- ...test.tsx => LexicalUtilsSplitNode.test.ts} | 0 ...xlcaiUtilsInsertNodeToNearestRoot.test.ts} | 0 15 files changed, 578 insertions(+), 1244 deletions(-) delete mode 100644 resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts delete mode 100644 resources/js/wysiwyg/lexical/list/__tests__/utils.ts rename resources/js/wysiwyg/lexical/selection/__tests__/unit/{LexicalSelection.test.tsx => LexicalSelection.test.ts} (73%) rename resources/js/wysiwyg/lexical/table/__tests__/unit/{LexicalTableNode.test.tsx => LexicalTableNode.test.ts} (53%) rename resources/js/wysiwyg/lexical/table/__tests__/unit/{LexicalTableSelection.test.tsx => LexicalTableSelection.test.ts} (73%) rename resources/js/wysiwyg/lexical/utils/__tests__/unit/{LexicalEventHelpers.test.tsx => LexicalEventHelpers.test.ts} (85%) rename resources/js/wysiwyg/lexical/utils/__tests__/unit/{LexicalUtilsSplitNode.test.tsx => LexicalUtilsSplitNode.test.ts} (100%) rename resources/js/wysiwyg/lexical/utils/__tests__/unit/{LexlcaiUtilsInsertNodeToNearestRoot.test.tsx => LexlcaiUtilsInsertNodeToNearestRoot.test.ts} (100%) diff --git a/jest.config.ts b/jest.config.ts index 11a86c672..3c04f05b2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -21,7 +21,7 @@ const config: Config = { clearMocks: true, // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected // collectCoverageFrom: undefined, @@ -164,10 +164,9 @@ const config: Config = { // testLocationInResults: false, // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], + testMatch: [ + "**/__tests__/**/*.test.[jt]s", + ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 30b0bc8c8..f7230595a 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -717,3 +717,11 @@ export function html( } return output; } + +export function expectHtmlToBeEqual(expected: string, actual: string): void { + expect(formatHtml(expected)).toBe(formatHtml(actual)); +} + +function formatHtml(s: string): string { + return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts index 337c96a41..57e1dcb3b 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts @@ -21,7 +21,6 @@ import { TextModeType, TextNode, } from 'lexical'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; import { $createTestSegmentedNode, diff --git a/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts b/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts deleted file mode 100644 index 8e086744d..000000000 --- a/resources/js/wysiwyg/lexical/core/shared/react-test-utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import * as React from 'react'; -import * as ReactTestUtils from 'react-dom/test-utils'; - -/** - * React 19 moved act from react-dom/test-utils to react - * https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-react-dom-test-utils - */ -export const act = - 'act' in React - ? (React.act as typeof ReactTestUtils.act) - : ReactTestUtils.act; diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts index ffcefd7c8..0f3513682 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -19,7 +19,7 @@ import { ParagraphNode, SerializedParagraphNode, TextNode, -} from 'lexical/src'; +} from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts index fe978849b..1aff91863 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts @@ -19,7 +19,7 @@ import { ParagraphNode, SerializedParagraphNode, TextNode, -} from 'lexical/src'; +} from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; const editorConfig = Object.freeze({ diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index a1ccd5020..22e555f35 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -62,7 +62,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( listItemNode.createDOM(editorConfig).outerHTML, html` -
                                        1. +
                                        2. `, ); @@ -90,7 +90,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                                        3. +
                                        4. `, ); const newListItemNode = new ListItemNode(); @@ -106,7 +106,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                                        5. +
                                        6. `, ); }); @@ -125,7 +125,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                                        7. +
                                        8. `, ); const nestedListNode = new ListNode('bullet', 1); @@ -142,9 +142,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                                        9. +
                                        10. `, ); }); @@ -184,13 +182,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          • -
                                          • +
                                          • two
                                          • -
                                          • +
                                          • three
                                          @@ -217,13 +215,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • bar
                                          • -
                                          • +
                                          • two
                                          • -
                                          • +
                                          • three
                                          @@ -247,13 +245,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          • -
                                          • +
                                          • two
                                          • -
                                          • +
                                          • three
                                          @@ -273,12 +271,12 @@ describe('LexicalListItemNode tests', () => { contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"> -


                                          +


                                            -
                                          • +
                                          • two
                                          • -
                                          • +
                                          • three
                                          @@ -303,14 +301,14 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          • -
                                          • +
                                          • two
                                          -


                                          +


                                          `, ); @@ -332,13 +330,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          -


                                          +


                                            -
                                          • +
                                          • three
                                          @@ -363,7 +361,7 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          @@ -383,7 +381,7 @@ describe('LexicalListItemNode tests', () => { contenteditable="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true"> -


                                          +


                                          `, ); @@ -423,13 +421,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • A
                                          • -
                                          • +
                                          • x
                                          • -
                                          • +
                                          • B
                                          @@ -447,10 +445,10 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • A
                                          • -
                                          • +
                                          • B
                                          @@ -497,15 +495,15 @@ describe('LexicalListItemNode tests', () => {
                                            • -
                                            • +
                                            • A
                                          • -
                                          • +
                                          • x
                                          • -
                                          • +
                                          • B
                                          @@ -525,12 +523,12 @@ describe('LexicalListItemNode tests', () => {
                                            • -
                                            • +
                                            • A
                                          • -
                                          • +
                                          • B
                                          @@ -575,15 +573,15 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • A
                                          • -
                                          • +
                                          • x
                                            • -
                                            • +
                                            • B
                                            @@ -603,12 +601,12 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                              -
                                            • +
                                            • A
                                              • -
                                              • +
                                              • B
                                              @@ -661,17 +659,17 @@ describe('LexicalListItemNode tests', () => {
                                                • -
                                                • +
                                                • A
                                              • -
                                              • +
                                              • x
                                                • -
                                                • +
                                                • B
                                                @@ -693,10 +691,10 @@ describe('LexicalListItemNode tests', () => {
                                                  • -
                                                  • +
                                                  • A
                                                  • -
                                                  • +
                                                  • B
                                                  @@ -757,24 +755,24 @@ describe('LexicalListItemNode tests', () => {
                                                    • -
                                                    • +
                                                    • A1
                                                      • -
                                                      • +
                                                      • A2
                                                  • -
                                                  • +
                                                  • x
                                                    • -
                                                    • +
                                                    • B
                                                    @@ -796,17 +794,17 @@ describe('LexicalListItemNode tests', () => {
                                                      • -
                                                      • +
                                                      • A1
                                                        • -
                                                        • +
                                                        • A2
                                                      • -
                                                      • +
                                                      • B
                                                      @@ -867,24 +865,24 @@ describe('LexicalListItemNode tests', () => {
                                                        • -
                                                        • +
                                                        • A
                                                      • -
                                                      • +
                                                      • x
                                                          • -
                                                          • +
                                                          • B1
                                                        • -
                                                        • +
                                                        • B2
                                                        @@ -906,17 +904,17 @@ describe('LexicalListItemNode tests', () => {
                                                          • -
                                                          • +
                                                          • A
                                                            • -
                                                            • +
                                                            • B1
                                                          • -
                                                          • +
                                                          • B2
                                                          @@ -985,31 +983,31 @@ describe('LexicalListItemNode tests', () => {
                                                            • -
                                                            • +
                                                            • A1
                                                              • -
                                                              • +
                                                              • A2
                                                          • -
                                                          • +
                                                          • x
                                                              • -
                                                              • +
                                                              • B1
                                                            • -
                                                            • +
                                                            • B2
                                                            @@ -1031,20 +1029,20 @@ describe('LexicalListItemNode tests', () => {
                                                              • -
                                                              • +
                                                              • A1
                                                                • -
                                                                • +
                                                                • A2
                                                                • -
                                                                • +
                                                                • B1
                                                              • -
                                                              • +
                                                              • B2
                                                              @@ -1089,13 +1087,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                                -
                                                              • +
                                                              • one
                                                              • -
                                                              • +
                                                              • two
                                                              • -
                                                              • +
                                                              • three
                                                              @@ -1119,14 +1117,14 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                                -
                                                              • +
                                                              • one
                                                              • -

                                                              • -
                                                              • +

                                                              • +
                                                              • two
                                                              • -
                                                              • +
                                                              • three
                                                              @@ -1150,16 +1148,16 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                                -
                                                              • +
                                                              • one
                                                              • -
                                                              • +
                                                              • two
                                                              • -
                                                              • +
                                                              • three
                                                              • -

                                                              • +

                                          `, @@ -1181,16 +1179,16 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          • -
                                          • +
                                          • two
                                          • -
                                          • +
                                          • three
                                          • -

                                          • +

                                          `, @@ -1213,7 +1211,7 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          @@ -1233,10 +1231,10 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                            -
                                          • +
                                          • one
                                          • -

                                          • +

                                          `, @@ -1310,7 +1308,7 @@ describe('LexicalListItemNode tests', () => {
                                            • -
                                            • +
                                            • one
                                            @@ -1319,7 +1317,7 @@ describe('LexicalListItemNode tests', () => {
                                          -
                                        11. +
                                        12. two
                                      @@ -1338,10 +1336,10 @@ describe('LexicalListItemNode tests', () => { editor.getRootElement()!.innerHTML, html`
                                        -
                                      • +
                                      • one
                                      • -
                                      • +
                                      • two
                                      diff --git a/resources/js/wysiwyg/lexical/list/__tests__/utils.ts b/resources/js/wysiwyg/lexical/list/__tests__/utils.ts deleted file mode 100644 index aa95a7a01..000000000 --- a/resources/js/wysiwyg/lexical/list/__tests__/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import {expect} from '@playwright/test'; -import prettier from 'prettier'; - -// This tag function is just used to trigger prettier auto-formatting. -// (https://prettier.io/blog/2020/08/24/2.1.0.html#api) -export function html( - partials: TemplateStringsArray, - ...params: string[] -): string { - let output = ''; - for (let i = 0; i < partials.length; i++) { - output += partials[i]; - if (i < partials.length - 1) { - output += params[i]; - } - } - return output; -} - -export function expectHtmlToBeEqual(expected: string, actual: string): void { - expect(prettifyHtml(expected)).toBe(prettifyHtml(actual)); -} - -export function prettifyHtml(s: string): string { - return prettier.format(s.replace(/\n/g, ''), {parser: 'html'}); -} diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts similarity index 73% rename from resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx rename to resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts index 68e9dcab5..665f5d854 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.tsx +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -8,13 +8,7 @@ import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {ContentEditable} from '@lexical/react/LexicalContentEditable'; -import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; -import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; -import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; -import {$createHeadingNode} from '@lexical/rich-text'; +import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; import { $addNodeStyle, $getSelectionStyleValueForProperty, @@ -28,7 +22,7 @@ import { $createRangeSelection, $createTextNode, $getRoot, - $getSelection, + $getSelection, $insertNodes, $isElementNode, $isRangeSelection, $isTextNode, @@ -49,10 +43,7 @@ import { createTestEditor, initializeClipboard, invariant, - TestComposer, } from 'lexical/__tests__/utils'; -import {createRoot, Root} from 'react-dom/client'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; import { $setAnchorPoint, @@ -81,6 +72,8 @@ import { setNativeSelectionWithPaths, undo, } from '../utils'; +import {createEmptyHistoryState, registerHistory} from "@lexical/history"; +import {mergeRegister} from "@lexical/utils"; interface ExpectedSelection { anchorPath: number[]; @@ -118,89 +111,72 @@ Range.prototype.getBoundingClientRect = function (): DOMRect { describe('LexicalSelection tests', () => { let container: HTMLElement; - let reactRoot: Root; + let root: HTMLDivElement; let editor: LexicalEditor | null = null; beforeEach(async () => { container = document.createElement('div'); document.body.appendChild(container); - reactRoot = createRoot(container); + + root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.append(root); + await init(); }); afterEach(async () => { - // Ensure we are clearing out any React state and running effects with - // act - await ReactTestUtils.act(async () => { - reactRoot.unmount(); - await Promise.resolve().then(); - }); document.body.removeChild(container); }); async function init() { - function TestBase() { - function TestPlugin() { - [editor] = useLexicalComposerContext(); - return null; + editor = createTestEditor({ + nodes: [], + theme: { + code: 'editor-code', + heading: { + h1: 'editor-heading-h1', + h2: 'editor-heading-h2', + h3: 'editor-heading-h3', + h4: 'editor-heading-h4', + h5: 'editor-heading-h5', + h6: 'editor-heading-h6', + }, + image: 'editor-image', + list: { + ol: 'editor-list-ol', + ul: 'editor-list-ul', + }, + listitem: 'editor-listitem', + paragraph: 'editor-paragraph', + quote: 'editor-quote', + text: { + bold: 'editor-text-bold', + code: 'editor-text-code', + hashtag: 'editor-text-hashtag', + italic: 'editor-text-italic', + link: 'editor-text-link', + strikethrough: 'editor-text-strikethrough', + underline: 'editor-text-underline', + underlineStrikethrough: 'editor-text-underlineStrikethrough', + }, } - - return ( - - - } - placeholder={null} - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - ); - } - - await ReactTestUtils.act(async () => { - reactRoot.render(); - await Promise.resolve().then(); }); - await Promise.resolve().then(); + mergeRegister( + registerHistory(editor, createEmptyHistoryState(), 300), + registerRichText(editor), + ); + + editor.setRootElement(root); + editor.update(() => { + const p = $createParagraphNode(); + $insertNodes([p]); + }); + editor.commitUpdates(); + editor.focus(); + // Focus first element setNativeSelectionWithPaths( editor!.getRootElement()!, @@ -212,9 +188,8 @@ describe('LexicalSelection tests', () => { } async function update(fn: () => void) { - await ReactTestUtils.act(async () => { - await editor!.update(fn); - }); + editor!.update(fn); + editor!.commitUpdates(); } test('Expect initial output to be a block with no text.', () => { @@ -734,301 +709,6 @@ describe('LexicalSelection tests', () => { name: 'Format selection that contains a token node in the middle should format the token node', }, - // Tests need fixing: - // ...GRAPHEME_SCENARIOS.flatMap(({description, grapheme}) => [ - // { - // name: `Delete backward eliminates entire ${description} (${grapheme})`, - // inputs: [insertText(grapheme + grapheme), deleteBackward(1)], - // expectedHTML: `

                                      ${grapheme}

                                      `, - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: grapheme.length, - // focusPath: [0, 0, 0], - // focusOffset: grapheme.length, - // }, - // setup: emptySetup, - // }, - // { - // name: `Delete forward eliminates entire ${description} (${grapheme})`, - // inputs: [ - // insertText(grapheme + grapheme), - // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), - // deleteForward(), - // ], - // expectedHTML: `

                                      ${grapheme}

                                      `, - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 0, - // focusPath: [0, 0, 0], - // focusOffset: 0, - // }, - // setup: emptySetup, - // }, - // { - // name: `Move backward skips over grapheme cluster (${grapheme})`, - // inputs: [insertText(grapheme + grapheme), moveBackward(1)], - // expectedHTML: `

                                      ${grapheme}${grapheme}

                                      `, - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: grapheme.length, - // focusPath: [0, 0, 0], - // focusOffset: grapheme.length, - // }, - // setup: emptySetup, - // }, - // { - // name: `Move forward skips over grapheme cluster (${grapheme})`, - // inputs: [ - // insertText(grapheme + grapheme), - // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), - // moveForward(), - // ], - // expectedHTML: `

                                      ${grapheme}${grapheme}

                                      `, - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: grapheme.length, - // focusPath: [0, 0, 0], - // focusOffset: grapheme.length, - // }, - // setup: emptySetup, - // }, - // ]), - // { - // name: 'Jump to beginning and insert', - // inputs: [ - // insertText('1'), - // insertText('1'), - // insertText('2'), - // insertText('3'), - // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 0), - // insertText('a'), - // insertText('b'), - // insertText('c'), - // deleteForward(), - // ], - // expectedHTML: - // '

                                      abc123

                                      ', - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 3, - // focusPath: [0, 0, 0], - // focusOffset: 3, - // }, - // }, - // { - // name: 'Select and replace', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // insertText('lexical'), - // ], - // expectedHTML: - // '

                                      Hello lexical!

                                      ', - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 13, - // focusPath: [0, 0, 0], - // focusOffset: 13, - // }, - // }, - // { - // name: 'Select and bold', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // formatBold(), - // ], - // expectedHTML: - // '

                                      Hello ' + - // 'draft!

                                      ', - // expectedSelection: { - // anchorPath: [0, 1, 0], - // anchorOffset: 0, - // focusPath: [0, 1, 0], - // focusOffset: 5, - // }, - // }, - // { - // name: 'Select and italic', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // formatItalic(), - // ], - // expectedHTML: - // '

                                      Hello ' + - // 'draft!

                                      ', - // expectedSelection: { - // anchorPath: [0, 1, 0], - // anchorOffset: 0, - // focusPath: [0, 1, 0], - // focusOffset: 5, - // }, - // }, - // { - // name: 'Select and bold + italic', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // formatBold(), - // formatItalic(), - // ], - // expectedHTML: - // '

                                      Hello ' + - // 'draft!

                                      ', - // expectedSelection: { - // anchorPath: [0, 1, 0], - // anchorOffset: 0, - // focusPath: [0, 1, 0], - // focusOffset: 5, - // }, - // }, - // { - // name: 'Select and underline', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // formatUnderline(), - // ], - // expectedHTML: - // '

                                      Hello ' + - // 'draft!

                                      ', - // expectedSelection: { - // anchorPath: [0, 1, 0], - // anchorOffset: 0, - // focusPath: [0, 1, 0], - // focusOffset: 5, - // }, - // }, - // { - // name: 'Select and strikethrough', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // formatStrikeThrough(), - // ], - // expectedHTML: - // '

                                      Hello ' + - // 'draft!

                                      ', - // expectedSelection: { - // anchorPath: [0, 1, 0], - // anchorOffset: 0, - // focusPath: [0, 1, 0], - // focusOffset: 5, - // }, - // }, - // { - // name: 'Select and underline + strikethrough', - // inputs: [ - // insertText('Hello draft!'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 11), - // formatUnderline(), - // formatStrikeThrough(), - // ], - // expectedHTML: - // '

                                      Hello ' + - // 'draft!

                                      ', - // expectedSelection: { - // anchorPath: [0, 1, 0], - // anchorOffset: 0, - // focusPath: [0, 1, 0], - // focusOffset: 5, - // }, - // }, - // { - // name: 'Select and replace all', - // inputs: [ - // insertText('This is broken.'), - // moveNativeSelection([0, 0, 0], 0, [0, 0, 0], 15), - // insertText('This works!'), - // ], - // expectedHTML: - // '

                                      This works!

                                      ', - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 11, - // focusPath: [0, 0, 0], - // focusOffset: 11, - // }, - // }, - // { - // name: 'Select and delete', - // inputs: [ - // insertText('A lion.'), - // moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6), - // deleteForward(), - // insertText('duck'), - // moveNativeSelection([0, 0, 0], 2, [0, 0, 0], 6), - // ], - // expectedHTML: - // '

                                      A duck.

                                      ', - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 2, - // focusPath: [0, 0, 0], - // focusOffset: 6, - // }, - // }, - // { - // name: 'Inserting a paragraph', - // inputs: [insertParagraph()], - // expectedHTML: - // '


                                      ' + - // '


                                      ', - // expectedSelection: { - // anchorPath: [1, 0, 0], - // anchorOffset: 0, - // focusPath: [1, 0, 0], - // focusOffset: 0, - // }, - // }, - // { - // name: 'Inserting a paragraph and then removing it', - // inputs: [insertParagraph(), deleteBackward(1)], - // expectedHTML: - // '


                                      ', - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 0, - // focusPath: [0, 0, 0], - // focusOffset: 0, - // }, - // }, - // { - // name: 'Inserting a paragraph part way through text', - // inputs: [ - // insertText('Hello world'), - // moveNativeSelection([0, 0, 0], 6, [0, 0, 0], 6), - // insertParagraph(), - // ], - // expectedHTML: - // '

                                      Hello

                                      ' + - // '

                                      world

                                      ', - // expectedSelection: { - // anchorPath: [1, 0, 0], - // anchorOffset: 0, - // focusPath: [1, 0, 0], - // focusOffset: 0, - // }, - // }, - // { - // name: 'Inserting two paragraphs and then deleting via selection', - // inputs: [ - // insertText('123'), - // insertParagraph(), - // insertText('456'), - // moveNativeSelection([0, 0, 0], 0, [1, 0, 0], 3), - // deleteBackward(1), - // ], - // expectedHTML: - // '


                                      ', - // expectedSelection: { - // anchorPath: [0, 0, 0], - // anchorOffset: 0, - // focusPath: [0, 0, 0], - // focusOffset: 0, - // }, - // }, ...[ { whitespaceCharacter: ' ', @@ -1254,47 +934,43 @@ describe('LexicalSelection tests', () => { }); test('insert text one selected node element selection', async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); + await editor!.update(() => { + const root = $getRoot(); - const paragraph = root.getFirstChild()!; + const paragraph = root.getFirstChild()!; - const elementNode = $createTestElementNode(); - const text = $createTextNode('foo'); + const elementNode = $createTestElementNode(); + const text = $createTextNode('foo'); - paragraph.append(elementNode); - elementNode.append(text); + paragraph.append(elementNode); + elementNode.append(text); - const selection = $createRangeSelection(); - selection.anchor.set(text.__key, 0, 'text'); - selection.focus.set(paragraph.__key, 1, 'element'); + const selection = $createRangeSelection(); + selection.anchor.set(text.__key, 0, 'text'); + selection.focus.set(paragraph.__key, 1, 'element'); - selection.insertText(''); + selection.insertText(''); - expect(root.getTextContent()).toBe(''); - }); + expect(root.getTextContent()).toBe(''); }); }); test('getNodes resolves nested block nodes', async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); + await editor!.update(() => { + const root = $getRoot(); - const paragraph = root.getFirstChild()!; + const paragraph = root.getFirstChild()!; - const elementNode = $createTestElementNode(); - const text = $createTextNode(); + const elementNode = $createTestElementNode(); + const text = $createTextNode(); - paragraph.append(elementNode); - elementNode.append(text); + paragraph.append(elementNode); + elementNode.append(text); - const selectedNodes = $getSelection()!.getNodes(); + const selectedNodes = $getSelection()!.getNodes(); - expect(selectedNodes.length).toBe(1); - expect(selectedNodes[0].getKey()).toBe(text.getKey()); - }); + expect(selectedNodes.length).toBe(1); + expect(selectedNodes[0].getKey()).toBe(text.getKey()); }); }); @@ -1851,50 +1527,48 @@ describe('LexicalSelection tests', () => { // eslint-disable-next-line no-only-tests/no-only-tests const test_ = only === true ? test.only : test; test_(name, async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); + await editor!.update(() => { + const root = $getRoot(); - const paragraph = root.getFirstChild()!; - const textNode = $createTextNode('foo'); - // Note: line break can't be selected by the DOM - const linebreak = $createLineBreakNode(); + const paragraph = root.getFirstChild()!; + const textNode = $createTextNode('foo'); + // Note: line break can't be selected by the DOM + const linebreak = $createLineBreakNode(); - const selection = $getSelection(); + const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } + if (!$isRangeSelection(selection)) { + return; + } - const anchor = selection.anchor; - const focus = selection.focus; + const anchor = selection.anchor; + const focus = selection.focus; - paragraph.append(textNode, linebreak); + paragraph.append(textNode, linebreak); - fnBefore(paragraph, textNode); + fnBefore(paragraph, textNode); - anchor.set(paragraph.getKey(), anchorOffset, 'element'); - focus.set(paragraph.getKey(), focusOffset, 'element'); + anchor.set(paragraph.getKey(), anchorOffset, 'element'); + focus.set(paragraph.getKey(), focusOffset, 'element'); - const { - expectedAnchor, - expectedAnchorOffset, - expectedFocus, - expectedFocusOffset, - } = fn(paragraph, textNode); + const { + expectedAnchor, + expectedAnchorOffset, + expectedFocus, + expectedFocusOffset, + } = fn(paragraph, textNode); - if (invertSelection !== true) { - expect(selection.anchor.key).toBe(expectedAnchor.__key); - expect(selection.anchor.offset).toBe(expectedAnchorOffset); - expect(selection.focus.key).toBe(expectedFocus.__key); - expect(selection.focus.offset).toBe(expectedFocusOffset); - } else { - expect(selection.anchor.key).toBe(expectedFocus.__key); - expect(selection.anchor.offset).toBe(expectedFocusOffset); - expect(selection.focus.key).toBe(expectedAnchor.__key); - expect(selection.focus.offset).toBe(expectedAnchorOffset); - } - }); + if (invertSelection !== true) { + expect(selection.anchor.key).toBe(expectedAnchor.__key); + expect(selection.anchor.offset).toBe(expectedAnchorOffset); + expect(selection.focus.key).toBe(expectedFocus.__key); + expect(selection.focus.offset).toBe(expectedFocusOffset); + } else { + expect(selection.anchor.key).toBe(expectedFocus.__key); + expect(selection.anchor.offset).toBe(expectedFocusOffset); + expect(selection.focus.key).toBe(expectedAnchor.__key); + expect(selection.focus.offset).toBe(expectedAnchorOffset); + } }); }); }, @@ -1903,132 +1577,19 @@ describe('LexicalSelection tests', () => { describe('Selection correctly resolves to a sibling ElementNode when a node is removed', () => { test('', async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); - - const listNode = $createListNode('bullet'); - const listItemNode = $createListItemNode(); - const paragraph = $createParagraphNode(); - - root.append(listNode); - - listNode.append(listItemNode); - listItemNode.select(); - listNode.insertAfter(paragraph); - listItemNode.remove(); - - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return; - } - - expect(selection.anchor.getNode().__type).toBe('paragraph'); - expect(selection.focus.getNode().__type).toBe('paragraph'); - }); - }); - }); - }); - - describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => { - test('', async () => { - await ReactTestUtils.act(async () => { - let paragraphNodeKey: string; - await editor!.update(() => { - const root = $getRoot(); - - const paragraphNode = $createParagraphNode(); - paragraphNodeKey = paragraphNode.__key; - const listNode = $createListNode('number'); - const listItemNode1 = $createListItemNode(); - const textNode1 = $createTextNode('foo'); - const listItemNode2 = $createListItemNode(); - const listNode2 = $createListNode('number'); - const listItemNode2x1 = $createListItemNode(); - - listNode.append(listItemNode1, listItemNode2); - listItemNode1.append(textNode1); - listItemNode2.append(listNode2); - listNode2.append(listItemNode2x1); - root.append(paragraphNode, listNode); - - listItemNode2.select(); - - listNode.remove(); - }); - await editor!.getEditorState().read(() => { - const selection = $assertRangeSelection($getSelection()); - expect(selection.anchor.key).toBe(paragraphNodeKey); - expect(selection.focus.key).toBe(paragraphNodeKey); - }); - }); - }); - }); - - describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => { - test('', async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - // Arrange - // Root - // |- Paragraph - // |- Link - // |- Text - // |- LineBreak - // |- Text - // |- Text - const root = $getRoot(); - - const paragraph = $createParagraphNode(); - const link = $createLinkNode('bullet'); - const textOne = $createTextNode('Hello'); - const br = $createLineBreakNode(); - const textTwo = $createTextNode('world'); - const textThree = $createTextNode(' '); - - root.append(paragraph); - link.append(textOne); - link.append(br); - link.append(textTwo); - - paragraph.append(link); - paragraph.append(textThree); - - textThree.select(); - // Act - textThree.remove(); - // Assert - const expectedKey = link.getKey(); - - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return; - } - - const {anchor, focus} = selection; - - expect(anchor.getNode().getKey()).toBe(expectedKey); - expect(focus.getNode().getKey()).toBe(expectedKey); - expect(anchor.offset).toBe(3); - expect(focus.offset).toBe(3); - }); - }); - }); - }); - - test('isBackward', async () => { - await ReactTestUtils.act(async () => { await editor!.update(() => { const root = $getRoot(); - const paragraph = root.getFirstChild()!; - const paragraphKey = paragraph.getKey(); - const textNode = $createTextNode('foo'); - const textNodeKey = textNode.getKey(); - // Note: line break can't be selected by the DOM - const linebreak = $createLineBreakNode(); + const listNode = $createListNode('bullet'); + const listItemNode = $createListItemNode(); + const paragraph = $createParagraphNode(); + + root.append(listNode); + + listNode.append(listItemNode); + listItemNode.select(); + listNode.insertAfter(paragraph); + listItemNode.remove(); const selection = $getSelection(); @@ -2036,32 +1597,137 @@ describe('LexicalSelection tests', () => { return; } - const anchor = selection.anchor; - const focus = selection.focus; - paragraph.append(textNode, linebreak); - anchor.set(textNodeKey, 0, 'text'); - focus.set(textNodeKey, 0, 'text'); - - expect(selection.isBackward()).toBe(false); - - anchor.set(paragraphKey, 1, 'element'); - focus.set(paragraphKey, 1, 'element'); - - expect(selection.isBackward()).toBe(false); - - anchor.set(paragraphKey, 0, 'element'); - focus.set(paragraphKey, 1, 'element'); - - expect(selection.isBackward()).toBe(false); - - anchor.set(paragraphKey, 1, 'element'); - focus.set(paragraphKey, 0, 'element'); - - expect(selection.isBackward()).toBe(true); + expect(selection.anchor.getNode().__type).toBe('paragraph'); + expect(selection.focus.getNode().__type).toBe('paragraph'); }); }); }); + describe('Selection correctly resolves to a sibling ElementNode when a selected node child is removed', () => { + test('', async () => { + let paragraphNodeKey: string; + await editor!.update(() => { + const root = $getRoot(); + + const paragraphNode = $createParagraphNode(); + paragraphNodeKey = paragraphNode.__key; + const listNode = $createListNode('number'); + const listItemNode1 = $createListItemNode(); + const textNode1 = $createTextNode('foo'); + const listItemNode2 = $createListItemNode(); + const listNode2 = $createListNode('number'); + const listItemNode2x1 = $createListItemNode(); + + listNode.append(listItemNode1, listItemNode2); + listItemNode1.append(textNode1); + listItemNode2.append(listNode2); + listNode2.append(listItemNode2x1); + root.append(paragraphNode, listNode); + + listItemNode2.select(); + + listNode.remove(); + }); + await editor!.getEditorState().read(() => { + const selection = $assertRangeSelection($getSelection()); + expect(selection.anchor.key).toBe(paragraphNodeKey); + expect(selection.focus.key).toBe(paragraphNodeKey); + }); + }); + }); + + describe('Selection correctly resolves to a sibling ElementNode that has multiple children with the correct offset when a node is removed', () => { + test('', async () => { + await editor!.update(() => { + // Arrange + // Root + // |- Paragraph + // |- Link + // |- Text + // |- LineBreak + // |- Text + // |- Text + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + const link = $createLinkNode('bullet'); + const textOne = $createTextNode('Hello'); + const br = $createLineBreakNode(); + const textTwo = $createTextNode('world'); + const textThree = $createTextNode(' '); + + root.append(paragraph); + link.append(textOne); + link.append(br); + link.append(textTwo); + + paragraph.append(link); + paragraph.append(textThree); + + textThree.select(); + // Act + textThree.remove(); + // Assert + const expectedKey = link.getKey(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const {anchor, focus} = selection; + + expect(anchor.getNode().getKey()).toBe(expectedKey); + expect(focus.getNode().getKey()).toBe(expectedKey); + expect(anchor.offset).toBe(3); + expect(focus.offset).toBe(3); + }); + }); + }); + + test('isBackward', async () => { + await editor!.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + const paragraphKey = paragraph.getKey(); + const textNode = $createTextNode('foo'); + const textNodeKey = textNode.getKey(); + // Note: line break can't be selected by the DOM + const linebreak = $createLineBreakNode(); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchor = selection.anchor; + const focus = selection.focus; + paragraph.append(textNode, linebreak); + anchor.set(textNodeKey, 0, 'text'); + focus.set(textNodeKey, 0, 'text'); + + expect(selection.isBackward()).toBe(false); + + anchor.set(paragraphKey, 1, 'element'); + focus.set(paragraphKey, 1, 'element'); + + expect(selection.isBackward()).toBe(false); + + anchor.set(paragraphKey, 0, 'element'); + focus.set(paragraphKey, 1, 'element'); + + expect(selection.isBackward()).toBe(false); + + anchor.set(paragraphKey, 1, 'element'); + focus.set(paragraphKey, 0, 'element'); + + expect(selection.isBackward()).toBe(true); + }); + }); + describe('Decorator text content for selection', () => { const baseCases: { name: string; @@ -2144,34 +1810,32 @@ describe('LexicalSelection tests', () => { }) .forEach(({name, fn, invertSelection}) => { it(name, async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); + await editor!.update(() => { + const root = $getRoot(); - const paragraph = root.getFirstChild()!; - const textNode1 = $createTextNode('1'); - const textNode2 = $createTextNode('2'); - const decorator = $createTestDecoratorNode(); + const paragraph = root.getFirstChild()!; + const textNode1 = $createTextNode('1'); + const textNode2 = $createTextNode('2'); + const decorator = $createTestDecoratorNode(); - paragraph.append(textNode1, decorator, textNode2); + paragraph.append(textNode1, decorator, textNode2); - const selection = $getSelection(); + const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } + if (!$isRangeSelection(selection)) { + return; + } - const expectedTextContent = fn({ - anchor: invertSelection ? selection.focus : selection.anchor, - decorator, - focus: invertSelection ? selection.anchor : selection.focus, - paragraph, - textNode1, - textNode2, - }); - - expect(selection.getTextContent()).toBe(expectedTextContent); + const expectedTextContent = fn({ + anchor: invertSelection ? selection.focus : selection.anchor, + decorator, + focus: invertSelection ? selection.anchor : selection.focus, + paragraph, + textNode1, + textNode2, }); + + expect(selection.getTextContent()).toBe(expectedTextContent); }); }); }); @@ -2274,44 +1938,42 @@ describe('LexicalSelection tests', () => { it('adjust offset for inline elements text formatting', async () => { await init(); - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); + await editor!.update(() => { + const root = $getRoot(); - const text1 = $createTextNode('--'); - const text2 = $createTextNode('abc'); - const text3 = $createTextNode('--'); + const text1 = $createTextNode('--'); + const text2 = $createTextNode('abc'); + const text3 = $createTextNode('--'); - root.append( + root.append( $createParagraphNode().append( - text1, - $createLinkNode('https://lexical.dev').append(text2), - text3, + text1, + $createLinkNode('https://lexical.dev').append(text2), + text3, ), - ); + ); - $setAnchorPoint({ - key: text1.getKey(), - offset: 2, - type: 'text', - }); - - $setFocusPoint({ - key: text3.getKey(), - offset: 0, - type: 'text', - }); - - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return; - } - - selection.formatText('bold'); - - expect(text2.hasFormat('bold')).toBe(true); + $setAnchorPoint({ + key: text1.getKey(), + offset: 2, + type: 'text', }); + + $setFocusPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); + + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + selection.formatText('bold'); + + expect(text2.hasFormat('bold')).toBe(true); }); }); }); @@ -2504,107 +2166,103 @@ describe('LexicalSelection tests', () => { describe('$patchStyle', () => { it('should patch the style with the new style object', async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const root = $getRoot(); - const paragraph = $createParagraphNode(); - const textNode = $createTextNode('Hello, World!'); - textNode.setStyle('font-family: serif; color: red;'); - $addNodeStyle(textNode); - paragraph.append(textNode); - root.append(paragraph); + await editor!.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle('font-family: serif; color: red;'); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); - const selection = $createRangeSelection(); - $setSelection(selection); - selection.insertParagraph(); - $setAnchorPoint({ - key: textNode.getKey(), - offset: 0, - type: 'text', - }); + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); - $setFocusPoint({ - key: textNode.getKey(), - offset: 10, - type: 'text', - }); + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); - const newStyle = { - color: 'blue', - 'font-family': 'Arial', - }; + const newStyle = { + color: 'blue', + 'font-family': 'Arial', + }; - $patchStyleText(selection, newStyle); + $patchStyleText(selection, newStyle); - const cssFontFamilyValue = $getSelectionStyleValueForProperty( + const cssFontFamilyValue = $getSelectionStyleValueForProperty( selection, 'font-family', '', - ); - expect(cssFontFamilyValue).toBe('Arial'); + ); + expect(cssFontFamilyValue).toBe('Arial'); - const cssColorValue = $getSelectionStyleValueForProperty( + const cssColorValue = $getSelectionStyleValueForProperty( selection, 'color', '', - ); - expect(cssColorValue).toBe('blue'); - }); + ); + expect(cssColorValue).toBe('blue'); }); }); it('should patch the style with property function', async () => { - await ReactTestUtils.act(async () => { - await editor!.update(() => { - const currentColor = 'red'; - const nextColor = 'blue'; + await editor!.update(() => { + const currentColor = 'red'; + const nextColor = 'blue'; - const root = $getRoot(); - const paragraph = $createParagraphNode(); - const textNode = $createTextNode('Hello, World!'); - textNode.setStyle(`color: ${currentColor};`); - $addNodeStyle(textNode); - paragraph.append(textNode); - root.append(paragraph); + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle(`color: ${currentColor};`); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); - const selection = $createRangeSelection(); - $setSelection(selection); - selection.insertParagraph(); - $setAnchorPoint({ - key: textNode.getKey(), - offset: 0, - type: 'text', - }); + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); - $setFocusPoint({ - key: textNode.getKey(), - offset: 10, - type: 'text', - }); + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); - const newStyle = { - color: jest.fn( + const newStyle = { + color: jest.fn( (current: string | null, target: LexicalNode | RangeSelection) => - nextColor, - ), - }; + nextColor, + ), + }; - $patchStyleText(selection, newStyle); + $patchStyleText(selection, newStyle); - const cssColorValue = $getSelectionStyleValueForProperty( + const cssColorValue = $getSelectionStyleValueForProperty( selection, 'color', '', - ); + ); - expect(cssColorValue).toBe(nextColor); - expect(newStyle.color).toHaveBeenCalledTimes(1); + expect(cssColorValue).toBe(nextColor); + expect(newStyle.color).toHaveBeenCalledTimes(1); - const lastCall = newStyle.color.mock.lastCall!; - expect(lastCall[0]).toBe(currentColor); - // @ts-ignore - It expected to be a LexicalNode - expect($isTextNode(lastCall[1])).toBeTruthy(); - }); + const lastCall = newStyle.color.mock.lastCall!; + expect(lastCall[0]).toBe(currentColor); + // @ts-ignore - It expected to be a LexicalNode + expect($isTextNode(lastCall[1])).toBeTruthy(); }); }); }); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts similarity index 53% rename from resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx rename to resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts index 37049e598..6ce133d18 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.tsx +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -7,22 +7,14 @@ */ import {$insertDataTransferForRichText} from '@lexical/clipboard'; -import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; import { $createTableNode, - $createTableNodeWithDimensions, - $createTableSelection, } from '@lexical/table'; import { $createParagraphNode, - $createTextNode, $getRoot, $getSelection, $isRangeSelection, - $selectAll, - $setSelection, - CUT_COMMAND, - ParagraphNode, } from 'lexical'; import { DataTransferMock, @@ -30,8 +22,6 @@ import { invariant, } from 'lexical/__tests__/utils'; -import {$getElementForTableNode, TableNode} from '../../LexicalTableNode'; - export class ClipboardDataMock { getData: jest.Mock; setData: jest.Mock; @@ -149,203 +139,7 @@ describe('LexicalTableNode tests', () => { `

                                      Surface

                                      MWP_WORK_LS_COMPOSER

                                      77349

                                      Lexical

                                      XDS_RICH_TEXT_AREA

                                      sdvd sdfvsfs

                                      `, ); }); - - test('Cut table in the middle of a range selection', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - const beforeText = $createTextNode('text before the table'); - const table = $createTableNodeWithDimensions(4, 4, true); - const afterText = $createTextNode('text after the table'); - - paragraph?.append(beforeText); - paragraph?.append(table); - paragraph?.append(afterText); - }); - await editor.update(() => { - editor.focus(); - $selectAll(); - }); - await editor.update(() => { - editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - }); - - expect(testEnv.innerHTML).toBe(`


                                      `); - }); - - test('Cut table as last node in range selection ', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - const beforeText = $createTextNode('text before the table'); - const table = $createTableNodeWithDimensions(4, 4, true); - - paragraph?.append(beforeText); - paragraph?.append(table); - }); - await editor.update(() => { - editor.focus(); - $selectAll(); - }); - await editor.update(() => { - editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - }); - - expect(testEnv.innerHTML).toBe(`


                                      `); - }); - - test('Cut table as first node in range selection ', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const paragraph = root.getFirstChild(); - const table = $createTableNodeWithDimensions(4, 4, true); - const afterText = $createTextNode('text after the table'); - - paragraph?.append(table); - paragraph?.append(afterText); - }); - await editor.update(() => { - editor.focus(); - $selectAll(); - }); - await editor.update(() => { - editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - }); - - expect(testEnv.innerHTML).toBe(`


                                      `); - }); - - test('Cut table is whole selection, should remove it', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - const DOMTable = $getElementForTableNode(editor, table); - if (DOMTable) { - table - ?.getCellNodeFromCords(0, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('some text')); - const selection = $createTableSelection(); - selection.set( - table.__key, - table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table?.getCellNodeFromCords(3, 3, DOMTable)?.__key || '', - ); - $setSelection(selection); - editor.dispatchCommand(CUT_COMMAND, { - preventDefault: () => {}, - stopPropagation: () => {}, - } as ClipboardEvent); - } - } - }); - - expect(testEnv.innerHTML).toBe(`


                                      `); - }); - - test('Cut subsection of table cells, should just clear contents', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - const DOMTable = $getElementForTableNode(editor, table); - if (DOMTable) { - table - ?.getCellNodeFromCords(0, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('some text')); - const selection = $createTableSelection(); - selection.set( - table.__key, - table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table?.getCellNodeFromCords(2, 2, DOMTable)?.__key || '', - ); - $setSelection(selection); - editor.dispatchCommand(CUT_COMMAND, { - preventDefault: () => {}, - stopPropagation: () => {}, - } as ClipboardEvent); - } - } - }); - - expect(testEnv.innerHTML).toBe( - `


















                                      `, - ); - }); - - test('Table plain text output validation', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - const table = $createTableNodeWithDimensions(4, 4, true); - root.append(table); - }); - await editor.update(() => { - const root = $getRoot(); - const table = root.getLastChild(); - if (table) { - const DOMTable = $getElementForTableNode(editor, table); - if (DOMTable) { - table - ?.getCellNodeFromCords(0, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('1')); - table - ?.getCellNodeFromCords(1, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('')); - table - ?.getCellNodeFromCords(2, 0, DOMTable) - ?.getLastChild() - ?.append($createTextNode('2')); - table - ?.getCellNodeFromCords(0, 1, DOMTable) - ?.getLastChild() - ?.append($createTextNode('3')); - table - ?.getCellNodeFromCords(1, 1, DOMTable) - ?.getLastChild() - ?.append($createTextNode('4')); - table - ?.getCellNodeFromCords(2, 1, DOMTable) - ?.getLastChild() - ?.append($createTextNode('')); - const selection = $createTableSelection(); - selection.set( - table.__key, - table?.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', - table?.getCellNodeFromCords(2, 1, DOMTable)?.__key || '', - ); - expect(selection.getTextContent()).toBe(`1\t\t2\n3\t4\t\n`); - } - } - }); - }); }, undefined, - , ); }); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts similarity index 73% rename from resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx rename to resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts index 9e9dbac81..35ee65b68 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.tsx +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts @@ -19,9 +19,6 @@ import { TextNode, } from 'lexical'; import {createTestEditor} from 'lexical/__tests__/utils'; -import {createRef, useEffect, useMemo} from 'react'; -import {createRoot, Root} from 'react-dom/client'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; describe('table selection', () => { let originalText: TextNode; @@ -31,57 +28,35 @@ describe('table selection', () => { let paragraphKey: string; let textKey: string; let parsedEditorState: EditorState; - let reactRoot: Root; + let root: HTMLDivElement; let container: HTMLDivElement | null = null; let editor: LexicalEditor | null = null; beforeEach(() => { container = document.createElement('div'); - reactRoot = createRoot(container); + root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); document.body.appendChild(container); }); - function useLexicalEditor( - rootElementRef: React.RefObject, - onError?: () => void, - ) { - const editorInHook = useMemo( - () => - createTestEditor({ - nodes: [], - onError: onError || jest.fn(), - theme: { - text: { - bold: 'editor-text-bold', - italic: 'editor-text-italic', - underline: 'editor-text-underline', - }, - }, - }), - [onError], - ); - - useEffect(() => { - const rootElement = rootElementRef.current; - - editorInHook.setRootElement(rootElement); - }, [rootElementRef, editorInHook]); - - return editorInHook; - } + afterEach(() => { + container?.remove(); + }); function init(onError?: () => void) { - const ref = createRef(); + editor = createTestEditor({ + nodes: [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }) - function TestBase() { - editor = useLexicalEditor(ref, onError); - - return
                                      ; - } - - ReactTestUtils.act(() => { - reactRoot.render(); - }); + editor.setRootElement(root); } async function update(fn: () => void) { diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts similarity index 85% rename from resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx rename to resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts index 2d5db2c69..53c743df6 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.tsx +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -5,27 +5,16 @@ * LICENSE file in the root directory of this source tree. * */ - -import {CodeHighlightNode, CodeNode} from '@lexical/code'; -import {HashtagNode} from '@lexical/hashtag'; import {AutoLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; -import {OverflowNode} from '@lexical/overflow'; -import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {ContentEditable} from '@lexical/react/LexicalContentEditable'; -import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; -import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; -import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text'; import { applySelectionInputs, pasteHTML, -} from '@lexical/selection/src/__tests__/utils'; +} from '@lexical/selection/__tests__/utils'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; -import {LexicalEditor} from 'lexical'; -import {initializeClipboard, TestComposer} from 'lexical/__tests__/utils'; -import {createRoot} from 'react-dom/client'; -import * as ReactTestUtils from 'lexical/shared/react-test-utils'; +import {$createParagraphNode, $insertNodes, LexicalEditor} from 'lexical'; +import {createTestEditor, initializeClipboard} from 'lexical/__tests__/utils'; jest.mock('lexical/shared/environment', () => { const originalModule = jest.requireActual('lexical/shared/environment'); @@ -89,85 +78,69 @@ describe('LexicalEventHelpers', () => { let editor: LexicalEditor | null = null; async function init() { - function TestBase() { - function TestPlugin(): null { - [editor] = useLexicalComposerContext(); - return null; - } + const config = { + nodes: [ + LinkNode, + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + ], + theme: { + code: 'editor-code', + heading: { + h1: 'editor-heading-h1', + h2: 'editor-heading-h2', + h3: 'editor-heading-h3', + h4: 'editor-heading-h4', + h5: 'editor-heading-h5', + h6: 'editor-heading-h6', + }, + image: 'editor-image', + list: { + listitem: 'editor-listitem', + olDepth: ['editor-list-ol'], + ulDepth: ['editor-list-ul'], + }, + paragraph: 'editor-paragraph', + placeholder: 'editor-placeholder', + quote: 'editor-quote', + text: { + bold: 'editor-text-bold', + code: 'editor-text-code', + hashtag: 'editor-text-hashtag', + italic: 'editor-text-italic', + link: 'editor-text-link', + strikethrough: 'editor-text-strikethrough', + underline: 'editor-text-underline', + underlineStrikethrough: 'editor-text-underlineStrikethrough', + }, + }, + }; - return ( - - - } - placeholder={null} - ErrorBoundary={LexicalErrorBoundary} - /> - - - - ); - } + editor = createTestEditor(config); + registerRichText(editor); - ReactTestUtils.act(() => { - createRoot(container!).render(); + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container?.append(root); + + editor.setRootElement(root); + + editor.update(() => { + $insertNodes([$createParagraphNode()]) }); + editor.commitUpdates(); } async function update(fn: () => void) { - await ReactTestUtils.act(async () => { - await editor!.update(fn); - }); + await editor!.update(fn); + editor?.commitUpdates(); return Promise.resolve().then(); } @@ -549,24 +522,6 @@ describe('LexicalEventHelpers', () => { ], name: 'collapsible spaces with nested structures', }, - // TODO no proper support for divs #4465 - // { - // expectedHTML: - // '

                                      a

                                      b

                                      ', - // inputs: [ - // pasteHTML(` - //
                                      - //
                                      - // a - //
                                      - //
                                      - // b - //
                                      - //
                                      - // `), - // ], - // name: 'collapsible spaces with nested structures (2)', - // }, { expectedHTML: '

                                      a b

                                      ', @@ -612,32 +567,6 @@ describe('LexicalEventHelpers', () => { ], name: 'forced line break with tabs', }, - // The 3 below are not correct, they're missing the first \n ->
                                      but that's a fault with - // the implementation of DOMParser, it works correctly in Safari - { - expectedHTML: - 'a
                                      b

                                      ', - inputs: [pasteHTML(`
                                      \na\r\nb\r\n
                                      `)], - name: 'pre (no touchy) (1)', - }, - { - expectedHTML: - 'a
                                      b

                                      ', - inputs: [ - pasteHTML(` -
                                      \na\r\nb\r\n
                                      - `), - ], - name: 'pre (no touchy) (2)', - }, - { - expectedHTML: - '


                                      a
                                      b

                                      ', - inputs: [ - pasteHTML(`\na\r\nb\r\n`), - ], - name: 'white-space: pre (no touchy) (2)', - }, { expectedHTML: '

                                      paragraph1

                                      paragraph2

                                      ', diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts index 369caaea4..1322b482b 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalRootHelpers.test.ts @@ -6,14 +6,39 @@ * */ -import { - $isRootTextContentEmpty, - $isRootTextContentEmptyCurry, - $rootTextContent, -} from '@lexical/text'; import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; +export function $rootTextContent(): string { + const root = $getRoot(); + + return root.getTextContent(); +} + +export function $isRootTextContentEmpty( + isEditorComposing: boolean, + trim = true, +): boolean { + if (isEditorComposing) { + return false; + } + + let text = $rootTextContent(); + + if (trim) { + text = text.trim(); + } + + return text === ''; +} + +export function $isRootTextContentEmptyCurry( + isEditorComposing: boolean, + trim?: boolean, +): () => boolean { + return () => $isRootTextContentEmpty(isEditorComposing, trim); +} + describe('LexicalRootHelpers tests', () => { initializeUnitTest((testEnv) => { it('textContent', async () => { diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts similarity index 100% rename from resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.tsx rename to resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts similarity index 100% rename from resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.tsx rename to resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts From 654a7a5d03000e6236929fa4dc3e2d6607edb757 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Sep 2024 13:00:16 +0100 Subject: [PATCH 100/107] Lexical: Removed reconciler level direction handling - Updated tests to consider changes --- .../wysiwyg/lexical/core/LexicalReconciler.ts | 115 +------------ .../__tests__/unit/HTMLCopyAndPaste.test.ts | 12 +- .../core/__tests__/unit/LexicalEditor.test.ts | 38 ++--- .../__tests__/unit/LexicalEditorState.test.ts | 6 +- .../core/__tests__/unit/LexicalNode.test.ts | 88 +++++----- .../__tests__/unit/LexicalSelection.test.ts | 18 +- .../unit/LexicalSerialization.test.ts | 4 +- .../__tests__/unit/LexicalTabNode.test.ts | 12 +- .../lexical/core/shared/reactPatches.ts | 22 --- .../lexical/core/shared/useLayoutEffect.ts | 19 --- .../unit/LexicalListItemNode.test.ts | 154 +++++++++--------- .../__tests__/unit/LexicalHeadingNode.test.ts | 12 +- .../__tests__/unit/LexicalSelection.test.ts | 66 ++++---- .../unit/LexicalSelectionHelpers.test.ts | 62 +++---- .../__tests__/unit/LexicalTableNode.test.ts | 8 +- .../unit/LexicalTableSelection.test.ts | 4 +- .../unit/LexicalEventHelpers.test.ts | 92 +++++------ resources/js/wysiwyg/nodes/_common.ts | 30 +++- resources/js/wysiwyg/todo.md | 4 + .../wysiwyg/ui/defaults/buttons/alignments.ts | 11 +- .../js/wysiwyg/ui/defaults/buttons/lists.ts | 10 +- tsconfig.json | 1 + 22 files changed, 329 insertions(+), 459 deletions(-) delete mode 100644 resources/js/wysiwyg/lexical/core/shared/reactPatches.ts delete mode 100644 resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index 0162d2281..09d01bffd 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -42,14 +42,12 @@ import { $textContentRequiresDoubleLinebreakAtEnd, cloneDecorators, getElementByKeyOrThrow, - getTextDirection, setMutatedNode, } from './LexicalUtils'; type IntentionallyMarkedAsDirtyElement = boolean; let subTreeTextContent = ''; -let subTreeDirectionedTextContent = ''; let subTreeTextFormat: number | null = null; let subTreeTextStyle: string = ''; let editorTextContent = ''; @@ -59,7 +57,6 @@ let activeEditorNodes: RegisteredNodes; let treatAllNodesAsDirty = false; let activeEditorStateReadOnly = false; let activeMutationListeners: MutationListeners; -let activeTextDirection: 'ltr' | 'rtl' | null = null; let activeDirtyElements: Map; let activeDirtyLeaves: Set; let activePrevNodeMap: NodeMap; @@ -197,7 +194,7 @@ function $createNode( if (childrenSize !== 0) { const endIndex = childrenSize - 1; const children = createChildrenArray(node, activeNextNodeMap); - $createChildrenWithDirection(children, endIndex, node, dom); + $createChildren(children, node, 0, endIndex, dom, null); } const format = node.__format; @@ -222,10 +219,6 @@ function $createNode( } // Decorators are always non editable dom.contentEditable = 'false'; - } else if ($isTextNode(node)) { - if (!node.isDirectionless()) { - subTreeDirectionedTextContent += text; - } } subTreeTextContent += text; editorTextContent += text; @@ -261,19 +254,6 @@ function $createNode( return dom; } -function $createChildrenWithDirection( - children: Array, - endIndex: number, - element: ElementNode, - dom: HTMLElement, -): void { - const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent; - subTreeDirectionedTextContent = ''; - $createChildren(children, element, 0, endIndex, dom, null); - reconcileBlockDirection(element, dom); - subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent; -} - function $createChildren( children: Array, element: ElementNode, @@ -388,93 +368,16 @@ function reconcileParagraphStyle(element: ElementNode): void { } } -function reconcileBlockDirection(element: ElementNode, dom: HTMLElement): void { - const previousSubTreeDirectionTextContent: string = - // @ts-expect-error: internal field - dom.__lexicalDirTextContent; - // @ts-expect-error: internal field - const previousDirection: string = dom.__lexicalDir; - - if ( - previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent || - previousDirection !== activeTextDirection - ) { - const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === ''; - const direction = hasEmptyDirectionedTextContent - ? activeTextDirection - : getTextDirection(subTreeDirectionedTextContent); - - if (direction !== previousDirection) { - const classList = dom.classList; - const theme = activeEditorConfig.theme; - let previousDirectionTheme = - previousDirection !== null ? theme[previousDirection] : undefined; - let nextDirectionTheme = - direction !== null ? theme[direction] : undefined; - - // Remove the old theme classes if they exist - if (previousDirectionTheme !== undefined) { - if (typeof previousDirectionTheme === 'string') { - const classNamesArr = normalizeClassNames(previousDirectionTheme); - previousDirectionTheme = theme[previousDirection] = classNamesArr; - } - - // @ts-ignore: intentional - classList.remove(...previousDirectionTheme); - } - - if ( - direction === null || - (hasEmptyDirectionedTextContent && direction === 'ltr') - ) { - // Remove direction - dom.removeAttribute('dir'); - } else { - // Apply the new theme classes if they exist - if (nextDirectionTheme !== undefined) { - if (typeof nextDirectionTheme === 'string') { - const classNamesArr = normalizeClassNames(nextDirectionTheme); - // @ts-expect-error: intentional - nextDirectionTheme = theme[direction] = classNamesArr; - } - - if (nextDirectionTheme !== undefined) { - classList.add(...nextDirectionTheme); - } - } - - // Update direction - dom.dir = direction; - } - - if (!activeEditorStateReadOnly) { - const writableNode = element.getWritable(); - writableNode.__dir = direction; - } - } - - activeTextDirection = direction; - // @ts-expect-error: internal field - dom.__lexicalDirTextContent = subTreeDirectionedTextContent; - // @ts-expect-error: internal field - dom.__lexicalDir = direction; - } -} - function $reconcileChildrenWithDirection( prevElement: ElementNode, nextElement: ElementNode, dom: HTMLElement, ): void { - const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent; - subTreeDirectionedTextContent = ''; subTreeTextFormat = null; subTreeTextStyle = ''; $reconcileChildren(prevElement, nextElement, dom); - reconcileBlockDirection(nextElement, dom); reconcileParagraphFormat(nextElement); reconcileParagraphStyle(nextElement); - subTreeDirectionedTextContent = previousSubTreeDirectionTextContent; } function createChildrenArray( @@ -624,20 +527,9 @@ function $reconcileNode( subTreeTextContent += previousSubTreeTextContent; editorTextContent += previousSubTreeTextContent; } - - // @ts-expect-error: internal field - const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent; - - if (previousSubTreeDirectionTextContent !== undefined) { - subTreeDirectionedTextContent += previousSubTreeDirectionTextContent; - } } else { const text = prevNode.getTextContent(); - if ($isTextNode(prevNode) && !prevNode.isDirectionless()) { - subTreeDirectionedTextContent += text; - } - editorTextContent += text; subTreeTextContent += text; } @@ -702,9 +594,6 @@ function $reconcileNode( if (decorator !== null) { reconcileDecorator(key, decorator); } - } else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) { - // Handle text content, for LTR, LTR cases. - subTreeDirectionedTextContent += text; } subTreeTextContent += text; @@ -871,11 +760,9 @@ export function $reconcileRoot( // The cache must be rebuilt during reconciliation to account for any changes. subTreeTextContent = ''; editorTextContent = ''; - subTreeDirectionedTextContent = ''; // Rather than pass around a load of arguments through the stack recursively // we instead set them as bindings within the scope of the module. treatAllNodesAsDirty = dirtyType === FULL_RECONCILE; - activeTextDirection = null; activeEditor = editor; activeEditorConfig = editor._config; activeEditorNodes = editor._nodes; diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts index 056de19e4..9f832b69e 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -34,12 +34,12 @@ describe('HTMLCopyAndPaste tests', () => { const HTML_COPY_PASTING_TESTS = [ { - expectedHTML: `

                                      Hello!

                                      `, + expectedHTML: `

                                      Hello!

                                      `, name: 'plain DOM text node', pastedHTML: `Hello!`, }, { - expectedHTML: `

                                      Hello!


                                      `, + expectedHTML: `

                                      Hello!


                                      `, name: 'a paragraph element', pastedHTML: `

                                      Hello!

                                      `, }, @@ -52,7 +52,7 @@ describe('HTMLCopyAndPaste tests', () => {

                                      `, }, { - expectedHTML: `

                                      a b c d e

                                      f g h

                                      `, + expectedHTML: `

                                      a b c d e

                                      f g h

                                      `, name: 'multiple nested spans and divs', pastedHTML: `
                                      a b @@ -82,12 +82,12 @@ describe('HTMLCopyAndPaste tests', () => { pastedHTML: ` 123
                                      456
                                      `, }, { - expectedHTML: `
                                      • done
                                      • todo
                                        • done
                                        • todo
                                      • todo
                                      `, + expectedHTML: `
                                      • done
                                      • todo
                                        • done
                                        • todo
                                      • todo
                                      `, name: 'google doc checklist', - pastedHTML: `
                                      • checked

                                        done

                                      • unchecked

                                        todo

                                        • checked

                                          done

                                        • unchecked

                                          todo

                                      • unchecked

                                        todo

                                      `, + pastedHTML: `
                                      • checked

                                        done

                                      • unchecked

                                        todo

                                        • checked

                                          done

                                        • unchecked

                                          todo

                                      • unchecked

                                        todo

                                      `, }, { - expectedHTML: `

                                      checklist

                                      • done
                                      • todo
                                      `, + expectedHTML: `

                                      checklist

                                      • done
                                      • todo
                                      `, name: 'github checklist', pastedHTML: `

                                      checklist

                                      • done
                                      • todo
                                      `, }, diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts index 4e3e622ce..f3c6f7105 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -258,7 +258,7 @@ describe('LexicalEditor tests', () => { await Promise.resolve().then(); expect(container.innerHTML).toBe( - '

                                      This works!

                                      ', + '

                                      This works!

                                      ', ); const initialEditorState = initialEditor.getEditorState(); @@ -276,7 +276,7 @@ describe('LexicalEditor tests', () => { expect(editor.getEditorState()).toEqual(initialEditorState); expect(container.innerHTML).toBe( - '

                                      This works!

                                      ', + '

                                      This works!

                                      ', ); }); @@ -520,7 +520,7 @@ describe('LexicalEditor tests', () => { underlineListener(); expect(container.innerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); }); @@ -586,7 +586,7 @@ describe('LexicalEditor tests', () => { italicsListener(); expect(container.innerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); }); @@ -657,7 +657,7 @@ describe('LexicalEditor tests', () => { boldFooListener(); expect(container.innerHTML).toBe( - '

                                      Foo!!

                                      ', + '

                                      Foo!!

                                      ', ); }); @@ -875,7 +875,7 @@ describe('LexicalEditor tests', () => { editor.setRootElement(element); expect(container.innerHTML).toBe( - '

                                      This works!

                                      ', + '

                                      This works!

                                      ', ); }); @@ -897,7 +897,7 @@ describe('LexicalEditor tests', () => { await Promise.resolve().then(); expect(container.innerHTML).toBe( - '

                                      This works!

                                      ', + '

                                      This works!

                                      ', ); expect(errorListener).toHaveBeenCalledTimes(0); @@ -912,7 +912,7 @@ describe('LexicalEditor tests', () => { expect(errorListener).toHaveBeenCalledTimes(1); expect(container.innerHTML).toBe( - '

                                      This works!

                                      ', + '

                                      This works!

                                      ', ); }); @@ -953,7 +953,7 @@ describe('LexicalEditor tests', () => { editorInstance.commitUpdates(); expect(container.innerHTML).toBe( - '

                                      Not changed

                                      ', + '

                                      Not changed

                                      ', ); edContainer = document.createElement('span'); @@ -966,7 +966,7 @@ describe('LexicalEditor tests', () => { expect(rootListener).toHaveBeenCalledTimes(3); expect(updateListener).toHaveBeenCalledTimes(3); expect(container.innerHTML).toBe( - '

                                      Change successful

                                      ', + '

                                      Change successful

                                      ', ); }); @@ -1046,7 +1046,7 @@ describe('LexicalEditor tests', () => { it('Parses the nodes of a stringified editor state', async () => { expect(parsedRoot).toEqual({ __cachedText: null, - __dir: 'ltr', + __dir: null, __first: paragraphKey, __format: 0, __indent: 0, @@ -1060,7 +1060,7 @@ describe('LexicalEditor tests', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ - __dir: 'ltr', + __dir: null, __first: textKey, __format: 0, __indent: 0, @@ -1128,7 +1128,7 @@ describe('LexicalEditor tests', () => { it('Parses the nodes of a stringified editor state', async () => { expect(parsedRoot).toEqual({ __cachedText: null, - __dir: 'ltr', + __dir: null, __first: paragraphKey, __format: 0, __indent: 0, @@ -1142,7 +1142,7 @@ describe('LexicalEditor tests', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ - __dir: 'ltr', + __dir: null, __first: textKey, __format: 0, __indent: 0, @@ -1275,7 +1275,7 @@ describe('LexicalEditor tests', () => { expect(editor._editorState._nodeMap.size).toBe(keys.length + 1); // + root expect(editor._keyToDOMMap.size).toBe(keys.length + 1); // + root expect(container.innerHTML).toBe( - '

                                      A
                                      B

                                      ', + '

                                      A
                                      B

                                      ', ); }); @@ -1310,7 +1310,7 @@ describe('LexicalEditor tests', () => { }); expect(container.innerHTML).toBe( - '

                                      B
                                      A

                                      ', + '

                                      B
                                      A

                                      ', ); }); @@ -1351,7 +1351,7 @@ describe('LexicalEditor tests', () => { }); expect(container.innerHTML).toBe( - '

                                      A
                                      C
                                      B

                                      ', + '

                                      A
                                      C
                                      B

                                      ', ); }); }); @@ -2294,14 +2294,14 @@ describe('LexicalEditor tests', () => { }); expect(container.firstElementChild?.innerHTML).toBe( - '

                                      Hello

                                      ', + '

                                      Hello

                                      ', ); }); it('reconciles state without root element', () => { editor = createTestEditor({}); const state = editor.parseEditorState( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`, ); editor.setEditorState(state); expect(editor._editorState).toBe(state); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts index 09b49b738..38ecf03bc 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts @@ -52,7 +52,7 @@ describe('LexicalEditorState tests', () => { expect(root).toEqual({ __cachedText: 'foo', - __dir: 'ltr', + __dir: null, __first: '1', __format: 0, __indent: 0, @@ -66,7 +66,7 @@ describe('LexicalEditorState tests', () => { __type: 'root', }); expect(paragraph).toEqual({ - __dir: 'ltr', + __dir: null, __first: '2', __format: 0, __indent: 0, @@ -113,7 +113,7 @@ describe('LexicalEditorState tests', () => { }); expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`, ); }); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts index 7373f898d..fcf666213 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalNode.test.ts @@ -645,7 +645,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.getEditorState().read(() => { @@ -667,7 +667,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobar

                                      ', + '

                                      foobar

                                      ', ); await editor.getEditorState().read(() => { @@ -694,7 +694,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobarbaz

                                      ', + '

                                      foobarbaz

                                      ', ); await editor.getEditorState().read(() => { @@ -730,7 +730,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobar

                                      ', + '

                                      foobar

                                      ', ); await editor.getEditorState().read(() => { @@ -754,7 +754,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobarbaz

                                      ', + '

                                      foobarbaz

                                      ', ); await editor.getEditorState().read(() => { @@ -795,7 +795,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      fooqux

                                      bar

                                      baz

                                      ', + '

                                      fooqux

                                      bar

                                      baz

                                      ', ); await editor.getEditorState().read(() => { @@ -828,7 +828,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobarbaz

                                      ', + '

                                      foobarbaz

                                      ', ); await editor.getEditorState().read(() => { @@ -879,7 +879,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobarbaz

                                      qux

                                      ', + '

                                      foobarbaz

                                      qux

                                      ', ); await editor.getEditorState().read(() => { @@ -915,7 +915,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      footoken

                                      ', + '

                                      footoken

                                      ', ); await editor.getEditorState().read(() => { @@ -935,7 +935,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foosegmented

                                      ', + '

                                      foosegmented

                                      ', ); await editor.getEditorState().read(() => { @@ -958,7 +958,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foodirectionless

                                      ', + '

                                      foodirectionless

                                      ', ); await editor.getEditorState().read(() => { @@ -1057,7 +1057,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1089,7 +1089,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); let barTextNode: TextNode; @@ -1102,7 +1102,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      bar

                                      ', + '

                                      foo

                                      bar

                                      ', ); await editor.update(() => { @@ -1110,7 +1110,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      bar


                                      ', + '

                                      bar


                                      ', ); }); @@ -1118,7 +1118,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1127,7 +1127,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      bar

                                      ', + '

                                      bar

                                      ', ); }); @@ -1135,7 +1135,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1144,7 +1144,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      bar

                                      ', + '

                                      bar

                                      ', ); }); @@ -1152,7 +1152,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1161,7 +1161,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      bar

                                      ', + '

                                      bar

                                      ', ); }); @@ -1169,7 +1169,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1205,7 +1205,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '', + '', ); }); @@ -1224,7 +1224,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1233,7 +1233,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobar

                                      ', + '

                                      foobar

                                      ', ); }); @@ -1241,7 +1241,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1250,7 +1250,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobar

                                      ', + '

                                      foobar

                                      ', ); }); @@ -1258,7 +1258,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1267,7 +1267,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobar

                                      ', + '

                                      foobar

                                      ', ); }); @@ -1275,7 +1275,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1284,7 +1284,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foobar

                                      ', + '

                                      foobar

                                      ', ); // TODO: add text direction validations }); @@ -1314,7 +1314,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      A

                                      B

                                      C

                                      ', + '

                                      A

                                      B

                                      C

                                      ', ); await editor.update(() => { @@ -1322,7 +1322,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      A

                                      B

                                      C

                                      ', + '

                                      A

                                      B

                                      C

                                      ', ); }); @@ -1356,7 +1356,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      A

                                      B

                                      C

                                      ', + '

                                      A

                                      B

                                      C

                                      ', ); await editor.update(() => { @@ -1365,7 +1365,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '



                                      CBA

                                      ', + '



                                      CBA

                                      ', ); }); @@ -1384,7 +1384,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); let barTextNode; @@ -1397,7 +1397,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      bar

                                      ', + '

                                      foo

                                      bar

                                      ', ); }); @@ -1405,7 +1405,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1414,7 +1414,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      barfoo

                                      ', + '

                                      barfoo

                                      ', ); }); @@ -1422,7 +1422,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1431,7 +1431,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      barfoo

                                      ', + '

                                      barfoo

                                      ', ); }); @@ -1439,7 +1439,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { @@ -1448,7 +1448,7 @@ describe('LexicalNode tests', () => { }); expect(testEnv.outerHTML).toBe( - '

                                      barfoo

                                      ', + '

                                      barfoo

                                      ', ); }); @@ -1456,7 +1456,7 @@ describe('LexicalNode tests', () => { const {editor} = testEnv; expect(testEnv.outerHTML).toBe( - '

                                      foo

                                      ', + '

                                      foo

                                      ', ); await editor.update(() => { diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts index 7055f361a..ac0ec15e5 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSelection.test.ts @@ -61,10 +61,10 @@ describe('LexicalSelection tests', () => { const expectation = mode === 'start-of-paragraph' - ? '

                                      ab

                                      ' + ? '

                                      ab

                                      ' : mode === 'mid-paragraph' - ? '

                                      abc

                                      ' - : '

                                      ab

                                      '; + ? '

                                      abc

                                      ' + : '

                                      ab

                                      '; expect(container.innerHTML).toBe(expectation); @@ -113,7 +113,7 @@ describe('LexicalSelection tests', () => { }); expect(container.innerHTML).toBe( - '

                                      xab

                                      ', + '

                                      xab

                                      ', ); }; @@ -154,7 +154,7 @@ describe('LexicalSelection tests', () => { }); expect(container.innerHTML).toBe( - '

                                      axbc

                                      ', + '

                                      axbc

                                      ', ); }; @@ -194,7 +194,7 @@ describe('LexicalSelection tests', () => { }); expect(container.innerHTML).toBe( - '

                                      axb

                                      ', + '

                                      axb

                                      ', ); }; @@ -236,7 +236,7 @@ describe('LexicalSelection tests', () => { }); expect(container.innerHTML).toBe( - '

                                      axb

                                      ', + '

                                      axb

                                      ', ); }; @@ -277,7 +277,7 @@ describe('LexicalSelection tests', () => { }); expect(container.innerHTML).toBe( - '

                                      abxc

                                      ', + '

                                      abxc

                                      ', ); }; @@ -319,7 +319,7 @@ describe('LexicalSelection tests', () => { }); expect(container.innerHTML).toBe( - '

                                      abx

                                      ', + '

                                      abx

                                      ', ); }; diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts index 02231f8bf..5599604c0 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -106,7 +106,7 @@ describe('LexicalSerialization tests', () => { }); const stringifiedEditorState = JSON.stringify(editor.getEditorState()); - const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":"ltr","format":"","indent":0,"type":"tablerow","version":1}],"direction":"ltr","format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; + const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":null,"format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`; expect(stringifiedEditorState).toBe(expectedStringifiedEditorState); @@ -115,7 +115,7 @@ describe('LexicalSerialization tests', () => { const otherStringifiedEditorState = JSON.stringify(editorState); expect(otherStringifiedEditorState).toBe( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":"ltr","format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":null,"format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`, ); }); }); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts index 2d751f5fd..a57ff3f42 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts @@ -55,7 +55,7 @@ describe('LexicalTabNode tests', () => { $insertDataTransferForPlainText(dataTransfer, selection); }); expect(testEnv.innerHTML).toBe( - '

                                      hello\tworld
                                      hello\tworld

                                      ', + '

                                      hello\tworld
                                      hello\tworld

                                      ', ); }); @@ -69,7 +69,7 @@ describe('LexicalTabNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); expect(testEnv.innerHTML).toBe( - '

                                      hello\tworld

                                      hello\tworld

                                      ', + '

                                      hello\tworld

                                      hello\tworld

                                      ', ); }); @@ -89,7 +89,7 @@ describe('LexicalTabNode tests', () => { // $insertDataTransferForRichText(dataTransfer, selection, editor); // }); // expect(testEnv.innerHTML).toBe( - // '

                                      hello\tworld
                                      hello\tworld

                                      ', + // '

                                      hello\tworld
                                      hello\tworld

                                      ', // ); // }); @@ -99,7 +99,7 @@ describe('LexicalTabNode tests', () => { // GDoc 2-liner hello\tworld (like previous test) dataTransfer.setData( 'text/html', - `

                                      Hello world

                                      Hello world
                                      `, + `

                                      Hello world

                                      Hello world
                                      `, ); await editor.update(() => { const selection = $getSelection(); @@ -107,7 +107,7 @@ describe('LexicalTabNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); expect(testEnv.innerHTML).toBe( - '

                                      Hello\tworld

                                      Hello\tworld

                                      ', + '

                                      Hello\tworld

                                      Hello\tworld

                                      ', ); }); @@ -121,7 +121,7 @@ describe('LexicalTabNode tests', () => { $getSelection()!.insertText('f'); }); expect(testEnv.innerHTML).toBe( - '

                                      \tf\t

                                      ', + '

                                      \tf\t

                                      ', ); }); }); diff --git a/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts b/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts deleted file mode 100644 index 9685cd89e..000000000 --- a/resources/js/wysiwyg/lexical/core/shared/reactPatches.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; - -// Webpack + React 17 fails to compile on the usage of `React.startTransition` or -// `React["startTransition"]` even if it's behind a feature detection of -// `"startTransition" in React`. Moving this to a constant avoids the issue :/ -const START_TRANSITION = 'startTransition'; - -export function startTransition(callback: () => void) { - if (START_TRANSITION in React) { - React[START_TRANSITION](callback); - } else { - callback(); - } -} diff --git a/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts b/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts deleted file mode 100644 index 6149879cb..000000000 --- a/resources/js/wysiwyg/lexical/core/shared/useLayoutEffect.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import {useEffect, useLayoutEffect} from 'react'; -import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; - -// This workaround is no longer necessary in React 19, -// but we currently support React >=17.x -// https://github.com/facebook/react/pull/26395 -const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM - ? useLayoutEffect - : useEffect; - -export default useLayoutEffectImpl; diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 22e555f35..581db0294 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -182,13 +182,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • one
                                      • -
                                      • +
                                      • two
                                      • -
                                      • +
                                      • three
                                      @@ -215,13 +215,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • bar
                                      • -
                                      • +
                                      • two
                                      • -
                                      • +
                                      • three
                                      @@ -245,13 +245,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • one
                                      • -
                                      • +
                                      • two
                                      • -
                                      • +
                                      • three
                                      @@ -273,10 +273,10 @@ describe('LexicalListItemNode tests', () => { data-lexical-editor="true">


                                        -
                                      • +
                                      • two
                                      • -
                                      • +
                                      • three
                                      @@ -301,10 +301,10 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • one
                                      • -
                                      • +
                                      • two
                                      @@ -330,13 +330,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • one


                                        -
                                      • +
                                      • three
                                      @@ -361,7 +361,7 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • one
                                      @@ -421,13 +421,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • A
                                      • -
                                      • +
                                      • x
                                      • -
                                      • +
                                      • B
                                      @@ -445,10 +445,10 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • A
                                      • -
                                      • +
                                      • B
                                      @@ -495,15 +495,15 @@ describe('LexicalListItemNode tests', () => {
                                        • -
                                        • +
                                        • A
                                      • -
                                      • +
                                      • x
                                      • -
                                      • +
                                      • B
                                      @@ -523,12 +523,12 @@ describe('LexicalListItemNode tests', () => {
                                        • -
                                        • +
                                        • A
                                      • -
                                      • +
                                      • B
                                      @@ -573,15 +573,15 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                        -
                                      • +
                                      • A
                                      • -
                                      • +
                                      • x
                                        • -
                                        • +
                                        • B
                                        @@ -601,12 +601,12 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                          -
                                        • +
                                        • A
                                          • -
                                          • +
                                          • B
                                          @@ -659,17 +659,17 @@ describe('LexicalListItemNode tests', () => {
                                            • -
                                            • +
                                            • A
                                          • -
                                          • +
                                          • x
                                            • -
                                            • +
                                            • B
                                            @@ -691,10 +691,10 @@ describe('LexicalListItemNode tests', () => {
                                              • -
                                              • +
                                              • A
                                              • -
                                              • +
                                              • B
                                              @@ -755,24 +755,24 @@ describe('LexicalListItemNode tests', () => {
                                                • -
                                                • +
                                                • A1
                                                  • -
                                                  • +
                                                  • A2
                                              • -
                                              • +
                                              • x
                                                • -
                                                • +
                                                • B
                                                @@ -794,17 +794,17 @@ describe('LexicalListItemNode tests', () => {
                                                  • -
                                                  • +
                                                  • A1
                                                    • -
                                                    • +
                                                    • A2
                                                  • -
                                                  • +
                                                  • B
                                                  @@ -865,24 +865,24 @@ describe('LexicalListItemNode tests', () => {
                                                    • -
                                                    • +
                                                    • A
                                                  • -
                                                  • +
                                                  • x
                                                      • -
                                                      • +
                                                      • B1
                                                    • -
                                                    • +
                                                    • B2
                                                    @@ -904,17 +904,17 @@ describe('LexicalListItemNode tests', () => {
                                                      • -
                                                      • +
                                                      • A
                                                        • -
                                                        • +
                                                        • B1
                                                      • -
                                                      • +
                                                      • B2
                                                      @@ -983,31 +983,31 @@ describe('LexicalListItemNode tests', () => {
                                                        • -
                                                        • +
                                                        • A1
                                                          • -
                                                          • +
                                                          • A2
                                                      • -
                                                      • +
                                                      • x
                                                          • -
                                                          • +
                                                          • B1
                                                        • -
                                                        • +
                                                        • B2
                                                        @@ -1029,20 +1029,20 @@ describe('LexicalListItemNode tests', () => {
                                                          • -
                                                          • +
                                                          • A1
                                                            • -
                                                            • +
                                                            • A2
                                                            • -
                                                            • +
                                                            • B1
                                                          • -
                                                          • +
                                                          • B2
                                                          @@ -1087,13 +1087,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                            -
                                                          • +
                                                          • one
                                                          • -
                                                          • +
                                                          • two
                                                          • -
                                                          • +
                                                          • three
                                                          @@ -1117,14 +1117,14 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                            -
                                                          • +
                                                          • one

                                                          • -
                                                          • +
                                                          • two
                                                          • -
                                                          • +
                                                          • three
                                                          @@ -1148,13 +1148,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                            -
                                                          • +
                                                          • one
                                                          • -
                                                          • +
                                                          • two
                                                          • -
                                                          • +
                                                          • three

                                                          • @@ -1179,13 +1179,13 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                              -
                                                            • +
                                                            • one
                                                            • -
                                                            • +
                                                            • two
                                                            • -
                                                            • +
                                                            • three

                                                            • @@ -1211,7 +1211,7 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                                -
                                                              • +
                                                              • one
                                                              @@ -1231,7 +1231,7 @@ describe('LexicalListItemNode tests', () => { style="user-select: text; white-space: pre-wrap; word-break: break-word;" data-lexical-editor="true">
                                                                -
                                                              • +
                                                              • one

                                                              • @@ -1308,7 +1308,7 @@ describe('LexicalListItemNode tests', () => {
                                                                  • -
                                                                  • +
                                                                  • one
                                                                  @@ -1317,7 +1317,7 @@ describe('LexicalListItemNode tests', () => {
                                                                -
                                                              • +
                                                              • two
                                                              @@ -1336,10 +1336,10 @@ describe('LexicalListItemNode tests', () => { editor.getRootElement()!.innerHTML, html`
                                                                -
                                                              • +
                                                              • one
                                                              • -
                                                              • +
                                                              • two
                                                              diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts index dcbd62ab3..a94f9ee0b 100644 --- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts @@ -117,7 +117,7 @@ describe('LexicalHeadingNode tests', () => { headingTextNode.select(5, 5); }); expect(testEnv.outerHTML).toBe( - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', ); await editor.update(() => { const selection = $getSelection() as RangeSelection; @@ -126,7 +126,7 @@ describe('LexicalHeadingNode tests', () => { expect(result.getDirection()).toEqual(headingNode.getDirection()); }); expect(testEnv.outerHTML).toBe( - '

                                                              hello world


                                                              ', + '

                                                              hello world


                                                              ', ); }); @@ -143,7 +143,7 @@ describe('LexicalHeadingNode tests', () => { headingTextNode2.selectEnd(); }); expect(testEnv.outerHTML).toBe( - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', ); await editor.update(() => { const selection = $getSelection() as RangeSelection; @@ -152,7 +152,7 @@ describe('LexicalHeadingNode tests', () => { expect(result.getDirection()).toEqual(headingNode.getDirection()); }); expect(testEnv.outerHTML).toBe( - '

                                                              hello world


                                                              ', + '

                                                              hello world


                                                              ', ); }); @@ -187,7 +187,7 @@ describe('LexicalHeadingNode tests', () => { headingNode.append(textNode); }); expect(testEnv.outerHTML).toBe( - `

                                                              ${text}

                                                              `, + `

                                                              ${text}

                                                              `, ); await editor.update(() => { const result = headingNode.insertNewAfter(); @@ -195,7 +195,7 @@ describe('LexicalHeadingNode tests', () => { expect(result.getDirection()).toEqual(headingNode.getDirection()); }); expect(testEnv.outerHTML).toBe( - `

                                                              ${text}


                                                              `, + `

                                                              ${text}


                                                              `, ); }); }); diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts index 665f5d854..5f2d9dcc0 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -250,7 +250,7 @@ describe('LexicalSelection tests', () => { const suite = [ { expectedHTML: - '

                                                              Hello

                                                              ', + '

                                                              Hello

                                                              ', expectedSelection: { anchorOffset: 5, anchorPath: [0, 0, 0], @@ -268,7 +268,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello

                                                              ', expectedSelection: { anchorOffset: 5, @@ -288,7 +288,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello

                                                              ', expectedSelection: { anchorOffset: 5, @@ -308,7 +308,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello

                                                              ', expectedSelection: { anchorOffset: 5, @@ -329,7 +329,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello

                                                              ', expectedSelection: { anchorOffset: 5, @@ -349,7 +349,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello

                                                              ', expectedSelection: { anchorOffset: 5, @@ -369,7 +369,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello

                                                              ', expectedSelection: { anchorOffset: 5, @@ -411,7 +411,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Dominic Gannaway' + '

                                                              ', expectedSelection: { @@ -425,7 +425,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Dominic Gannaway' + '

                                                              ', expectedSelection: { @@ -443,7 +443,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Dominic Gannaway' + '

                                                              ', expectedSelection: { @@ -457,7 +457,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Dominic Gannaway' + '

                                                              ', expectedSelection: { @@ -477,7 +477,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello world' + '

                                                              ' + '


                                                              ' + @@ -501,10 +501,10 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello' + '

                                                              ' + - '

                                                              ' + + '

                                                              ' + 'world' + '

                                                              ' + '


                                                              ' + @@ -529,11 +529,11 @@ describe('LexicalSelection tests', () => { { expectedHTML: '
                                                              ' + - '

                                                              ' + + '

                                                              ' + 'He' + 'llo' + '

                                                              ' + - '

                                                              ' + + '

                                                              ' + 'wo' + 'rld' + '

                                                              ' + @@ -557,7 +557,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello ' + 'world' + '

                                                              ' + @@ -582,7 +582,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello' + ' world' + '

                                                              ' + @@ -608,7 +608,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello world' + '

                                                              ' + '


                                                              ' + @@ -634,7 +634,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello world' + '

                                                              ' + '


                                                              ' + @@ -660,7 +660,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello world' + '

                                                              ' + '


                                                              ' + @@ -686,7 +686,7 @@ describe('LexicalSelection tests', () => { expectedHTML: '
                                                              ' + '


                                                              ' + - '

                                                              ' + + '

                                                              ' + 'Hello beautiful world' + '

                                                              ' + '


                                                              ' + @@ -764,7 +764,7 @@ describe('LexicalSelection tests', () => { }, ].flatMap(({whitespaceCharacter, whitespaceName}) => [ { - expectedHTML: `

                                                              Hello${printWhitespace( + expectedHTML: `

                                                              Hello${printWhitespace( whitespaceCharacter, )}

                                                              `, expectedSelection: { @@ -780,7 +780,7 @@ describe('LexicalSelection tests', () => { name: `Type two words separated by a ${whitespaceName}, delete word backward from end`, }, { - expectedHTML: `

                                                              ${printWhitespace( + expectedHTML: `

                                                              ${printWhitespace( whitespaceCharacter, )}world

                                                              `, expectedSelection: { @@ -798,7 +798,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              Hello

                                                              ', + '

                                                              Hello

                                                              ', expectedSelection: { anchorOffset: 5, anchorPath: [0, 0, 0], @@ -814,7 +814,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              world

                                                              ', + '

                                                              world

                                                              ', expectedSelection: { anchorOffset: 0, anchorPath: [0, 0, 0], @@ -830,7 +830,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              Hello world

                                                              ', + '

                                                              Hello world

                                                              ', expectedSelection: { anchorOffset: 11, anchorPath: [0, 0, 0], @@ -842,7 +842,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              Hello

                                                              ', + '

                                                              Hello

                                                              ', expectedSelection: { anchorOffset: 6, anchorPath: [0, 0, 0], @@ -859,7 +859,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'this is weird test

                                                              ', expectedSelection: { anchorOffset: 0, @@ -876,7 +876,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ' + + '

                                                              ' + 'Hello ' + 'Bob' + '

                                                              ', @@ -897,7 +897,7 @@ describe('LexicalSelection tests', () => { }, { expectedHTML: - '

                                                              ABD\tEFG

                                                              ', + '

                                                              ABD\tEFG

                                                              ', expectedSelection: { anchorOffset: 3, anchorPath: [0, 0, 0], @@ -1883,7 +1883,7 @@ describe('LexicalSelection tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              Hello awesome

                                                              world

                                                              ', + '

                                                              Hello awesome

                                                              world

                                                              ', ); }); @@ -1931,7 +1931,7 @@ describe('LexicalSelection tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              Hello awesome

                                                              beautiful world

                                                              ', + '

                                                              Hello awesome

                                                              beautiful world

                                                              ', ); }); diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts index 7b5bef451..4d88bde0e 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -1827,7 +1827,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foo

                                                              ', + '

                                                              foo

                                                              ', ); }); @@ -1868,7 +1868,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foobar

                                                              ', + '

                                                              foobar

                                                              ', ); }); @@ -1913,7 +1913,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', ); }); @@ -1956,7 +1956,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foo

                                                              ', + '

                                                              foo

                                                              ', ); }); }); @@ -1999,7 +1999,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              Existing text...foo

                                                              ', + '

                                                              Existing text...foo

                                                              ', ); }); @@ -2044,7 +2044,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              Existing text...foobar

                                                              ', + '

                                                              Existing text...foobar

                                                              ', ); }); @@ -2091,7 +2091,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              Existing text...foo

                                                              ', + '

                                                              Existing text...foo

                                                              ', ); }); @@ -2162,7 +2162,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              ABCDE

                                                              ', + '

                                                              ABCDE

                                                              ', ); }); }); @@ -2205,7 +2205,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foo

                                                              ', + '

                                                              foo

                                                              ', ); }); }); @@ -2252,7 +2252,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foolink

                                                              ', + '

                                                              foolink

                                                              ', ); }); }); @@ -2299,7 +2299,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              linkfoo

                                                              ', + '

                                                              linkfoo

                                                              ', ); }); }); @@ -2324,7 +2324,7 @@ describe('LexicalSelectionHelpers tests', () => { // TODO #5109 ElementNode should have a way to control when other nodes can be inserted inside expect(element.innerHTML).toBe( - '


                                                              Lexical

                                                              ', + '


                                                              Lexical

                                                              ', ); }); }); @@ -2371,7 +2371,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foo

                                                              ', + '

                                                              foo

                                                              ', ); }); }); @@ -2421,7 +2421,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              foolink

                                                              ', + '

                                                              foolink

                                                              ', ); }); }); @@ -2471,7 +2471,7 @@ describe('LexicalSelectionHelpers tests', () => { }); expect(element.innerHTML).toBe( - '

                                                              linkfoo

                                                              ', + '

                                                              linkfoo

                                                              ', ); }); }); @@ -2491,7 +2491,7 @@ describe('LexicalSelectionHelpers tests', () => { $insertNodes([linkNode]); }); expect(element.innerHTML).toBe( - '

                                                              Lexical

                                                              ', + '

                                                              Lexical

                                                              ', ); }); @@ -2511,7 +2511,7 @@ describe('LexicalSelectionHelpers tests', () => { $insertNodes([linkNode, textNode2]); }); expect(element.innerHTML).toBe( - '

                                                              Lexical...

                                                              ', + '

                                                              Lexical...

                                                              ', ); }); @@ -2604,9 +2604,9 @@ describe('insertNodes', () => { }); expect(element.innerHTML).toBe( - '

                                                              Text before

                                                              ' + + '

                                                              Text before

                                                              ' + '' + - '

                                                              Text after

                                                              ', + '

                                                              Text after

                                                              ', ); }); @@ -2678,7 +2678,7 @@ describe('insertNodes', () => { }); editor.getEditorState().read(() => { expect(element.innerHTML).toBe( - '

                                                              heading


                                                              ', + '

                                                              heading


                                                              ', ); const selectedNode = ($getSelection() as RangeSelection).anchor.getNode(); expect($isParagraphNode(selectedNode)).toBeTruthy(); @@ -2736,8 +2736,8 @@ describe('$patchStyleText', () => { }); expect(element.innerHTML).toBe( - '

                                                              a' + - '' + + '

                                                              a' + + '' + 'link' + '' + 'b

                                                              ', @@ -2789,8 +2789,8 @@ describe('$patchStyleText', () => { }); expect(element.innerHTML).toBe( - '

                                                              a

                                                              ' + - '

                                                              b

                                                              ', + '

                                                              a

                                                              ' + + '

                                                              b

                                                              ', ); }); @@ -2838,9 +2838,9 @@ describe('$patchStyleText', () => { }); expect(element.innerHTML).toBe( - '

                                                              ' + + '

                                                              ' + 'a' + - '' + + '' + 'link' + '' + '

                                                              ', @@ -2891,9 +2891,9 @@ describe('$patchStyleText', () => { }); expect(element.innerHTML).toBe( - '

                                                              ' + + '

                                                              ' + 'a' + - '' + + '' + 'link' + '' + '

                                                              ', @@ -2935,7 +2935,7 @@ describe('$patchStyleText', () => { expect(element.innerHTML).toBe( '

                                                              ' + - '' + + '' + 'link' + '' + '

                                                              ', @@ -2976,7 +2976,7 @@ describe('$patchStyleText', () => { }); expect(element.innerHTML).toBe( - '

                                                              text

                                                              ', + '

                                                              text

                                                              ', ); }); @@ -3115,7 +3115,7 @@ describe('$patchStyleText', () => { }); expect(element.innerHTML).toBe( - '

                                                              ' + + '

                                                              ' + 'fir' + 'st' + 'second' + diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts index 6ce133d18..abc509629 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -102,7 +102,7 @@ describe('LexicalTableNode tests', () => { const dataTransfer = new DataTransferMock(); dataTransfer.setData( 'text/html', - '

                                                              Hello there

                                                              General Kenobi!

                                                              Lexical is nice


                                                              ', + '

                                                              Hello there

                                                              General Kenobi!

                                                              Lexical is nice


                                                              ', ); await editor.update(() => { const selection = $getSelection(); @@ -115,7 +115,7 @@ describe('LexicalTableNode tests', () => { // Make sure paragraph is inserted inside empty cells const emptyCell = '


                                                              '; expect(testEnv.innerHTML).toBe( - `${emptyCell}

                                                              Hello there

                                                              General Kenobi!

                                                              Lexical is nice

                                                              `, + `${emptyCell}

                                                              Hello there

                                                              General Kenobi!

                                                              Lexical is nice

                                                              `, ); }); @@ -125,7 +125,7 @@ describe('LexicalTableNode tests', () => { const dataTransfer = new DataTransferMock(); dataTransfer.setData( 'text/html', - '
                                                              SurfaceMWP_WORK_LS_COMPOSER77349
                                                              LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
                                                              ', + '
                                                              SurfaceMWP_WORK_LS_COMPOSER77349
                                                              LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
                                                              ', ); await editor.update(() => { const selection = $getSelection(); @@ -136,7 +136,7 @@ describe('LexicalTableNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); expect(testEnv.innerHTML).toBe( - `

                                                              Surface

                                                              MWP_WORK_LS_COMPOSER

                                                              77349

                                                              Lexical

                                                              XDS_RICH_TEXT_AREA

                                                              sdvd sdfvsfs

                                                              `, + `

                                                              Surface

                                                              MWP_WORK_LS_COMPOSER

                                                              77349

                                                              Lexical

                                                              XDS_RICH_TEXT_AREA

                                                              sdvd sdfvsfs

                                                              `, ); }); }, diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts index 35ee65b68..d5b85ccaa 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts @@ -99,7 +99,7 @@ describe('table selection', () => { it('Parses the nodes of a stringified editor state', async () => { expect(parsedRoot).toEqual({ __cachedText: null, - __dir: 'ltr', + __dir: null, __first: paragraphKey, __format: 0, __indent: 0, @@ -113,7 +113,7 @@ describe('table selection', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ - __dir: 'ltr', + __dir: null, __first: textKey, __format: 0, __indent: 0, diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts index 53c743df6..7655b4540 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -156,25 +156,25 @@ describe('LexicalEventHelpers', () => { const suite = [ { expectedHTML: - '

                                                              Hello

                                                              ', + '

                                                              Hello

                                                              ', inputs: [pasteHTML(`

                                                              Hello

                                                              `)], name: 'should produce the correct editor state from a pasted HTML h1 element', }, { expectedHTML: - '

                                                              From

                                                              ', + '

                                                              From

                                                              ', inputs: [pasteHTML(`

                                                              From

                                                              `)], name: 'should produce the correct editor state from a pasted HTML h2 element', }, { expectedHTML: - '

                                                              The

                                                              ', + '

                                                              The

                                                              ', inputs: [pasteHTML(`

                                                              The

                                                              `)], name: 'should produce the correct editor state from a pasted HTML h3 element', }, { expectedHTML: - '
                                                              • Other side
                                                              • I must have called
                                                              ', + '
                                                              • Other side
                                                              • I must have called
                                                              ', inputs: [ pasteHTML( `
                                                              • Other side
                                                              • I must have called
                                                              `, @@ -184,7 +184,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                                              1. To tell you
                                                              2. I’m sorry
                                                              ', + '
                                                              1. To tell you
                                                              2. I’m sorry
                                                              ', inputs: [ pasteHTML( `
                                                              1. To tell you
                                                              2. I’m sorry
                                                              `, @@ -194,37 +194,37 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              A thousand times

                                                              ', + '

                                                              A thousand times

                                                              ', inputs: [pasteHTML(`A thousand times`)], name: 'should produce the correct editor state from pasted DOM Text Node', }, { expectedHTML: - '

                                                              Bold

                                                              ', + '

                                                              Bold

                                                              ', inputs: [pasteHTML(`Bold`)], name: 'should produce the correct editor state from a pasted HTML b element', }, { expectedHTML: - '

                                                              Italic

                                                              ', + '

                                                              Italic

                                                              ', inputs: [pasteHTML(`Italic`)], name: 'should produce the correct editor state from a pasted HTML i element', }, { expectedHTML: - '

                                                              Italic

                                                              ', + '

                                                              Italic

                                                              ', inputs: [pasteHTML(`Italic`)], name: 'should produce the correct editor state from a pasted HTML em element', }, { expectedHTML: - '

                                                              Underline

                                                              ', + '

                                                              Underline

                                                              ', inputs: [pasteHTML(`Underline`)], name: 'should produce the correct editor state from a pasted HTML u element', }, { expectedHTML: - '

                                                              Lyrics to Hello by Adele

                                                              A thousand times

                                                              ', + '

                                                              Lyrics to Hello by Adele

                                                              A thousand times

                                                              ', inputs: [ pasteHTML( `

                                                              Lyrics to Hello by Adele

                                                              A thousand times`, @@ -234,7 +234,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '', + '', inputs: [ pasteHTML( `Facebook`, @@ -244,7 +244,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Welcome toFacebook!

                                                              ', + '

                                                              Welcome toFacebook!

                                                              ', inputs: [ pasteHTML( `Welcome toFacebook!`, @@ -254,7 +254,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', + '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', inputs: [ pasteHTML( `Welcome toFacebook!We hope you like it here.`, @@ -264,7 +264,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                                              • Hello
                                                              • from the other
                                                              • side
                                                              ', + '
                                                              • Hello
                                                              • from the other
                                                              • side
                                                              ', inputs: [ pasteHTML( `
                                                              • Hello
                                                              • from the other
                                                              • side
                                                              `, @@ -274,7 +274,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                                              • Hello
                                                              • from the other
                                                              • side
                                                              ', + '
                                                              • Hello
                                                              • from the other
                                                              • side
                                                              ', inputs: [ pasteHTML( `
                                                              • Hello
                                                              • from the other
                                                              • side
                                                              `, @@ -284,7 +284,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', + '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', inputs: [ pasteHTML( `Welcome toFacebook!We hope you like it here.`, @@ -294,7 +294,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', + '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', inputs: [ pasteHTML( `Welcome toFacebook!We hope you like it here.`, @@ -304,7 +304,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', + '

                                                              Welcome toFacebook!We hope you like it here.

                                                              ', inputs: [ pasteHTML( `Welcome toFacebook!We hope you like it here.`, @@ -330,7 +330,7 @@ describe('LexicalEventHelpers', () => { const suite = [ { expectedHTML: - '

                                                              Get schwifty!

                                                              ', + '

                                                              Get schwifty!

                                                              ', inputs: [ pasteHTML( `Get schwifty!`, @@ -340,7 +340,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Get schwifty!

                                                              ', + '

                                                              Get schwifty!

                                                              ', inputs: [ pasteHTML( `Get schwifty!`, @@ -350,7 +350,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Get schwifty!

                                                              ', + '

                                                              Get schwifty!

                                                              ', inputs: [ pasteHTML( `Get schwifty!`, @@ -360,7 +360,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              Get schwifty!

                                                              ', + '

                                                              Get schwifty!

                                                              ', inputs: [ pasteHTML( `Get schwifty!`, @@ -386,20 +386,20 @@ describe('LexicalEventHelpers', () => { const suite = [ { expectedHTML: - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', inputs: [pasteHTML('hello world')], name: 'inline hello world', }, { expectedHTML: - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', inputs: [pasteHTML(' hello world ')], name: 'inline hello world (2)', }, { // MS Office got it right expectedHTML: - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', inputs: [ pasteHTML(' hello world '), ], @@ -407,19 +407,19 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a b\tc

                                                              ', + '

                                                              a b\tc

                                                              ', inputs: [pasteHTML('

                                                              a b\tc

                                                              ')], name: 'white-space: pre (1) (no touchy)', }, { expectedHTML: - '

                                                              a b c

                                                              ', + '

                                                              a b c

                                                              ', inputs: [pasteHTML('

                                                              \ta\tb c\t\t

                                                              ')], name: 'tabs are collapsed', }, { expectedHTML: - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', inputs: [ pasteHTML(`
                                                              @@ -432,7 +432,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              hello world

                                                              ', + '

                                                              hello world

                                                              ', inputs: [ pasteHTML(`
                                                              @@ -447,7 +447,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a b c

                                                              ', + '

                                                              a b c

                                                              ', inputs: [ pasteHTML(`
                                                              @@ -461,25 +461,25 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a b

                                                              ', + '

                                                              a b

                                                              ', inputs: [pasteHTML('
                                                              a b
                                                              ')], name: 'collapsibles and neighbors (1)', }, { expectedHTML: - '

                                                              a b

                                                              ', + '

                                                              a b

                                                              ', inputs: [pasteHTML('
                                                              a b
                                                              ')], name: 'collapsibles and neighbors (2)', }, { expectedHTML: - '

                                                              a b

                                                              ', + '

                                                              a b

                                                              ', inputs: [pasteHTML('
                                                              a b
                                                              ')], name: 'collapsibles and neighbors (3)', }, { expectedHTML: - '

                                                              a b

                                                              ', + '

                                                              a b

                                                              ', inputs: [pasteHTML('
                                                              a b
                                                              ')], name: 'collapsibles and neighbors (4)', }, @@ -495,19 +495,19 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a

                                                              ', + '

                                                              a

                                                              ', inputs: [pasteHTML(' a')], name: 'redundant inline at start', }, { expectedHTML: - '

                                                              a

                                                              ', + '

                                                              a

                                                              ', inputs: [pasteHTML('a ')], name: 'redundant inline at end', }, { expectedHTML: - '

                                                              a

                                                              b

                                                              ', + '

                                                              a

                                                              b

                                                              ', inputs: [ pasteHTML(`
                                                              @@ -524,7 +524,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a b

                                                              ', + '

                                                              a b

                                                              ', inputs: [ pasteHTML(`
                                                              @@ -541,7 +541,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a
                                                              b

                                                              ', + '

                                                              a
                                                              b

                                                              ', inputs: [ pasteHTML(`

                                                              @@ -555,7 +555,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a
                                                              b

                                                              ', + '

                                                              a
                                                              b

                                                              ', inputs: [ pasteHTML(`

                                                              @@ -569,7 +569,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              paragraph1

                                                              paragraph2

                                                              ', + '

                                                              paragraph1

                                                              paragraph2

                                                              ', inputs: [ pasteHTML( '\n

                                                              paragraph1

                                                              \n

                                                              paragraph2

                                                              \n', @@ -579,7 +579,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              line 1
                                                              line 2


                                                              paragraph 1

                                                              paragraph 2

                                                              ', + '

                                                              line 1
                                                              line 2


                                                              paragraph 1

                                                              paragraph 2

                                                              ', inputs: [ pasteHTML( '\n

                                                              line 1
                                                              \nline 2

                                                              \n


                                                              \n

                                                              paragraph 1

                                                              \n

                                                              paragraph 2

                                                              \n', @@ -589,7 +589,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              line 1
                                                              line 2


                                                              paragraph 1

                                                              paragraph 2

                                                              ', + '

                                                              line 1
                                                              line 2


                                                              paragraph 1

                                                              paragraph 2

                                                              ', inputs: [ pasteHTML( '\n

                                                              line 1
                                                              \nline 2

                                                              \n

                                                              \n
                                                              \n

                                                              \n

                                                              paragraph 1

                                                              \n

                                                              paragraph 2

                                                              \n', @@ -599,7 +599,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              line 1
                                                              line 2

                                                              ', + '

                                                              line 1
                                                              line 2

                                                              ', inputs: [ pasteHTML( '

                                                              line 1
                                                              line 2

                                                              ', @@ -635,7 +635,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                              a

                                                              b b

                                                              c

                                                              z

                                                              d e

                                                              fg

                                                              ', + '

                                                              a

                                                              b b

                                                              c

                                                              z

                                                              d e

                                                              fg

                                                              ', inputs: [ pasteHTML( `
                                                              a
                                                              b b
                                                              c
                                                              z
                                                              d e
                                                              fg
                                                              `, diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts index 8a0475c7b..36e692f25 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -1,10 +1,12 @@ import {LexicalNode, Spread} from "lexical"; import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; -import {sizeToPixels} from "../utils/dom"; +import {el, sizeToPixels} from "../utils/dom"; export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; +type EditorNodeDirection = 'ltr' | 'rtl' | null; + export type SerializedCommonBlockNode = Spread<{ id: string; alignment: CommonBlockAlignment; @@ -29,7 +31,13 @@ export interface NodeHasInset { getInset(): number; } -interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset {} +export interface NodeHasDirection { + readonly __dir: EditorNodeDirection; + setDirection(direction: EditorNodeDirection): void; + getDirection(): EditorNodeDirection; +} + +interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset, NodeHasDirection {} export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment { const textAlignStyle: string = element.style.textAlign || ''; @@ -55,6 +63,15 @@ export function extractInsetFromElement(element: HTMLElement): number { return sizeToPixels(elemPadding); } +function extractDirectionFromElement(element: HTMLElement): EditorNodeDirection { + const elemDir = (element.dir || '').toLowerCase(); + if (elemDir === 'rtl' || elemDir === 'ltr') { + return elemDir; + } + + return null; +} + export function setCommonBlockPropsFromElement(element: HTMLElement, node: CommonBlockInterface): void { if (element.id) { node.setId(element.id); @@ -62,12 +79,14 @@ export function setCommonBlockPropsFromElement(element: HTMLElement, node: Commo node.setAlignment(extractAlignmentFromElement(element)); node.setInset(extractInsetFromElement(element)); + node.setDirection(extractDirectionFromElement(element)); } export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: CommonBlockInterface): boolean { return nodeA.__id !== nodeB.__id || nodeA.__alignment !== nodeB.__alignment || - nodeA.__inset !== nodeB.__inset; + nodeA.__inset !== nodeB.__inset || + nodeA.__dir !== nodeB.__dir; } export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void { @@ -82,12 +101,17 @@ export function updateElementWithCommonBlockProps(element: HTMLElement, node: Co if (node.__inset) { element.style.paddingLeft = `${node.__inset}px`; } + + if (node.__dir) { + element.dir = node.__dir; + } } export function deserializeCommonBlockNode(serializedNode: SerializedCommonBlockNode, node: CommonBlockInterface): void { node.setId(serializedNode.id); node.setAlignment(serializedNode.alignment); node.setInset(serializedNode.inset); + node.setDirection(serializedNode.direction); } export interface NodeHasSize { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 874ac537f..e07792a1c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,6 +3,10 @@ ## In progress - RTL/LTR support + - Basic implementation added + - Test across main range of content blocks + - Test that HTML is being set as expected + - Test editor defaults when between RTL/LTR modes ## Main Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 130fd6b72..b1c701dda 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -37,14 +37,15 @@ function setAlignmentForSelection(editor: LexicalEditor, alignment: CommonBlockA $toggleSelection(editor); } -function setDirectionForSelection(editor: LexicalEditor, direction: 'ltr' | 'rtl'): void { - const selection = getLastSelection(editor); +function setDirectionForSelection(context: EditorUiContext, direction: 'ltr' | 'rtl'): void { + const selection = getLastSelection(context.editor); const elements = $getBlockElementNodesInSelection(selection); for (const node of elements) { - console.log('setting direction', node); node.setDirection(direction); } + + context.manager.triggerFutureStateRefresh(); } export const alignLeft: EditorButtonDefinition = { @@ -95,7 +96,7 @@ export const directionLTR: EditorButtonDefinition = { label: 'Left to right', icon: ltrIcon, action(context: EditorUiContext) { - context.editor.update(() => setDirectionForSelection(context.editor, 'ltr')); + context.editor.update(() => setDirectionForSelection(context, 'ltr')); }, isActive(selection: BaseSelection|null) { return $selectionContainsDirection(selection, 'ltr'); @@ -106,7 +107,7 @@ export const directionRTL: EditorButtonDefinition = { label: 'Right to left', icon: rtlIcon, action(context: EditorUiContext) { - context.editor.update(() => setDirectionForSelection(context.editor, 'rtl')); + context.editor.update(() => setDirectionForSelection(context, 'rtl')); }, isActive(selection: BaseSelection|null) { return $selectionContainsDirection(selection, 'rtl'); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index 87630eb27..9cfa8168e 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -3,7 +3,6 @@ import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; import { BaseSelection, - LexicalEditor, LexicalNode, } from "lexical"; import listBulletIcon from "@icons/editor/list-bullet.svg"; @@ -12,15 +11,10 @@ import listCheckIcon from "@icons/editor/list-check.svg"; import indentIncreaseIcon from "@icons/editor/indent-increase.svg"; import indentDecreaseIcon from "@icons/editor/indent-decrease.svg"; import { - $getBlockElementNodesInSelection, - $selectionContainsNodeType, $selectNodes, $selectSingleNode, - $toggleSelection, - getLastSelection + $selectionContainsNodeType, } from "../../../utils/selection"; import {toggleSelectionAsList} from "../../../utils/formats"; -import {nodeHasInset} from "../../../utils/nodes"; -import {$isCustomListItemNode, CustomListItemNode} from "../../../nodes/custom-list-item"; -import {$nestListItem, $setInsetForSelection, $unnestListItem} from "../../../utils/lists"; +import {$setInsetForSelection} from "../../../utils/lists"; function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { diff --git a/tsconfig.json b/tsconfig.json index 4026872ac..8bffc25f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["resources/js/**/*"], + "exclude": ["resources/js/wysiwyg/lexical/yjs/*"], "compilerOptions": { "target": "es2019", "module": "commonjs", From e6edd9340ee59cdd33634647bd467dd806529a6b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Sep 2024 17:02:54 +0100 Subject: [PATCH 101/107] Lexical: Added alignment detoggle, fixed inital focus area --- resources/js/wysiwyg/todo.md | 1 - .../wysiwyg/ui/defaults/buttons/alignments.ts | 28 ++++++++++--------- resources/sass/_editor.scss | 2 ++ .../pages/parts/wysiwyg-editor.blade.php | 4 +-- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index e07792a1c..7fcf25444 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -22,5 +22,4 @@ ## Bugs -- Focus/click area reduced to content area, single line on initial access - List selection can get lost on nesting/unnesting \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index b1c701dda..6233e6ce6 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,4 +1,4 @@ -import {$isElementNode, BaseSelection, LexicalEditor} from "lexical"; +import {$isElementNode, BaseSelection} from "lexical"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; @@ -15,26 +15,28 @@ import {CommonBlockAlignment} from "../../../nodes/_common"; import {nodeHasAlignment} from "../../../utils/nodes"; -function setAlignmentForSelection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { - const selection = getLastSelection(editor); +function setAlignmentForSelection(context: EditorUiContext, alignment: CommonBlockAlignment): void { + const selection = getLastSelection(context.editor); const selectionNodes = selection?.getNodes() || []; // Handle inline node selection alignment if (selectionNodes.length === 1 && $isElementNode(selectionNodes[0]) && selectionNodes[0].isInline() && nodeHasAlignment(selectionNodes[0])) { selectionNodes[0].setAlignment(alignment); $selectSingleNode(selectionNodes[0]); - $toggleSelection(editor); + context.manager.triggerFutureStateRefresh(); return; } // Handle normal block/range alignment const elements = $getBlockElementNodesInSelection(selection); - for (const node of elements) { - if (nodeHasAlignment(node)) { - node.setAlignment(alignment) - } + const alignmentNodes = elements.filter(n => nodeHasAlignment(n)); + const allAlreadyAligned = alignmentNodes.every(n => n.getAlignment() === alignment); + const newAlignment = allAlreadyAligned ? '' : alignment; + for (const node of alignmentNodes) { + node.setAlignment(newAlignment); } - $toggleSelection(editor); + + context.manager.triggerFutureStateRefresh(); } function setDirectionForSelection(context: EditorUiContext, direction: 'ltr' | 'rtl'): void { @@ -52,7 +54,7 @@ export const alignLeft: EditorButtonDefinition = { label: 'Align left', icon: alignLeftIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSelection(context.editor, 'left')); + context.editor.update(() => setAlignmentForSelection(context, 'left')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'left'); @@ -63,7 +65,7 @@ export const alignCenter: EditorButtonDefinition = { label: 'Align center', icon: alignCenterIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSelection(context.editor, 'center')); + context.editor.update(() => setAlignmentForSelection(context, 'center')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'center'); @@ -74,7 +76,7 @@ export const alignRight: EditorButtonDefinition = { label: 'Align right', icon: alignRightIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSelection(context.editor, 'right')); + context.editor.update(() => setAlignmentForSelection(context, 'right')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'right'); @@ -85,7 +87,7 @@ export const alignJustify: EditorButtonDefinition = { label: 'Align justify', icon: alignJustifyIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSelection(context.editor, 'justify')); + context.editor.update(() => setAlignmentForSelection(context, 'justify')); }, isActive(selection: BaseSelection|null) { return $selectionContainsAlignment(selection, 'justify'); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index dd1e1a2c3..91aef9920 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -26,6 +26,7 @@ body.editor-is-fullscreen { } } .editor-content-area { + min-height: 100%; &:focus { outline: 0; } @@ -33,6 +34,7 @@ body.editor-is-fullscreen { .editor-content-wrap { position: relative; overflow-y: scroll; + flex: 1; } // Buttons diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index ee5a2e19b..d927a03d3 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -4,9 +4,9 @@ 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-container-column flex-fill"> + class="flex-container-column flex-fill flex"> -
                                                              +
                                                              From 2add15bd728b0f02af619e425d97b441d5832145 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Sep 2024 12:07:24 +0100 Subject: [PATCH 102/107] Lexical: Added direction support to extra blocks Also removed duplicated dir functionality that remained in core. --- .../core/nodes/LexicalParagraphNode.ts | 5 ----- .../js/wysiwyg/lexical/rich-text/index.ts | 12 ------------ resources/js/wysiwyg/nodes/_common.ts | 2 +- resources/js/wysiwyg/nodes/custom-list.ts | 19 +++++++++++++++++-- resources/js/wysiwyg/nodes/details.ts | 16 ++++++++++++++-- resources/js/wysiwyg/todo.md | 14 ++++++++------ 6 files changed, 40 insertions(+), 28 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts index deab3a2cc..4e69dc21c 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -135,10 +135,6 @@ export class ParagraphNode extends ElementNode { const formatType = this.getFormatType(); element.style.textAlign = formatType; - const direction = this.getDirection(); - if (direction) { - element.dir = direction; - } const indent = this.getIndent(); if (indent > 0) { // padding-inline-start is not widely supported in email HTML, but @@ -156,7 +152,6 @@ export class ParagraphNode extends ElementNode { const node = $createParagraphNode(); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); - node.setDirection(serializedNode.direction); node.setTextFormat(serializedNode.textFormat); return node; } diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts index fd9162566..d937060c6 100644 --- a/resources/js/wysiwyg/lexical/rich-text/index.ts +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -158,11 +158,6 @@ export class QuoteNode extends ElementNode { const formatType = this.getFormatType(); element.style.textAlign = formatType; - - const direction = this.getDirection(); - if (direction) { - element.dir = direction; - } } return { @@ -174,7 +169,6 @@ export class QuoteNode extends ElementNode { const node = $createQuoteNode(); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); - node.setDirection(serializedNode.direction); return node; } @@ -324,11 +318,6 @@ export class HeadingNode extends ElementNode { const formatType = this.getFormatType(); element.style.textAlign = formatType; - - const direction = this.getDirection(); - if (direction) { - element.dir = direction; - } } return { @@ -340,7 +329,6 @@ export class HeadingNode extends ElementNode { const node = $createHeadingNode(serializedNode.tag); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); - node.setDirection(serializedNode.direction); return node; } diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts index 36e692f25..71849bb45 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -63,7 +63,7 @@ export function extractInsetFromElement(element: HTMLElement): number { return sizeToPixels(elemPadding); } -function extractDirectionFromElement(element: HTMLElement): EditorNodeDirection { +export function extractDirectionFromElement(element: HTMLElement): EditorNodeDirection { const elemDir = (element.dir || '').toLowerCase(); if (elemDir === 'rtl' || elemDir === 'ltr') { return elemDir; diff --git a/resources/js/wysiwyg/nodes/custom-list.ts b/resources/js/wysiwyg/nodes/custom-list.ts index a6c473999..4b05fa62e 100644 --- a/resources/js/wysiwyg/nodes/custom-list.ts +++ b/resources/js/wysiwyg/nodes/custom-list.ts @@ -1,12 +1,12 @@ import { DOMConversionFn, - DOMConversionMap, + DOMConversionMap, EditorConfig, LexicalNode, Spread } from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list"; import {$createCustomListItemNode} from "./custom-list-item"; +import {extractDirectionFromElement} from "./_common"; export type SerializedCustomListNode = Spread<{ @@ -33,6 +33,7 @@ export class CustomListNode extends ListNode { static clone(node: CustomListNode) { const newNode = new CustomListNode(node.__listType, node.__start, node.__key); newNode.__id = node.__id; + newNode.__dir = node.__dir; return newNode; } @@ -42,9 +43,18 @@ export class CustomListNode extends ListNode { dom.setAttribute('id', this.__id); } + if (this.__dir) { + dom.setAttribute('dir', this.__dir); + } + return dom; } + updateDOM(prevNode: ListNode, dom: HTMLElement, config: EditorConfig): boolean { + return super.updateDOM(prevNode, dom, config) || + prevNode.__dir !== this.__dir; + } + exportJSON(): SerializedCustomListNode { return { ...super.exportJSON(), @@ -57,6 +67,7 @@ export class CustomListNode extends ListNode { static importJSON(serializedNode: SerializedCustomListNode): CustomListNode { const node = $createCustomListNode(serializedNode.listType); node.setId(serializedNode.id); + node.setDirection(serializedNode.direction); return node; } @@ -69,6 +80,10 @@ export class CustomListNode extends ListNode { (baseResult.node as CustomListNode).setId(element.id); } + if (element.dir && baseResult?.node) { + (baseResult.node as CustomListNode).setDirection(extractDirectionFromElement(element)); + } + if (baseResult) { baseResult.after = $normalizeChildren; } diff --git a/resources/js/wysiwyg/nodes/details.ts b/resources/js/wysiwyg/nodes/details.ts index 119619da6..de87696f3 100644 --- a/resources/js/wysiwyg/nodes/details.ts +++ b/resources/js/wysiwyg/nodes/details.ts @@ -5,10 +5,11 @@ import { LexicalEditor, LexicalNode, SerializedElementNode, Spread, + EditorConfig, } from 'lexical'; -import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../utils/dom"; +import {extractDirectionFromElement} from "./_common"; export type SerializedDetailsNode = Spread<{ id: string; @@ -34,6 +35,7 @@ export class DetailsNode extends ElementNode { static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; + newNode.__dir = node.__dir; return newNode; } @@ -43,11 +45,16 @@ export class DetailsNode extends ElementNode { el.setAttribute('id', this.__id); } + if (this.__dir) { + el.setAttribute('dir', this.__dir); + } + return el; } updateDOM(prevNode: DetailsNode, dom: HTMLElement) { - return prevNode.__id !== this.__id; + return prevNode.__id !== this.__id + || prevNode.__dir !== this.__dir; } static importDOM(): DOMConversionMap|null { @@ -60,6 +67,10 @@ export class DetailsNode extends ElementNode { node.setId(element.id); } + if (element.dir) { + node.setDirection(extractDirectionFromElement(element)); + } + return {node}; }, priority: 3, @@ -80,6 +91,7 @@ export class DetailsNode extends ElementNode { static importJSON(serializedNode: SerializedDetailsNode): DetailsNode { const node = $createDetailsNode(); node.setId(serializedNode.id); + node.setDirection(serializedNode.direction); return node; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 7fcf25444..66878f37d 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,16 +2,14 @@ ## In progress -- RTL/LTR support - - Basic implementation added - - Test across main range of content blocks - - Test that HTML is being set as expected - - Test editor defaults when between RTL/LTR modes +// ## Main Todo - Mac: Shortcut support via command. - Translations +- Form closing on submit +- Update toolbar overflows to match existing editor, incl. direction dynamic controls ## Secondary Todo @@ -19,7 +17,11 @@ - Color picker for color controls - Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) +- Check translation coverage ## Bugs -- List selection can get lost on nesting/unnesting \ No newline at end of file +- List selection can get lost on nesting/unnesting +- Can't escape lists when bottom element +- Content not properly saving on new pages +- BookStack UI (non-editor) shortcuts can trigger in editor (`/` for example) \ No newline at end of file From ef3de1050f880d6848e2382c5df13cf48131c02e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Sep 2024 12:29:06 +0100 Subject: [PATCH 103/107] Lexical: Added UI translation support --- resources/js/components/wysiwyg-editor.js | 11 +++++++---- .../js/wysiwyg/ui/defaults/buttons/alignments.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/block-formats.ts | 2 +- resources/js/wysiwyg/ui/defaults/buttons/controls.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/inline-formats.ts | 4 ++-- resources/js/wysiwyg/ui/defaults/modals.ts | 2 +- resources/js/wysiwyg/ui/framework/forms.ts | 2 +- resources/js/wysiwyg/ui/index.ts | 5 ++++- resources/views/pages/parts/wysiwyg-editor.blade.php | 4 ++-- 9 files changed, 20 insertions(+), 14 deletions(-) diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index eed1c6155..56dbe8d7c 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -10,6 +10,12 @@ export class WysiwygEditor extends Component { /** @var {SimpleWysiwygEditorInterface|null} */ this.editor = null; + const translations = { + ...window.editor_translations, + imageUploadErrorText: this.$opts.imageUploadErrorText, + serverUploadLimitText: this.$opts.serverUploadLimitText, + }; + window.importVersioned('wysiwyg').then(wysiwyg => { const editorContent = this.input.value; this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent, { @@ -17,10 +23,7 @@ export class WysiwygEditor extends Component { pageId: Number(this.$opts.pageId), darkMode: document.documentElement.classList.contains('dark-mode'), textDirection: this.$opts.textDirection, - translations: { - imageUploadErrorText: this.$opts.imageUploadErrorText, - serverUploadLimitText: this.$opts.serverUploadLimitText, - }, + translations, }); }); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 6233e6ce6..f0f46ddc6 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -84,7 +84,7 @@ export const alignRight: EditorButtonDefinition = { }; export const alignJustify: EditorButtonDefinition = { - label: 'Align justify', + label: 'Justify', icon: alignJustifyIcon, action(context: EditorUiContext) { context.editor.update(() => setAlignmentForSelection(context, 'justify')); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index 80e493486..f86e33c31 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -17,7 +17,7 @@ import { function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { - label: `${name} Callout`, + label: name, action(context: EditorUiContext) { context.editor.update(() => { $toggleSelectionBlockNodeType( diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 8829d241f..77223dac3 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -56,7 +56,7 @@ export const redo: EditorButtonDefinition = { export const source: EditorButtonDefinition = { - label: 'Source code', + label: 'Source', icon: sourceIcon, async action(context: EditorUiContext) { const modal = context.manager.createModal('source'); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index a967ecb2f..c3726acf0 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -30,12 +30,12 @@ export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold', bo export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic', italicIcon); export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline', underlinedIcon); export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; -export const highlightColor: EditorBasicButtonDefinition = {label: 'Highlight color', icon: highlightIcon}; +export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); -export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code', codeIcon); +export const code: EditorButtonDefinition = buildFormatButton('Inline code', 'code', codeIcon); export const clearFormating: EditorButtonDefinition = { label: 'Clear formatting', icon: formatClearIcon, diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index 44d4e0360..c43923778 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -5,7 +5,7 @@ import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; export const modals: Record = { link: { - title: 'Insert/Edit link', + title: 'Insert/Edit Link', form: link, }, image: { diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index a2db34dd7..615d5b4de 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -54,7 +54,7 @@ export class EditorFormField extends EditorUiElement { if (this.definition.type === 'select') { const options = (this.definition as EditorSelectFormFieldDefinition).valuesByLabel const labels = Object.keys(options); - const optionElems = labels.map(label => el('option', {value: options[label]}, [label])); + const optionElems = labels.map(label => el('option', {value: options[label]}, [this.trans(label)])); input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); } else if (this.definition.type === 'textarea') { input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'}); diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 71a2623d6..8bfdb8965 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -19,7 +19,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro editorDOM: element, scrollDOM: scrollContainer, manager, - translate: (text: string): string => text, // TODO - Implement + translate(text: string): string { + const translations = options.translations; + return translations[text] || text; + }, error(error: string|Error): void { const message = error instanceof Error ? error.message : error; window.$events.error(message); // TODO - Translate diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index d927a03d3..73e655752 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -9,7 +9,7 @@
                                                              -
                                                              +{{--
                                                              --}}
                                                              @@ -18,4 +18,4 @@
                                                              {{ $errors->first('html') }}
                                                              @endif -{{--TODO - @include('form.editor-translations')--}} \ No newline at end of file +@include('form.editor-translations') \ No newline at end of file From c8ccb2bac7a211741222c371bf92caac65fc9979 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Sep 2024 16:15:02 +0100 Subject: [PATCH 104/107] Lexical: Range of fixes - Prevented ui shortcuts running in editor - Added form modal closing on submit - Fixed ability to escape lists via enter on empty last item --- resources/js/components/shortcuts.js | 2 +- .../lexical/list/LexicalListItemNode.ts | 12 ++++++++++++ resources/js/wysiwyg/todo.md | 8 ++------ .../js/wysiwyg/ui/defaults/forms/objects.ts | 18 ++++++++++++------ resources/js/wysiwyg/ui/framework/forms.ts | 12 ++++++++++-- resources/js/wysiwyg/ui/framework/modals.ts | 1 + 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js index b22c46731..8bf26fbb5 100644 --- a/resources/js/components/shortcuts.js +++ b/resources/js/components/shortcuts.js @@ -25,7 +25,7 @@ export class Shortcuts extends Component { setupListeners() { window.addEventListener('keydown', event => { - if (event.target.closest('input, select, textarea, .cm-editor')) { + if (event.target.closest('input, select, textarea, .cm-editor, .editor-container')) { return; } diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 7d12b5bd3..5026a0129 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -259,9 +259,21 @@ export class ListItemNode extends ElementNode { _: RangeSelection, restoreSelection = true, ): ListItemNode | ParagraphNode { + + if (this.getTextContent().trim() === '' && this.isLastChild()) { + const list = this.getParentOrThrow(); + if (!$isListItemNode(list.getParent())) { + const paragraph = $createParagraphNode(); + list.insertAfter(paragraph, restoreSelection); + this.remove(); + return paragraph; + } + } + const newElement = $createListItemNode( this.__checked == null ? undefined : false, ); + this.insertAfter(newElement, restoreSelection); return newElement; diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 66878f37d..91e1a9678 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -7,8 +7,6 @@ ## Main Todo - Mac: Shortcut support via command. -- Translations -- Form closing on submit - Update toolbar overflows to match existing editor, incl. direction dynamic controls ## Secondary Todo @@ -17,11 +15,9 @@ - Color picker for color controls - Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) -- Check translation coverage +- Deep check of translation coverage ## Bugs - List selection can get lost on nesting/unnesting -- Can't escape lists when bottom element -- Content not properly saving on new pages -- BookStack UI (non-editor) shortcuts can trigger in editor (`/` for example) \ No newline at end of file +- Content not properly saving on new pages \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index f1575953b..228566d44 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -100,13 +100,12 @@ export const image: EditorFormDefinition = { export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) { const linkModal = context.manager.createModal('link'); - let formDefaults = {}; if (link) { - formDefaults = { + const formDefaults: Record = { url: link.getURL(), text: link.getTextContent(), - title: link.getTitle(), - target: link.getTarget(), + title: link.getTitle() || '', + target: link.getTarget() || '', } context.editor.update(() => { @@ -114,9 +113,16 @@ export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) { selection.add(link.getKey()); $setSelection(selection); }); - } - linkModal.show(formDefaults); + linkModal.show(formDefaults); + } else { + context.editor.getEditorState().read(() => { + const selection = $getSelection(); + const text = selection?.getTextContent() || ''; + const formDefaults = {text}; + linkModal.show(formDefaults); + }); + } } export const link: EditorFormDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 615d5b4de..36371e302 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -72,6 +72,7 @@ export class EditorFormField extends EditorUiElement { export class EditorForm extends EditorContainerUiElement { protected definition: EditorFormDefinition; protected onCancel: null|(() => void) = null; + protected onSuccessfulSubmit: null|(() => void) = null; constructor(definition: EditorFormDefinition) { let children: (EditorFormField|EditorUiElement)[] = definition.fields.map(fieldDefinition => { @@ -98,6 +99,10 @@ export class EditorForm extends EditorContainerUiElement { this.onCancel = callback; } + setOnSuccessfulSubmit(callback: () => void) { + this.onSuccessfulSubmit = callback; + } + protected getFieldByName(name: string): EditorFormField|null { const search = (children: EditorUiElement[]): EditorFormField|null => { @@ -128,10 +133,13 @@ export class EditorForm extends EditorContainerUiElement { ]) ]); - form.addEventListener('submit', (event) => { + form.addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(form as HTMLFormElement); - this.definition.action(formData, this.getContext()); + const result = await this.definition.action(formData, this.getContext()); + if (result && this.onSuccessfulSubmit) { + this.onSuccessfulSubmit(); + } }); cancelButton.addEventListener('click', (event) => { diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index ae69302f6..3eea62ebb 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -28,6 +28,7 @@ export class EditorFormModal extends EditorContainerUiElement { const form = this.getForm(); form.setValues(defaultValues); form.setOnCancel(this.hide.bind(this)); + form.setOnSuccessfulSubmit(this.hide.bind(this)); this.getContext().manager.setModalActive(this.key, this); } From 8b32e6c15ac4a188b48aed478be4382acae120ee Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Sep 2024 20:06:55 +0100 Subject: [PATCH 105/107] Page Editors: Added switching/options for new lexical editor --- app/Entities/Models/Page.php | 1 + app/Entities/Repos/PageRepo.php | 11 ++-- app/Entities/Tools/PageEditorData.php | 22 ++----- app/Entities/Tools/PageEditorType.php | 37 +++++++++++ lang/en/entities.php | 2 + resources/js/wysiwyg/todo.md | 1 + .../pages/parts/editor-toolbar.blade.php | 15 ++++- resources/views/pages/parts/form.blade.php | 7 +- .../views/settings/customization.blade.php | 1 + tests/Entity/PageEditorTest.php | 65 +++++++++++++++++-- 10 files changed, 134 insertions(+), 28 deletions(-) create mode 100644 app/Entities/Tools/PageEditorType.php diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index 3a433338b..499ef4d72 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -3,6 +3,7 @@ namespace BookStack\Entities\Models; use BookStack\Entities\Tools\PageContent; +use BookStack\Entities\Tools\PageEditorType; use BookStack\Permissions\PermissionApplicator; use BookStack\Uploads\Attachment; use Illuminate\Database\Eloquent\Builder; diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 2526b6c44..0d9541c52 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditorData; +use BookStack\Entities\Tools\PageEditorType; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; @@ -126,7 +127,9 @@ class PageRepo } $pageContent = new PageContent($page); - $currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor(); + $defaultEditor = PageEditorType::getSystemDefault(); + $currentEditor = PageEditorType::forPage($page) ?: $defaultEditor; + $inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor; $newEditor = $currentEditor; $haveInput = isset($input['markdown']) || isset($input['html']); @@ -135,15 +138,15 @@ class PageRepo if ($haveInput && $inputEmpty) { $pageContent->setNewHTML('', user()); } elseif (!empty($input['markdown']) && is_string($input['markdown'])) { - $newEditor = 'markdown'; + $newEditor = PageEditorType::Markdown; $pageContent->setNewMarkdown($input['markdown'], user()); } elseif (isset($input['html'])) { - $newEditor = 'wysiwyg'; + $newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce; $pageContent->setNewHTML($input['html'], user()); } if ($newEditor !== $currentEditor && userCan('editor-change')) { - $page->editor = $newEditor; + $page->editor = $newEditor->value; } } diff --git a/app/Entities/Tools/PageEditorData.php b/app/Entities/Tools/PageEditorData.php index f0bd23589..e4fe2fd25 100644 --- a/app/Entities/Tools/PageEditorData.php +++ b/app/Entities/Tools/PageEditorData.php @@ -74,17 +74,17 @@ class PageEditorData ]; } - protected function updateContentForEditor(Page $page, string $editorType): void + protected function updateContentForEditor(Page $page, PageEditorType $editorType): void { $isHtml = !empty($page->html) && empty($page->markdown); // HTML to markdown-clean conversion - if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') { + if ($editorType === PageEditorType::Markdown && $isHtml && $this->requestedEditor === 'markdown-clean') { $page->markdown = (new HtmlToMarkdown($page->html))->convert(); } // Markdown to HTML conversion if we don't have HTML - if ($editorType === 'wysiwyg' && !$isHtml) { + if ($editorType->isHtmlBased() && !$isHtml) { $page->html = (new MarkdownToHtml($page->markdown))->convert(); } } @@ -94,24 +94,16 @@ class PageEditorData * Defaults based upon the current content of the page otherwise will fall back * to system default but will take a requested type (if provided) if permissions allow. */ - protected function getEditorType(Page $page): string + protected function getEditorType(Page $page): PageEditorType { - $editorType = $page->editor ?: self::getSystemDefaultEditor(); + $editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault(); // Use requested editor if valid and if we have permission - $requestedType = explode('-', $this->requestedEditor)[0]; - if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) { + $requestedType = PageEditorType::fromRequestValue($this->requestedEditor); + if ($requestedType && userCan('editor-change')) { $editorType = $requestedType; } return $editorType; } - - /** - * Get the configured system default editor. - */ - public static function getSystemDefaultEditor(): string - { - return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg'; - } } diff --git a/app/Entities/Tools/PageEditorType.php b/app/Entities/Tools/PageEditorType.php new file mode 100644 index 000000000..1c1d430e4 --- /dev/null +++ b/app/Entities/Tools/PageEditorType.php @@ -0,0 +1,37 @@ + true, + self::Markdown => false, + }; + } + + public static function fromRequestValue(string $value): static|null + { + $editor = explode('-', $value)[0]; + return static::tryFrom($editor); + } + + public static function forPage(Page $page): static|null + { + return static::tryFrom($page->editor); + } + + public static function getSystemDefault(): static + { + $setting = setting('app-editor'); + return static::tryFrom($setting) ?? static::WysiwygTinymce; + } +} diff --git a/lang/en/entities.php b/lang/en/entities.php index 9e620b24e..35e6f050b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -224,6 +224,8 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', + 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', 'pages_edit_set_changelog' => 'Set Changelog', 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', 'pages_edit_enter_changelog' => 'Enter Changelog', diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 91e1a9678..31e3533b1 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -19,5 +19,6 @@ ## Bugs +- Editor theme classes remain on items after export - List selection can get lost on nesting/unnesting - Content not properly saving on new pages \ No newline at end of file diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php index d25f6a0a4..341fbf67d 100644 --- a/resources/views/pages/parts/editor-toolbar.blade.php +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -55,7 +55,7 @@
                                                            • - @if($editor === 'wysiwyg') + @if($editor !== \BookStack\Entities\Tools\PageEditorType::Markdown) @icon('swap-horizontal')
                                                              @@ -72,12 +72,23 @@ {{ trans('entities.pages_edit_switch_to_markdown_stable') }}
                                                              - @else + @endif + @if($editor !== \BookStack\Entities\Tools\PageEditorType::WysiwygTinymce) @icon('swap-horizontal')
                                                              {{ trans('entities.pages_edit_switch_to_wysiwyg') }}
                                                              @endif + @if($editor !== \BookStack\Entities\Tools\PageEditorType::WysiwygLexical) + + @icon('swap-horizontal') +
                                                              + {{ trans('entities.pages_edit_switch_to_new_wysiwyg') }} +
                                                              + {{ trans('entities.pages_edit_switch_to_new_wysiwyg_desc') }} +
                                                              +
                                                              + @endif
                                                            • @endif
                                                            diff --git a/resources/views/pages/parts/form.blade.php b/resources/views/pages/parts/form.blade.php index 490374e40..e1104b406 100644 --- a/resources/views/pages/parts/form.blade.php +++ b/resources/views/pages/parts/form.blade.php @@ -32,18 +32,19 @@
                                                            {{--Editors--}}
                                                            + - @if($editor === 'wysiwyg') + @if($editor === \BookStack\Entities\Tools\PageEditorType::WysiwygLexical) @include('pages.parts.wysiwyg-editor', ['model' => $model]) @endif {{--WYSIWYG Editor (TinyMCE - Deprecated)--}} - @if($editor === 'wysiwyg-tinymce') + @if($editor === \BookStack\Entities\Tools\PageEditorType::WysiwygTinymce) @include('pages.parts.wysiwyg-editor-tinymce', ['model' => $model]) @endif {{--Markdown Editor--}} - @if($editor === 'markdown') + @if($editor === \BookStack\Entities\Tools\PageEditorType::Markdown) @include('pages.parts.markdown-editor', ['model' => $model]) @endif diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 4845e2055..70a490298 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -32,6 +32,7 @@
                                                            diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index b2fb85955..934024956 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -4,6 +4,7 @@ namespace Tests\Entity; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; +use BookStack\Entities\Tools\PageEditorType; use Tests\TestCase; class PageEditorTest extends TestCase @@ -25,7 +26,7 @@ class PageEditorTest extends TestCase public function test_markdown_setting_shows_markdown_editor_for_new_pages() { - $this->setSettings(['app-editor' => 'markdown']); + $this->setSettings(['app-editor' => PageEditorType::Markdown->value]); $resp = $this->asAdmin()->get($this->page->book->getUrl('/create-page')); $this->withHtml($this->followRedirects($resp)) @@ -37,7 +38,7 @@ class PageEditorTest extends TestCase { $mdContent = '# hello. This is a test'; $this->page->markdown = $mdContent; - $this->page->editor = 'markdown'; + $this->page->editor = PageEditorType::Markdown; $this->page->save(); $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); @@ -135,6 +136,19 @@ class PageEditorTest extends TestCase $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=wysiwyg')); $resp->assertStatus(200); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor-tinymce"]'); + $resp->assertSee("

                                                            A Header

                                                            \n

                                                            Some content with bold text!

                                                            ", true); + } + + public function test_switching_from_markdown_to_wysiwyg2024_works() + { + $page = $this->entities->page(); + $page->html = ''; + $page->markdown = "## A Header\n\nSome content with **bold** text!"; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=wysiwyg2024')); + $resp->assertStatus(200); $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); $resp->assertSee("

                                                            A Header

                                                            \n

                                                            Some content with bold text!

                                                            ", true); } @@ -142,7 +156,7 @@ class PageEditorTest extends TestCase public function test_page_editor_changes_with_editor_property() { $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); - $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor-tinymce"]'); $this->page->markdown = "## A Header\n\nSome content with **bold** text!"; $this->page->editor = 'markdown'; @@ -150,6 +164,12 @@ class PageEditorTest extends TestCase $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); $this->withHtml($resp)->assertElementExists('[component="markdown-editor"]'); + + $this->page->editor = 'wysiwyg2024'; + $this->page->save(); + + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); } public function test_editor_type_switch_options_show() @@ -158,6 +178,7 @@ class PageEditorTest extends TestCase $editLink = $this->page->getUrl('/edit') . '?editor='; $this->withHtml($resp)->assertElementContains("a[href=\"${editLink}markdown-clean\"]", '(Clean Content)'); $this->withHtml($resp)->assertElementContains("a[href=\"${editLink}markdown-stable\"]", '(Stable Content)'); + $this->withHtml($resp)->assertElementContains("a[href=\"${editLink}wysiwyg2024\"]", '(In Alpha Testing)'); $resp = $this->asAdmin()->get($this->page->getUrl('/edit?editor=markdown-stable')); $editLink = $this->page->getUrl('/edit') . '?editor='; @@ -179,7 +200,7 @@ class PageEditorTest extends TestCase $resp = $this->asEditor()->get($page->getUrl('/edit?editor=markdown-stable')); $resp->assertStatus(200); - $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor"]'); + $this->withHtml($resp)->assertElementExists('[component="wysiwyg-editor-tinymce"]'); $this->withHtml($resp)->assertElementNotExists('[component="markdown-editor"]'); } @@ -193,4 +214,40 @@ class PageEditorTest extends TestCase $this->asEditor()->put($page->getUrl(), ['name' => $page->name, 'markdown' => '## Updated content abc']); $this->assertEquals('wysiwyg', $page->refresh()->editor); } + + public function test_editor_type_change_to_wysiwyg_infers_type_from_request_or_uses_system_default() + { + $tests = [ + [ + 'setting' => 'wysiwyg', + 'request' => 'wysiwyg2024', + 'expected' => 'wysiwyg2024', + ], + [ + 'setting' => 'wysiwyg2024', + 'request' => 'wysiwyg', + 'expected' => 'wysiwyg', + ], + [ + 'setting' => 'wysiwyg', + 'request' => null, + 'expected' => 'wysiwyg', + ], + [ + 'setting' => 'wysiwyg2024', + 'request' => null, + 'expected' => 'wysiwyg2024', + ] + ]; + + $page = $this->entities->page(); + foreach ($tests as $test) { + $page->editor = 'markdown'; + $page->save(); + + $this->setSettings(['app-editor' => $test['setting']]); + $this->asAdmin()->put($page->getUrl(), ['name' => $page->name, 'html' => '

                                                            Hello

                                                            ', 'editor' => $test['request']]); + $this->assertEquals($test['expected'], $page->refresh()->editor, "Failed asserting global editor {$test['setting']} with request editor {$test['request']} results in {$test['expected']} set for the page"); + } + } } From a62d8381be1b5736b625f516c303b84c8a64c47a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Sep 2024 17:36:16 +0100 Subject: [PATCH 106/107] Lexical: Updated toolbar & text node exporting - Updated toolbar to match existing editor, including dynamic RTL/LTR controls. - Updated text node handling to not include spans and extra classes when not needed. Added & update tests to cover. --- .../__tests__/unit/HTMLCopyAndPaste.test.ts | 2 +- .../lexical/core/nodes/LexicalTextNode.ts | 41 ++++++++++++- .../__tests__/unit/LexicalTabNode.test.ts | 2 +- .../__tests__/unit/LexicalTextNode.test.ts | 57 ++++++++++++++++++- .../unit/LexicalHeadlessEditor.test.ts | 2 +- .../html/__tests__/unit/LexicalHtml.test.ts | 8 +-- .../__tests__/unit/LexicalTableNode.test.ts | 2 +- .../unit/LexicalEventHelpers.test.ts | 8 +-- .../unit/LexicalUtilsSplitNode.test.ts | 20 +++---- ...exlcaiUtilsInsertNodeToNearestRoot.test.ts | 24 ++++---- resources/js/wysiwyg/todo.md | 7 +-- resources/js/wysiwyg/ui/framework/manager.ts | 4 ++ resources/js/wysiwyg/ui/index.ts | 2 +- resources/js/wysiwyg/ui/toolbars.ts | 17 +++--- resources/sass/_editor.scss | 2 +- resources/sass/_pages.scss | 5 +- 16 files changed, 152 insertions(+), 51 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts index 9f832b69e..534663a54 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -82,7 +82,7 @@ describe('HTMLCopyAndPaste tests', () => { pastedHTML: ` 123
                                                            456
                                                            `, }, { - expectedHTML: `
                                                            • done
                                                            • todo
                                                              • done
                                                              • todo
                                                            • todo
                                                            `, + expectedHTML: `
                                                            • done
                                                            • todo
                                                              • done
                                                              • todo
                                                            • todo
                                                            `, name: 'google doc checklist', pastedHTML: `
                                                            • checked

                                                              done

                                                            • unchecked

                                                              todo

                                                              • checked

                                                                done

                                                              • unchecked

                                                                todo

                                                            • unchecked

                                                              todo

                                                            `, }, diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts index 43bef7e83..4a3a48950 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -624,7 +624,28 @@ export class TextNode extends LexicalNode { element !== null && isHTMLElement(element), 'Expected TextNode createDOM to always return a HTMLElement', ); - element.style.whiteSpace = 'pre-wrap'; + + // Wrap up to retain space if head/tail whitespace exists + const text = this.getTextContent(); + if (/^\s|\s$/.test(text)) { + element.style.whiteSpace = 'pre-wrap'; + } + + // Strip editor theme classes + for (const className of Array.from(element.classList.values())) { + if (className.startsWith('editor-theme-')) { + element.classList.remove(className); + } + } + if (element.classList.length === 0) { + element.removeAttribute('class'); + } + + // Remove placeholder tag if redundant + if (element.nodeName === 'SPAN' && !element.getAttribute('style')) { + element = document.createTextNode(text); + } + // This is the only way to properly add support for most clients, // even if it's semantically incorrect to have to resort to using // , , , elements. @@ -632,7 +653,7 @@ export class TextNode extends LexicalNode { element = wrapElementWith(element, 'b'); } if (this.hasFormat('italic')) { - element = wrapElementWith(element, 'i'); + element = wrapElementWith(element, 'em'); } if (this.hasFormat('strikethrough')) { element = wrapElementWith(element, 's'); @@ -1329,6 +1350,10 @@ function applyTextFormatFromStyle( // Google Docs uses span tags + vertical-align to specify subscript and superscript const verticalAlign = style.verticalAlign; + // Styles to copy to node + const color = style.color; + const backgroundColor = style.backgroundColor; + return (lexicalNode: LexicalNode) => { if (!$isTextNode(lexicalNode)) { return lexicalNode; @@ -1355,6 +1380,18 @@ function applyTextFormatFromStyle( lexicalNode.toggleFormat('superscript'); } + // Apply styles + let style = lexicalNode.getStyle(); + if (color) { + style += `color: ${color};`; + } + if (backgroundColor && backgroundColor !== 'transparent') { + style += `background-color: ${backgroundColor};`; + } + if (style) { + lexicalNode.setStyle(style); + } + if (shouldApply && !lexicalNode.hasFormat(shouldApply)) { lexicalNode.toggleFormat(shouldApply); } diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts index a57ff3f42..d8525fb36 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts @@ -107,7 +107,7 @@ describe('LexicalTabNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); expect(testEnv.innerHTML).toBe( - '

                                                            Hello\tworld

                                                            Hello\tworld

                                                            ', + '

                                                            Hello\tworld

                                                            Hello\tworld

                                                            ', ); }); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts index 57e1dcb3b..b1ea099ac 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts @@ -8,7 +8,7 @@ import { $createParagraphNode, - $createTextNode, + $createTextNode, $getEditor, $getNodeByKey, $getRoot, $getSelection, @@ -41,6 +41,9 @@ import { $setCompositionKey, getEditorStateTextContent, } from '../../../LexicalUtils'; +import {Text} from "@codemirror/state"; +import {$generateHtmlFromNodes} from "@lexical/html"; +import {formatBold} from "@lexical/selection/__tests__/utils"; const editorConfig = Object.freeze({ namespace: '', @@ -792,6 +795,58 @@ describe('LexicalTextNode tests', () => { ); }); + describe('exportDOM()', () => { + + test('simple text exports as a text node', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + const textNode = $createTextNode('hello'); + paragraph.append(textNode); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe('

                                                            hello

                                                            '); + }); + }); + + test('simple text wrapped in span if leading or ending spacing', async () => { + + const textByExpectedHtml = { + 'hello ': '

                                                            hello

                                                            ', + ' hello': '

                                                            hello

                                                            ', + ' hello ': '

                                                            hello

                                                            ', + } + + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + for (const [text, expectedHtml] of Object.entries(textByExpectedHtml)) { + paragraph.getChildren().forEach(c => c.remove(true)); + const textNode = $createTextNode(text); + paragraph.append(textNode); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe(expectedHtml); + } + }); + }); + + test('text with formats exports using format elements instead of classes', async () => { + await update(() => { + const paragraph = $getRoot().getFirstChild()!; + const textNode = $createTextNode('hello'); + textNode.toggleFormat('bold'); + textNode.toggleFormat('subscript'); + textNode.toggleFormat('italic'); + textNode.toggleFormat('underline'); + textNode.toggleFormat('code'); + paragraph.append(textNode); + + const html = $generateHtmlFromNodes($getEditor(), null); + expect(html).toBe('

                                                            hello

                                                            '); + }); + }); + + }); + test('mergeWithSibling', async () => { await update(() => { const paragraph = $getRoot().getFirstChild()!; diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts index afa65708d..c4dedd47d 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -206,7 +206,7 @@ describe('LexicalHeadlessEditor', () => { cleanup(); expect(html).toBe( - '

                                                            hello world

                                                            ', + '

                                                            hello world

                                                            ', ); }); }); diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts index 3dbe5da8b..947e591b4 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -102,7 +102,7 @@ describe('HTML', () => { html = $generateHtmlFromNodes(editor, selection); }); - expect(html).toBe('World'); + expect(html).toBe('World'); }); test(`[Lexical -> HTML]: Default selection (undefined) should serialize entire editor state`, () => { @@ -145,7 +145,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

                                                            Hello

                                                            World

                                                            ', + '

                                                            Hello

                                                            World

                                                            ', ); }); @@ -175,7 +175,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

                                                            Hello world!

                                                            ', + '

                                                            Hello world!

                                                            ', ); }); @@ -205,7 +205,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

                                                            Hello world!

                                                            ', + '

                                                            Hello world!

                                                            ', ); }); }); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts index abc509629..6848e5532 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -115,7 +115,7 @@ describe('LexicalTableNode tests', () => { // Make sure paragraph is inserted inside empty cells const emptyCell = '


                                                            '; expect(testEnv.innerHTML).toBe( - `${emptyCell}

                                                            Hello there

                                                            General Kenobi!

                                                            Lexical is nice

                                                            `, + `${emptyCell}

                                                            Hello there

                                                            General Kenobi!

                                                            Lexical is nice

                                                            `, ); }); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts index 7655b4540..fd7731f90 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -330,7 +330,7 @@ describe('LexicalEventHelpers', () => { const suite = [ { expectedHTML: - '

                                                            Get schwifty!

                                                            ', + '

                                                            Get schwifty!

                                                            ', inputs: [ pasteHTML( `Get schwifty!`, @@ -340,7 +340,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                            Get schwifty!

                                                            ', + '

                                                            Get schwifty!

                                                            ', inputs: [ pasteHTML( `Get schwifty!`, @@ -350,7 +350,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                            Get schwifty!

                                                            ', + '

                                                            Get schwifty!

                                                            ', inputs: [ pasteHTML( `Get schwifty!`, @@ -360,7 +360,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '

                                                            Get schwifty!

                                                            ', + '

                                                            Get schwifty!

                                                            ', inputs: [ pasteHTML( `Get schwifty!`, diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts index f04bb5d2e..a70200d63 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts @@ -38,7 +38,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph in between two text nodes', expectedHtml: - '

                                                            Hello

                                                            world

                                                            ', + '

                                                            Hello

                                                            world

                                                            ', initialHtml: '

                                                            Helloworld

                                                            ', splitOffset: 1, splitPath: [0], @@ -46,7 +46,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph before the first text node', expectedHtml: - '


                                                            Helloworld

                                                            ', + '


                                                            Helloworld

                                                            ', initialHtml: '

                                                            Helloworld

                                                            ', splitOffset: 0, splitPath: [0], @@ -54,7 +54,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph after the last text node', expectedHtml: - '

                                                            Helloworld


                                                            ', + '

                                                            Helloworld


                                                            ', initialHtml: '

                                                            Helloworld

                                                            ', splitOffset: 2, // Any offset that is higher than children size splitPath: [0], @@ -62,8 +62,8 @@ describe('LexicalUtils#splitNode', () => { { _: 'split list items between two text nodes', expectedHtml: - '
                                                            • Hello
                                                            ' + - '
                                                            • world
                                                            ', + '
                                                            • Hello
                                                            ' + + '
                                                            • world
                                                            ', initialHtml: '
                                                            • Helloworld
                                                            ', splitOffset: 1, // Any offset that is higher than children size splitPath: [0, 0], @@ -72,7 +72,7 @@ describe('LexicalUtils#splitNode', () => { _: 'split list items before the first text node', expectedHtml: '
                                                            ' + - '
                                                            • Helloworld
                                                            ', + '
                                                            • Helloworld
                                                            ', initialHtml: '
                                                            • Helloworld
                                                            ', splitOffset: 0, // Any offset that is higher than children size splitPath: [0, 0], @@ -81,12 +81,12 @@ describe('LexicalUtils#splitNode', () => { _: 'split nested list items', expectedHtml: '
                                                              ' + - '
                                                            • Before
                                                            • ' + - '
                                                              • Hello
                                                            • ' + + '
                                                            • Before
                                                            • ' + + '
                                                              • Hello
                                                            • ' + '
                                                            ' + '
                                                              ' + - '
                                                              • world
                                                            • ' + - '
                                                            • After
                                                            • ' + + '
                                                              • world
                                                            • ' + + '
                                                            • After
                                                            • ' + '
                                                            ', initialHtml: '
                                                              ' + diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts index 9664b2d80..fb04e6284 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts @@ -46,7 +46,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert into paragraph in between two text nodes', expectedHtml: - '

                                                              Hello

                                                              world

                                                              ', + '

                                                              Hello

                                                              world

                                                              ', initialHtml: '

                                                              Helloworld

                                                              ', selectionOffset: 5, // Selection on text node after "Hello" world selectionPath: [0, 0], @@ -55,13 +55,13 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { _: 'insert into nested list items', expectedHtml: '
                                                                ' + - '
                                                              • Before
                                                              • ' + - '
                                                                • Hello
                                                              • ' + + '
                                                              • Before
                                                              • ' + + '
                                                                • Hello
                                                              • ' + '
                                                              ' + '' + '
                                                                ' + - '
                                                                • world
                                                              • ' + - '
                                                              • After
                                                              • ' + + '
                                                                • world
                                                              • ' + + '
                                                              • After
                                                              • ' + '
                                                              ', initialHtml: '
                                                                ' + @@ -82,7 +82,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert in the end of paragraph', expectedHtml: - '

                                                                Hello world

                                                                ' + + '

                                                                Hello world

                                                                ' + '' + '


                                                                ', initialHtml: '

                                                                Hello world

                                                                ', @@ -94,7 +94,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { expectedHtml: '


                                                                ' + '' + - '

                                                                Hello world

                                                                ', + '

                                                                Hello world

                                                                ', initialHtml: '

                                                                Hello world

                                                                ', selectionOffset: 0, // Selection on text node after "Hello" world selectionPath: [0, 0], @@ -104,8 +104,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { expectedHtml: '' + '' + - '

                                                                Before

                                                                ' + - '

                                                                After

                                                                ', + '

                                                                Before

                                                                ' + + '

                                                                After

                                                                ', initialHtml: '' + '

                                                                Before

                                                                ' + @@ -116,9 +116,9 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root child', expectedHtml: - '

                                                                Before

                                                                ' + + '

                                                                Before

                                                                ' + '' + - '

                                                                After

                                                                ', + '

                                                                After

                                                                ', initialHtml: '

                                                                Before

                                                                After

                                                                ', selectionOffset: 1, selectionPath: [], @@ -126,7 +126,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root end', expectedHtml: - '

                                                                Before

                                                                ' + + '

                                                                Before

                                                                ' + '', initialHtml: '

                                                                Before

                                                                ', selectionOffset: 1, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 31e3533b1..bcd4851e8 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -7,7 +7,6 @@ ## Main Todo - Mac: Shortcut support via command. -- Update toolbar overflows to match existing editor, incl. direction dynamic controls ## Secondary Todo @@ -16,9 +15,9 @@ - Table caption text support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Deep check of translation coverage +- About button & view +- Mobile display and handling ## Bugs -- Editor theme classes remain on items after export -- List selection can get lost on nesting/unnesting -- Content not properly saving on new pages \ No newline at end of file +- List selection can get lost on nesting/unnesting \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 732530375..7c0975da7 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -163,6 +163,10 @@ export class EditorUIManager { }); } + getDefaultDirection(): 'rtl' | 'ltr' { + return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr'; + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 8bfdb8965..3811f44b9 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -32,7 +32,7 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro manager.setContext(context); // Create primary toolbar - manager.setToolbar(getMainEditorFullToolbar()); + manager.setToolbar(getMainEditorFullToolbar(context)); // Register modals for (const key of Object.keys(modals)) { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index b064a2a9f..35146e5a4 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,5 +1,5 @@ import {EditorButton} from "./framework/buttons"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; @@ -80,7 +80,10 @@ import {el} from "../utils/dom"; import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; import {EditorSeparator} from "./framework/blocks/separator"; -export function getMainEditorFullToolbar(): EditorContainerUiElement { +export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { + + const inRtlMode = context.manager.getDefaultDirection() === 'rtl'; + return new EditorSimpleClassContainer('editor-toolbar-main', [ // History state @@ -124,17 +127,17 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Alignment - new EditorOverflowContainer(6, [ // TODO - Dynamic + new EditorOverflowContainer(6, [ new EditorButton(alignLeft), new EditorButton(alignCenter), new EditorButton(alignRight), new EditorButton(alignJustify), - new EditorButton(directionLTR), // TODO - Dynamic - new EditorButton(directionRTL), // TODO - Dynamic - ]), + inRtlMode ? new EditorButton(directionLTR) : null, + inRtlMode ? new EditorButton(directionRTL) : null, + ].filter(x => x !== null)), // Lists - new EditorOverflowContainer(5, [ + new EditorOverflowContainer(3, [ new EditorButton(bulletList), new EditorButton(numberList), new EditorButton(taskList), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 91aef9920..b33cb4d05 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -27,6 +27,7 @@ body.editor-is-fullscreen { } .editor-content-area { min-height: 100%; + padding-block: 1rem; &:focus { outline: 0; } @@ -136,7 +137,6 @@ body.editor-is-fullscreen { background-color: #FFF; box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); z-index: 99; - min-width: 120px; display: flex; flex-direction: row; } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 6e6f7bb7e..426f7961c 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -35,12 +35,15 @@ } @include larger-than($xxl) { + .page-editor-wysiwyg2024 .page-edit-toolbar, + .page-editor-wysiwyg2024 .page-editor-page-area, .page-editor-wysiwyg .page-edit-toolbar, .page-editor-wysiwyg .page-editor-page-area { max-width: 1140px; } - .page-editor-wysiwyg .floating-toolbox { + .page-editor-wysiwyg .floating-toolbox, + .page-editor-wysiwyg2024 .floating-toolbox { position: absolute; } } From 1b9310e7662756215928ce8f4470706cac537a93 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Sep 2024 10:40:53 +0100 Subject: [PATCH 107/107] Meta: Added lexical licensing info and added TS/JS CI testing --- .github/workflows/lint-js.yml | 4 ++-- .github/workflows/test-js.yml | 29 +++++++++++++++++++++++++ readme.md | 1 + resources/views/help/licenses.blade.php | 6 +++++ 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-js.yml diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 0391ce5b5..9aceea2a2 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -13,9 +13,9 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install NPM deps run: npm ci diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml new file mode 100644 index 000000000..13f9a8a98 --- /dev/null +++ b/.github/workflows/test-js.yml @@ -0,0 +1,29 @@ +name: test-js + +on: + push: + paths: + - '**.js' + - '**.ts' + - '**.json' + pull_request: + paths: + - '**.js' + - '**.ts' + - '**.json' + +jobs: + build: + if: ${{ github.ref != 'refs/heads/l10n_development' }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install NPM deps + run: npm ci + + - name: Run TypeScript type checking + run: npm run ts:lint + + - name: Run JavaScript tests + run: npm run test \ No newline at end of file diff --git a/readme.md b/readme.md index 11bf2c888..29983eaff 100644 --- a/readme.md +++ b/readme.md @@ -145,6 +145,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be * [Laravel](http://laravel.com/) - _[MIT](https://github.com/laravel/framework/blob/v8.82.0/LICENSE.md)_ * [TinyMCE](https://www.tinymce.com/) - _[MIT](https://github.com/tinymce/tinymce/blob/develop/LICENSE.TXT)_ +* [Lexical](https://lexical.dev/) - _[MIT](https://github.com/facebook/lexical/blob/main/LICENSE)_ * [CodeMirror](https://codemirror.net) - _[MIT](https://github.com/codemirror/CodeMirror/blob/master/LICENSE)_ * [Sortable](https://github.com/SortableJS/Sortable) - _[MIT](https://github.com/SortableJS/Sortable/blob/master/LICENSE)_ * [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_ diff --git a/resources/views/help/licenses.blade.php b/resources/views/help/licenses.blade.php index 1eb293523..09126ddad 100644 --- a/resources/views/help/licenses.blade.php +++ b/resources/views/help/licenses.blade.php @@ -54,6 +54,12 @@ License File: https://github.com/tinymce/tinymce/blob/release/6.7/LICENSE.TXT Copyright: Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. Link: https://github.com/tinymce/tinymce + ----------- + BookStack's newer WYSIWYG editor is based upon lexical code: + License: MIT + License File: https://github.com/facebook/lexical/blob/v0.17.1/LICENSE + Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. + Link: https://github.com/facebook/lexical