From 5a4f59534124a83b9d7b54abe1d5c8984b2fa04f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 May 2024 15:39:41 +0100 Subject: [PATCH 001/800] 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 c5b3c9ef3a3..20193db7fa9 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 63b0d2478e6..6a992f4d0e0 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 ba2de38ba3c..706c187382e 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 3a66079d7f8..8ad5e14cb2e 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 79c9d3c2c67..cac20c9fb2c 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 cfb0634a904..bd6dd3c82ff 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 00000000000..093442ea2bc --- /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 82f60827d7a..bcd480ce6ab 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 ad964aed2c4..aa21a63717f 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 00000000000..4c4f16ce304 --- /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 e2c839cd220..490374e40c4 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 00000000000..33c526a9910 --- /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 84a267b681b..7528b1e02a7 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/800] 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 6a992f4d0e0..6cd32760d96 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 706c187382e..42b86fdc7b5 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 bcd480ce6ab..98732dab73e 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 4c4f16ce304..decfa4f22b1 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 00000000000..db90f22a945 --- /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 00000000000..ada229d9ea6 --- /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 7528b1e02a7..30be1a21422 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/800] 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 20193db7fa9..7f180fc0741 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 6cd32760d96..2b6b677c2e3 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 42b86fdc7b5..97a79612623 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 decfa4f22b1..266866c6251 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 db90f22a945..4fba5ee5b63 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 ada229d9ea6..77f582877f4 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/800] 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 97a79612623..ca0f01f17a7 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 266866c6251..9553fd4dd3d 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 4fba5ee5b63..89b9b162ef0 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 00000000000..f13cef56f31 --- /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 77f582877f4..7dda306479b 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 30be1a21422..bbe76090c40 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -11,7 +11,7 @@ class="">
-

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/800] 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 00000000000..720f3c6d5da --- /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 9553fd4dd3d..0dcbf27f550 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); + } }); - // Example of creating, registering and using a custom command + buildEditorUI(editArea, editor); - 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); + // Example of creating, registering and using a custom command - 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 89b9b162ef0..e39dcc3ee94 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 7dda306479b..ffe1b027fe5 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 00000000000..2ce272fce7c --- /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 00000000000..10eaa558f3f --- /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 bbe76090c40..90e42e576c2 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 00000000000..e075f973c4d --- /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/800] 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 720f3c6d5da..737666ffa91 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 00000000000..cf5660ef040 --- /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 10eaa558f3f..d04808faec5 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/800] 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 2b6b677c2e3..2cddccb5900 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 ca0f01f17a7..d9fa89c186a 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 737666ffa91..7218f1ae643 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 ffe1b027fe5..9f772df1e5c 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 cf5660ef040..874f632fe14 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 2ce272fce7c..00000000000 --- 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 00000000000..66501178238 --- /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 00000000000..51c7d294d90 --- /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 00000000000..9ef59c72f19 --- /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 d04808faec5..56ae9354ae2 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 00000000000..0f46f5b2a55 --- /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 90e42e576c2..b48e10570b2 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -14,6 +14,7 @@ class="">

    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/800] Lexical: Added format previews to format buttons --- resources/js/wysiwyg/ui/framework/buttons.ts | 58 +++++++++++++++++-- .../js/wysiwyg/ui/framework/containers.ts | 57 +++++++++++++++++- 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, 183 insertions(+), 37 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 51c7d294d90..2a6f5a976fd 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 9ef59c72f19..e58988e7b84 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 { +export class EditorSimpleClassContainer extends EditorContainerUiElement { + protected className; + + constructor(className: string, children: EditorUiElement[]) { + super(children); + this.className = className; + } + + protected buildDOM(): HTMLElement { return el('div', { - class: 'editor-format-menu' + 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 0f46f5b2a55..2d5063cf444 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 00000000000..48912be8b93 --- /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 f52b6199262..636367e3aeb 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 b48e10570b2..940c005b5b7 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/800] 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 874f632fe14..da0a1e2c53a 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 00000000000..c8477d9f2a4 --- /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 2a6f5a976fd..48046e9de9a 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 e58988e7b84..ed191a88205 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 66501178238..68d845b4249 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 00000000000..0fce73c125a --- /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 00000000000..f1a34c92a94 --- /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 56ae9354ae2..5a0d7fd2d5e 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/800] 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 | 33 +++++++++- resources/js/wysiwyg/ui/framework/modals.ts | 63 +++++++++++++++++++ resources/js/wysiwyg/ui/index.ts | 13 ++-- resources/sass/_editor.scss | 26 ++++++++ 8 files changed, 220 insertions(+), 33 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 7218f1ae643..40379cc27de 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 da0a1e2c53a..f5be8251930 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 c8477d9f2a4..457efa421e7 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 0fce73c125a..c6338f79849 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 f1a34c92a94..c3fe9ecd877 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 { + protected modalDefinitionsByKey: Record = {}; + protected context: EditorUiContext|null = null; + setContext(context: EditorUiContext) { + this.context = context; + } -export class EditorUIManager { + 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()); - // Todo - Register and show modal via this - // (Part of UI context) + 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 00000000000..e2a6b3f33eb --- /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 5a0d7fd2d5e..7e1f8d98173 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 48912be8b93..2633e853965 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/800] 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 00000000000..1e2cbd83c11 --- /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 9f772df1e5c..1d492a87a25 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 48046e9de9a..367a3933063 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 7e1f8d98173..9206f8b4056 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 940c005b5b7..c0ceddc45d0 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -9,13 +9,14 @@ 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
      -

      +

      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/800] 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 1e2cbd83c11..289e36c4b32 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 00000000000..fd333fa5470 --- /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 00000000000..89077412691 --- /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 c3fe9ecd877..1684b662813 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 9206f8b4056..19320b2629f 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 2633e853965..94fe2c75607 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/800] 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 289e36c4b32..9f017b5fed2 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 fd333fa5470..b3ceee65fa4 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 94fe2c75607..87cc70c9bf3 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/800] 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 9f017b5fed2..92d5518dbc7 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 b3ceee65fa4..1692d078d0c 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 f5be8251930..92f0cfc8120 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 457efa421e7..13e7a9c9fc6 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 68d845b4249..2fdadcb40c9 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 19320b2629f..8227dec6838 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 2d5063cf444..2802b1ca7ba 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/800] 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 00000000000..a18c4d858d5 --- /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 1d492a87a25..f47575bc5f6 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 92f0cfc8120..e549e69a2d7 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 2802b1ca7ba..b5d151fc116 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 c0ceddc45d0..6414027697c 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -16,6 +16,13 @@ class="">
    • 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/800] 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 00000000000..bca31e51b7a --- /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 0dcbf27f550..41207b706f4 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 e549e69a2d7..077bcae21f6 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 13e7a9c9fc6..04147a4f057 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 c6338f79849..a7fcb45ba6c 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 8227dec6838..b2fd7e05a13 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 b5d151fc116..63ff8a05397 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/800] 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 077bcae21f6..2e7cc6821e5 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 63ff8a05397..33726661739 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/800] 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 2e7cc6821e5..d8c7f515cca 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 00000000000..6972d7a8e44 --- /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 00000000000..199c7728d8a --- /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 00000000000..bcd61e45cd3 --- /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 00000000000..f83035aa6bd --- /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 367a3933063..c3ba533b325 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 ed191a88205..00000000000 --- 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 2fdadcb40c9..d437b36bd09 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 a7fcb45ba6c..4fee787d354 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 00000000000..35886d2f922 --- /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 1684b662813..78ddc8ce3d6 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 e2a6b3f33eb..bfc5fc619bf 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 33726661739..de90a1d70b3 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 87cc70c9bf3..b98e624bdf3 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 b81f2b52d0956138c8862dc6a01f54ad3de1a336 Mon Sep 17 00:00:00 2001 From: Stefan Mueller Date: Thu, 13 Jun 2024 12:41:05 +0200 Subject: [PATCH 019/800] Add ALLOWED_IFRAME_SOURCES to phpunit.xml Fix for bug #5068 test_frame_src_csp_header_set fails, when .env-file has customized ALLOWED_IFRAME_SOURCES --- phpunit.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/phpunit.xml b/phpunit.xml index 21f17685b5b..b72c6e5f13e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,6 +13,7 @@ + From e2409a5fab3e38e1753adb51ff432b6104c7572b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 19 Jun 2024 16:14:20 +0100 Subject: [PATCH 020/800] 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 2cddccb5900..0757e786873 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 d9fa89c186a..732bb175980 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 f47575bc5f6..03fcd33a5b3 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 d8c7f515cca..57460ef60ed 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 de90a1d70b3..fe19b94ed7a 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 b98e624bdf3..13d8e96f98b 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 021/800] 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 7f180fc0741..0680f4ac3da 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 00000000000..495ae000cc3 --- /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 00000000000..bf8f61abb7b --- /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 00000000000..811212755c2 --- /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 00000000000..839110c420f --- /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 00000000000..93cc44a3f00 --- /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 00000000000..308db53b466 --- /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 00000000000..d8434b76138 --- /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 00000000000..d86e8c42352 --- /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 00000000000..b6483fb5677 --- /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 00000000000..8c3410b8432 --- /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 00000000000..c70df0d6e07 --- /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 00000000000..81d04cea702 --- /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 00000000000..af0caa8627d --- /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 00000000000..aa6b4cb3621 --- /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 00000000000..a9881942757 --- /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 00000000000..b29800dc310 --- /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 00000000000..c073c6ff022 --- /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 00000000000..f30266b27be --- /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 00000000000..92cdbf0aed8 --- /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 00000000000..d542296c5e4 --- /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 00000000000..5314c39dad7 --- /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 00000000000..92d14aa76fc --- /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 00000000000..e877b335912 --- /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 00000000000..897ceddc2b4 --- /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 00000000000..5d17ef6ef0f --- /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 00000000000..4b9f22675d0 --- /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 00000000000..c5aba8ee287 --- /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 40379cc27de..d7cd23a359c 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 57460ef60ed..7fa1fb5f85b 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 c3ba533b325..332b3509973 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 fe19b94ed7a..559e9a87cfb 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 13d8e96f98b..f8c895afd35 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 e075f973c4d..40d930149a9 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 022/800] 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 0757e786873..646750df49a 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 732bb175980..d649b54e2d2 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 41207b706f4..b910b2eb619 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 03fcd33a5b3..ea6206ac2ba 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 00000000000..53017e93b93 --- /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 f8c895afd35..ad1f5a339ea 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 6414027697c..5cd60bbc69b 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -26,6 +26,25 @@ class="">

    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 023/800] 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 d7cd23a359c..62e9457213e 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 7fa1fb5f85b..bf1846b8f13 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 00000000000..c54645856ab --- /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 559e9a87cfb..4dbf9bb7e3b 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 ad1f5a339ea..69027ea69f5 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 024/800] 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 646750df49a..3867a1d1fb0 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 d649b54e2d2..439eaa5a13d 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 b910b2eb619..9f2f1645a4f 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 bf1846b8f13..589567c031a 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 332b3509973..02f88dac8d4 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 69027ea69f5..b5ee69d9820 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 025/800] 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 00000000000..b2eaacdb2bf --- /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 f30266b27be..4517d0d5311 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 92cdbf0aed8..4bc0fc1babf 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 00000000000..15425063c40 --- /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 00000000000..a862e19629f --- /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 589567c031a..5e5f0d40904 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 00000000000..e81521a2677 --- /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 199c7728d8a..a419b92b28e 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 02f88dac8d4..7e8df076a60 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 4dbf9bb7e3b..821c9f9cfea 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 026/800] 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 00000000000..c070e06b51d --- /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 ea6206ac2ba..6b1b66e6678 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 c54645856ab..8c28953d566 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 821c9f9cfea..7f7e99a785a 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 027/800] 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 1692d078d0c..1e8bfd165a9 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 00000000000..141f9b20f6a --- /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 53017e93b93..ccf269daa0f 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 028/800] 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 c070e06b51d..1107f0a906b 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 ccf269daa0f..869de8460d2 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 b5ee69d9820..a4b0e632f28 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 029/800] 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 869de8460d2..8f1e978e9dc 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 030/800] 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 62e9457213e..64bcb64902e 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 00000000000..fbd019e7299 --- /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 6b1b66e6678..befc2ab2e05 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 5e5f0d40904..aa8b27ec56c 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 7f7e99a785a..a8ba52c5f42 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 031/800] 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 00000000000..ce09d985586 --- /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 a419b92b28e..b24b4c16c72 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 00000000000..2c188471e51 --- /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 d437b36bd09..5e29a0f9e1c 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 a8ba52c5f42..02e46549e83 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 a4b0e632f28..e0a2bac9001 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 14837e34fb7524b8ace87a49c8b808542addd55d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 28 Jun 2024 22:28:06 +0100 Subject: [PATCH 032/800] Readme: Added sponsor practinet --- readme.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/readme.md b/readme.md index c67fcb81435..5b78129913c 100644 --- a/readme.md +++ b/readme.md @@ -77,6 +77,11 @@ Big thanks to these companies for supporting the project. Schroeck IT Consulting + + + + Practinet + ## 🛣️ Road Map From 517c578a5f3c7718db9dc54603e553c0a935a89f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 30 Jun 2024 10:31:39 +0100 Subject: [PATCH 033/800] 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 b24b4c16c72..70e1a9ffc51 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 5e29a0f9e1c..2972c9821d5 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 78ddc8ce3d6..2ef117b8836 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 b2fd7e05a13..c823f9f9500 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 034/800] 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 2ef117b8836..685031ff871 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 00000000000..a844161f468 --- /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 c823f9f9500..9f05e211ed0 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 02e46549e83..6e822e9c139 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 035/800] 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 00000000000..28f47fd24e6 --- /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 aa8b27ec56c..cebf2580721 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 685031ff871..4ca90a12a1a 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 a844161f468..c9db0d6bd7f 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 9f05e211ed0..1f07fe71056 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 6e822e9c139..bb3b436b9e1 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 e0a2bac9001..2fcf4edb397 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 036/800] 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 00000000000..3cca3097a87 --- /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 98732dab73e..bdcdd5c51a0 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 9f2f1645a4f..3ca01835f28 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 cebf2580721..4a45ef75d0a 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 7e8df076a60..f74201ff74c 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 2972c9821d5..465765caa31 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 4ca90a12a1a..3c2ad89265c 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 1f07fe71056..3501ed557b6 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 bb3b436b9e1..550c798c264 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 2fcf4edb397..79cbc73e89d 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 5cd60bbc69b..8fc0dc55a55 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 038/800] 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 00000000000..7184334a0d2 --- /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 befc2ab2e05..4cc6bd08b78 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 00000000000..f1fd8c1994b --- /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 89077412691..b0d2392fd31 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 3c2ad89265c..a75d2478624 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 3501ed557b6..1ad1395dc77 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 039/800] 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 | 30 +++++----- resources/js/wysiwyg/ui/toolbars.ts | 3 +- resources/sass/_editor.scss | 7 +++ 8 files changed, 129 insertions(+), 26 deletions(-) diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index c5aba8ee287..537da636854 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 7184334a0d2..934fe7eddd6 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 f1fd8c1994b..80dcef3bdea 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 4a45ef75d0a..9f83fbea363 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 b0d2392fd31..a9917ab232f 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 a75d2478624..6477c4a1a76 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 550c798c264..18b81138007 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 7530382630b..5305ada8235 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 040/800] 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 934fe7eddd6..f839501db2e 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 00000000000..15726813c9b --- /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 4cc6bd08b78..e2c6902d338 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 00000000000..2f092bd203e --- /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 1ad1395dc77..50307fa6113 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 40d930149a9..9913c1235a4 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 041/800] 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 439eaa5a13d..71debf2bd68 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 5b822e9009e..123d6c8f5cd 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 00000000000..c5aba8ee287 --- /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 537da636854..da19545d110 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 beb0ce92fbf..00000000000 --- 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 00000000000..c19939e92a9 --- /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 d5e6fa79874..351e8016734 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 f839501db2e..2478ba24900 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 9913c1235a4..3ca03da303d 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 9b0ef85f77f140be8dffa2f333070f0429c15bb2 Mon Sep 17 00:00:00 2001 From: DanielGordonIT <108084596+DanielGordonIT@users.noreply.github.com> Date: Wed, 3 Jul 2024 15:50:25 -0400 Subject: [PATCH 042/800] Wraps file extension comparison components in strtolower() This avoids the issue where replacing file.PNG with newfile.png fails due to "PNG" not being equal to "png" --- app/Uploads/ImageRepo.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 1e58816a4f3..845067fdc29 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -166,7 +166,7 @@ public function updateImageDetails(Image $image, $updateDetails): Image */ public function updateImageFile(Image $image, UploadedFile $file): void { - if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) { + if (strtolower($file->getClientOriginalExtension()) !== strtolower(pathinfo($image->path, PATHINFO_EXTENSION))) { throw new ImageUploadException(trans('errors.image_upload_replace_type')); } From 04c7e680fd8e86004ab27c517b3fa300c7091062 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 4 Jul 2024 13:09:53 +0100 Subject: [PATCH 043/800] 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 cd928de9f16..ad5bcf09030 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 963c2100896..ecfc3546f4e 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 093442ea2bc..46ae6ecf4cc 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 2f0e660b19f..deb37186471 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 b0ff896c76e..09b6e060b67 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 044/800] 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 70e1a9ffc51..a75cf64fef5 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 bcd61e45cd3..52a9c380986 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 2c188471e51..83f394d9d86 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 35886d2f922..45c3f39d190 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 18b81138007..df514e504bf 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 EditorDropdownButton(new EditorColorButton(highlightColor, 'background-color'), [ - new EditorColorPicker('background-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 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 5305ada8235..f5e166cc3cb 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 25f92ce584cf058d225cbb3560b23ca558d60c0d Mon Sep 17 00:00:00 2001 From: DanielGordonIT <108084596+DanielGordonIT@users.noreply.github.com> Date: Thu, 4 Jul 2024 19:48:12 -0400 Subject: [PATCH 045/800] Add test to verify different case on extensions works --- tests/Uploads/ImageTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index db500f60669..e28880c95fd 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -119,6 +119,7 @@ public function test_image_file_update() $this->files->deleteAtRelativePath($relPath); } + public function test_image_file_update_does_not_allow_change_in_image_extension() { $page = $this->entities->page(); From ca310966b2ef52460c9ad7ca79dc707ff2ed3329 Mon Sep 17 00:00:00 2001 From: DanielGordonIT <108084596+DanielGordonIT@users.noreply.github.com> Date: Fri, 5 Jul 2024 03:59:49 +0000 Subject: [PATCH 046/800] Actually add the test this time --- tests/Uploads/ImageTest.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index e28880c95fd..2c36f5f356e 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -119,6 +119,32 @@ public function test_image_file_update() $this->files->deleteAtRelativePath($relPath); } + public function test_image_file_update_allows_case_differences() + { + $page = $this->entities->page(); + $this->asEditor(); + + $imgDetails = $this->files->uploadGalleryImageToPage($this, $page); + $relPath = $imgDetails['path']; + + $newUpload = $this->files->uploadedImage('updated-image.PNG', 'compressed.png'); + $this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath)); + + $imageId = $imgDetails['response']->id; + $image = Image::findOrFail($imageId); + $image->updated_at = now()->subMonth(); + $image->save(); + + $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload]) + ->assertOk(); + + $this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath)); + + $image->refresh(); + $this->assertTrue($image->updated_at->gt(now()->subMinute())); + + $this->files->deleteAtRelativePath($relPath); + } public function test_image_file_update_does_not_allow_change_in_image_extension() { From ce697ab0f5a217b0959b3a0ad2b19f487abd7885 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Jul 2024 14:37:29 +0100 Subject: [PATCH 047/800] Readme: Added sponsor, removed road map section Road map section was very much outdated and redundant so removing to avoid confusion. --- readme.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 5b78129913c..7c68d7ec607 100644 --- a/readme.md +++ b/readme.md @@ -79,20 +79,14 @@ Big thanks to these companies for supporting the project. - + Practinet + + Transport Talent + -## 🛣️ Road Map - -Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in our [Release Process](dev/docs/release-process.md) documentation. - -- **Platform REST API** - *(Most actions implemented, maturing)* - - *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.* -- **Permission System Review** - *(In Progress)* - - *Improvement in how permissions are applied and a review of the efficiency of the permission & roles system.* - ## 🛠️ Development & Testing Please see our [development docs](dev/docs/development.md) for full details regarding work on the BookStack source code. From 51d8044a547164080bb6ab31a60eb53348e595e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 9 Jul 2024 20:49:47 +0100 Subject: [PATCH 048/800] 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 c2ef4651081..afd3f467145 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 09b6e060b67..d1d96b17233 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 bfc5fc619bf..6b09accdc8b 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 df514e504bf..25a7e7815fd 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 f5e166cc3cb..1f932e14705 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 95c3cc5c00fefe954481129caa68f6bf6bad5f7a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 12:20:06 +0100 Subject: [PATCH 049/800] Styles: Improved callout RTL support Will now adapt using logical styles where supported, will fallbacks to old fixed LTR positioning where not supported. For #5104 --- resources/sass/_content.scss | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 3aa4ac653d9..be5a6517e0b 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -127,11 +127,18 @@ body .page-content img, /** * Callouts + * Some styles duplicated for supporting logical units (eg. inline-end) while + * providing fallbacks to non-logical rules, so RTL is natively supported where possible. */ .callout { border-left: 3px solid #BBB; + border-inline-start: 3px solid #BBB; + border-inline-end: none; background-color: #EEE; - padding: $-s $-s $-s $-xl; + padding: $-s; + padding-left: $-xl; + padding-inline-start: $-xl; + padding-inline-end: $-s; display: block; position: relative; overflow: auto; @@ -142,6 +149,8 @@ body .page-content img, width: 1.2em; height: 1.2em; left: $-xs + 2px; + inset-inline-start: $-xs + 2px; + inset-inline-end: unset; top: 50%; margin-top: -9px; display: inline-block; @@ -150,7 +159,7 @@ body .page-content img, opacity: 0.8; } &.success { - @include lightDark(border-left-color, $positive, $positive-dark); + @include lightDark(border-color, $positive, $positive-dark); @include lightDark(background-color, lighten($positive, 68%), darken($positive-dark, 36%)); @include lightDark(color, darken($positive, 16%), $positive-dark); } @@ -158,7 +167,7 @@ body .page-content img, background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMzNzZjMzkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0yIDE1bC01LTUgMS40MS0xLjQxTDEwIDE0LjE3bDcuNTktNy41OUwxOSA4bC05IDl6Ii8+PC9zdmc+"); } &.danger { - @include lightDark(border-left-color, $negative, $negative-dark); + @include lightDark(border-color, $negative, $negative-dark); @include lightDark(background-color, lighten($negative, 56%), darken($negative-dark, 55%)); @include lightDark(color, darken($negative, 20%), $negative-dark); } @@ -166,12 +175,12 @@ body .page-content img, background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiOTE4MTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0xNS43MyAzSDguMjdMMyA4LjI3djcuNDZMOC4yNyAyMWg3LjQ2TDIxIDE1LjczVjguMjdMMTUuNzMgM3pNMTIgMTcuM2MtLjcyIDAtMS4zLS41OC0xLjMtMS4zIDAtLjcyLjU4LTEuMyAxLjMtMS4zLjcyIDAgMS4zLjU4IDEuMyAxLjMgMCAuNzItLjU4IDEuMy0xLjMgMS4zem0xLTQuM2gtMlY3aDJ2NnoiLz4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg=="); } &.info { - @include lightDark(border-left-color, $info, $info-dark); + @include lightDark(border-color, $info, $info-dark); @include lightDark(color, darken($info, 20%), $info-dark); @include lightDark(background-color, lighten($info, 50%), darken($info-dark, 34%)); } &.warning { - @include lightDark(border-left-color, $warning, $warning-dark); + @include lightDark(border-color, $warning, $warning-dark); @include lightDark(background-color, lighten($warning, 50%), darken($warning-dark, 50%)); @include lightDark(color, darken($warning, 20%), $warning-dark); } From 7161f2270693cb91b930dd7dc646e933651f96fa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 13:55:46 +0100 Subject: [PATCH 050/800] Dependancies: Updated composer & npm deps --- composer.lock | 452 +++++++++++++++++++++++----------------------- package-lock.json | 192 +++++++++++--------- 2 files changed, 328 insertions(+), 316 deletions(-) diff --git a/composer.lock b/composer.lock index db0c9e667a6..0a1ed3e7b0e 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "aws/aws-crt-php", - "version": "v1.2.5", + "version": "v1.2.6", "source": { "type": "git", "url": "https://github.com/awslabs/aws-crt-php.git", - "reference": "0ea1f04ec5aa9f049f97e012d1ed63b76834a31b" + "reference": "a63485b65b6b3367039306496d49737cf1995408" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/0ea1f04ec5aa9f049f97e012d1ed63b76834a31b", - "reference": "0ea1f04ec5aa9f049f97e012d1ed63b76834a31b", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/a63485b65b6b3367039306496d49737cf1995408", + "reference": "a63485b65b6b3367039306496d49737cf1995408", "shasum": "" }, "require": { @@ -56,22 +56,22 @@ ], "support": { "issues": "https://github.com/awslabs/aws-crt-php/issues", - "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.5" + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.6" }, - "time": "2024-04-19T21:30:56+00:00" + "time": "2024-06-13T17:21:28+00:00" }, { "name": "aws/aws-sdk-php", - "version": "3.311.2", + "version": "3.316.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "731cd73062909594c5f7443413c4c4c40ed1c25c" + "reference": "e832e594b3c213760e067e15ef2739f77505e832" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/731cd73062909594c5f7443413c4c4c40ed1c25c", - "reference": "731cd73062909594c5f7443413c4c4c40ed1c25c", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e832e594b3c213760e067e15ef2739f77505e832", + "reference": "e832e594b3c213760e067e15ef2739f77505e832", "shasum": "" }, "require": { @@ -151,9 +151,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.311.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.316.3" }, - "time": "2024-06-07T18:05:33+00:00" + "time": "2024-07-12T18:07:23+00:00" }, { "name": "bacon/bacon-qr-code", @@ -390,16 +390,16 @@ }, { "name": "dflydev/dot-access-data", - "version": "v3.0.2", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/dflydev/dflydev-dot-access-data.git", - "reference": "f41715465d65213d644d3141a6a93081be5d3549" + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/f41715465d65213d644d3141a6a93081be5d3549", - "reference": "f41715465d65213d644d3141a6a93081be5d3549", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", "shasum": "" }, "require": { @@ -459,9 +459,9 @@ ], "support": { "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", - "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.2" + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" }, - "time": "2022-10-27T11:44:00+00:00" + "time": "2024-07-08T12:26:09+00:00" }, { "name": "doctrine/cache", @@ -558,16 +558,16 @@ }, { "name": "doctrine/dbal", - "version": "3.8.4", + "version": "3.8.6", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "b05e48a745f722801f55408d0dbd8003b403dbbd" + "reference": "b7411825cf7efb7e51f9791dea19d86e43b399a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/b05e48a745f722801f55408d0dbd8003b403dbbd", - "reference": "b05e48a745f722801f55408d0dbd8003b403dbbd", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/b7411825cf7efb7e51f9791dea19d86e43b399a1", + "reference": "b7411825cf7efb7e51f9791dea19d86e43b399a1", "shasum": "" }, "require": { @@ -583,12 +583,12 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.10.58", - "phpstan/phpstan-strict-rules": "^1.5", - "phpunit/phpunit": "9.6.16", + "phpstan/phpstan": "1.11.5", + "phpstan/phpstan-strict-rules": "^1.6", + "phpunit/phpunit": "9.6.19", "psalm/plugin-phpunit": "0.18.4", "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.9.0", + "squizlabs/php_codesniffer": "3.10.1", "symfony/cache": "^5.4|^6.0|^7.0", "symfony/console": "^4.4|^5.4|^6.0|^7.0", "vimeo/psalm": "4.30.0" @@ -651,7 +651,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.8.4" + "source": "https://github.com/doctrine/dbal/tree/3.8.6" }, "funding": [ { @@ -667,7 +667,7 @@ "type": "tidelift" } ], - "time": "2024-04-25T07:04:44+00:00" + "time": "2024-06-19T10:38:17+00:00" }, { "name": "doctrine/deprecations", @@ -1838,16 +1838,16 @@ }, { "name": "intervention/image", - "version": "3.6.5", + "version": "3.7.2", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "d428433aa74836ab75e8d4a5241628bebb7f4077" + "reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/d428433aa74836ab75e8d4a5241628bebb7f4077", - "reference": "d428433aa74836ab75e8d4a5241628bebb7f4077", + "url": "https://api.github.com/repos/Intervention/image/zipball/5451ff9f909c2fc836722e5ed6831b9f9a6db68c", + "reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c", "shasum": "" }, "require": { @@ -1894,7 +1894,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.6.5" + "source": "https://github.com/Intervention/image/tree/3.7.2" }, "funding": [ { @@ -1906,7 +1906,7 @@ "type": "github" } ], - "time": "2024-06-06T17:15:24+00:00" + "time": "2024-07-05T13:35:01+00:00" }, { "name": "knplabs/knp-snappy", @@ -1977,16 +1977,16 @@ }, { "name": "laravel/framework", - "version": "v10.48.12", + "version": "v10.48.16", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "590afea38e708022662629fbf5184351fa82cf08" + "reference": "f9a3a50fae399d75e125b0eea637dda90c99eae7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/590afea38e708022662629fbf5184351fa82cf08", - "reference": "590afea38e708022662629fbf5184351fa82cf08", + "url": "https://api.github.com/repos/laravel/framework/zipball/f9a3a50fae399d75e125b0eea637dda90c99eae7", + "reference": "f9a3a50fae399d75e125b0eea637dda90c99eae7", "shasum": "" }, "require": { @@ -2180,20 +2180,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-05-28T15:46:19+00:00" + "time": "2024-07-09T15:25:22+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.23", + "version": "v0.1.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "9bc4df7c699b0452c6b815e64a2d84b6d7f99400" + "reference": "409b0b4305273472f3754826e68f4edbd0150149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/9bc4df7c699b0452c6b815e64a2d84b6d7f99400", - "reference": "9bc4df7c699b0452c6b815e64a2d84b6d7f99400", + "url": "https://api.github.com/repos/laravel/prompts/zipball/409b0b4305273472f3754826e68f4edbd0150149", + "reference": "409b0b4305273472f3754826e68f4edbd0150149", "shasum": "" }, "require": { @@ -2236,9 +2236,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.23" + "source": "https://github.com/laravel/prompts/tree/v0.1.24" }, - "time": "2024-05-27T13:53:20+00:00" + "time": "2024-06-17T13:58:22+00:00" }, { "name": "laravel/serializable-closure", @@ -2302,16 +2302,16 @@ }, { "name": "laravel/socialite", - "version": "v5.14.0", + "version": "v5.15.1", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "c7b0193a3753a29aff8ce80aa2f511917e6ed68a" + "reference": "cc02625f0bd1f95dc3688eb041cce0f1e709d029" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/c7b0193a3753a29aff8ce80aa2f511917e6ed68a", - "reference": "c7b0193a3753a29aff8ce80aa2f511917e6ed68a", + "url": "https://api.github.com/repos/laravel/socialite/zipball/cc02625f0bd1f95dc3688eb041cce0f1e709d029", + "reference": "cc02625f0bd1f95dc3688eb041cce0f1e709d029", "shasum": "" }, "require": { @@ -2370,7 +2370,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2024-05-03T20:31:38+00:00" + "time": "2024-06-28T20:09:34+00:00" }, { "name": "laravel/tinker", @@ -3173,16 +3173,16 @@ }, { "name": "monolog/monolog", - "version": "3.6.0", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "4b18b21a5527a3d5ffdac2fd35d3ab25a9597654" + "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/4b18b21a5527a3d5ffdac2fd35d3ab25a9597654", - "reference": "4b18b21a5527a3d5ffdac2fd35d3ab25a9597654", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8", + "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8", "shasum": "" }, "require": { @@ -3258,7 +3258,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.6.0" + "source": "https://github.com/Seldaek/monolog/tree/3.7.0" }, "funding": [ { @@ -3270,7 +3270,7 @@ "type": "tidelift" } ], - "time": "2024-04-12T21:02:21+00:00" + "time": "2024-06-28T09:40:51+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3595,16 +3595,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.0.2", + "version": "v5.1.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13" + "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/139676794dc1e9231bf7bcd123cfc0c99182cb13", - "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", "shasum": "" }, "require": { @@ -3615,7 +3615,7 @@ }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -3647,9 +3647,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" }, - "time": "2024-03-05T20:51:40+00:00" + "time": "2024-07-01T20:03:41+00:00" }, { "name": "nunomaduro/termwind", @@ -4085,20 +4085,20 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.37", + "version": "3.0.39", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "cfa2013d0f68c062055180dd4328cc8b9d1f30b8" + "reference": "211ebc399c6e73c225a018435fe5ae209d1d1485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/cfa2013d0f68c062055180dd4328cc8b9d1f30b8", - "reference": "cfa2013d0f68c062055180dd4328cc8b9d1f30b8", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/211ebc399c6e73c225a018435fe5ae209d1d1485", + "reference": "211ebc399c6e73c225a018435fe5ae209d1d1485", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^1|^2", + "paragonie/constant_time_encoding": "^1|^2|^3", "paragonie/random_compat": "^1.4|^2.0|^9.99.99", "php": ">=5.6.1" }, @@ -4175,7 +4175,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.37" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.39" }, "funding": [ { @@ -4191,7 +4191,7 @@ "type": "tidelift" } ], - "time": "2024-03-03T02:14:58+00:00" + "time": "2024-06-24T06:27:33+00:00" }, { "name": "pragmarx/google2fa", @@ -4769,16 +4769,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.3", + "version": "v0.12.4", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "b6b6cce7d3ee8fbf31843edce5e8f5a72eff4a73" + "reference": "2fd717afa05341b4f8152547f142cd2f130f6818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/b6b6cce7d3ee8fbf31843edce5e8f5a72eff4a73", - "reference": "b6b6cce7d3ee8fbf31843edce5e8f5a72eff4a73", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/2fd717afa05341b4f8152547f142cd2f130f6818", + "reference": "2fd717afa05341b4f8152547f142cd2f130f6818", "shasum": "" }, "require": { @@ -4842,9 +4842,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.3" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.4" }, - "time": "2024-04-02T15:57:53+00:00" + "time": "2024-06-10T01:18:23+00:00" }, { "name": "ralouphie/getallheaders", @@ -5115,16 +5115,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v8.5.1", + "version": "v8.6.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "4a3d572b0f8b28bb6fd016ae8bbfc445facef152" + "reference": "d2fb94a9641be84d79c7548c6d39bbebba6e9a70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/4a3d572b0f8b28bb6fd016ae8bbfc445facef152", - "reference": "4a3d572b0f8b28bb6fd016ae8bbfc445facef152", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d2fb94a9641be84d79c7548c6d39bbebba6e9a70", + "reference": "d2fb94a9641be84d79c7548c6d39bbebba6e9a70", "shasum": "" }, "require": { @@ -5174,9 +5174,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.5.1" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.6.0" }, - "time": "2024-02-15T16:41:13+00:00" + "time": "2024-07-01T07:33:21+00:00" }, { "name": "socialiteproviders/discord", @@ -5623,16 +5623,16 @@ }, { "name": "symfony/console", - "version": "v6.4.8", + "version": "v6.4.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "be5854cee0e8c7b110f00d695d11debdfa1a2a91" + "reference": "6edb5363ec0c78ad4d48c5128ebf4d083d89d3a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/be5854cee0e8c7b110f00d695d11debdfa1a2a91", - "reference": "be5854cee0e8c7b110f00d695d11debdfa1a2a91", + "url": "https://api.github.com/repos/symfony/console/zipball/6edb5363ec0c78ad4d48c5128ebf4d083d89d3a9", + "reference": "6edb5363ec0c78ad4d48c5128ebf4d083d89d3a9", "shasum": "" }, "require": { @@ -5697,7 +5697,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.8" + "source": "https://github.com/symfony/console/tree/v6.4.9" }, "funding": [ { @@ -5713,7 +5713,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-06-28T09:49:33+00:00" }, { "name": "symfony/css-selector", @@ -5849,16 +5849,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.8", + "version": "v6.4.9", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "ef836152bf13472dc5fb5b08b0c0c4cfeddc0fcc" + "reference": "c9b7cc075b3ab484239855622ca05cb0b99c13ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/ef836152bf13472dc5fb5b08b0c0c4cfeddc0fcc", - "reference": "ef836152bf13472dc5fb5b08b0c0c4cfeddc0fcc", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c9b7cc075b3ab484239855622ca05cb0b99c13ec", + "reference": "c9b7cc075b3ab484239855622ca05cb0b99c13ec", "shasum": "" }, "require": { @@ -5904,7 +5904,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.8" + "source": "https://github.com/symfony/error-handler/tree/v6.4.9" }, "funding": [ { @@ -5920,7 +5920,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-06-21T16:04:15+00:00" }, { "name": "symfony/event-dispatcher", @@ -6221,16 +6221,16 @@ }, { "name": "symfony/http-kernel", - "version": "v6.4.8", + "version": "v6.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6c519aa3f32adcfd1d1f18d923f6b227d9acf3c1" + "reference": "cc4a9bec6e1bdd2405f40277a68a6ed1bb393005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6c519aa3f32adcfd1d1f18d923f6b227d9acf3c1", - "reference": "6c519aa3f32adcfd1d1f18d923f6b227d9acf3c1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cc4a9bec6e1bdd2405f40277a68a6ed1bb393005", + "reference": "cc4a9bec6e1bdd2405f40277a68a6ed1bb393005", "shasum": "" }, "require": { @@ -6315,7 +6315,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.8" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.9" }, "funding": [ { @@ -6331,20 +6331,20 @@ "type": "tidelift" } ], - "time": "2024-06-02T16:06:25+00:00" + "time": "2024-06-28T11:48:06+00:00" }, { "name": "symfony/mime", - "version": "v6.4.8", + "version": "v6.4.9", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "618597ab8b78ac86d1c75a9d0b35540cda074f33" + "reference": "7d048964877324debdcb4e0549becfa064a20d43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/618597ab8b78ac86d1c75a9d0b35540cda074f33", - "reference": "618597ab8b78ac86d1c75a9d0b35540cda074f33", + "url": "https://api.github.com/repos/symfony/mime/zipball/7d048964877324debdcb4e0549becfa064a20d43", + "reference": "7d048964877324debdcb4e0549becfa064a20d43", "shasum": "" }, "require": { @@ -6358,7 +6358,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/mailer": "<5.4", - "symfony/serializer": "<6.3.2" + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", @@ -6368,7 +6368,7 @@ "symfony/process": "^5.4|^6.4|^7.0", "symfony/property-access": "^5.4|^6.0|^7.0", "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.3.2|^7.0" + "symfony/serializer": "^6.4.3|^7.0.3" }, "type": "library", "autoload": { @@ -6400,7 +6400,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.8" + "source": "https://github.com/symfony/mime/tree/v6.4.9" }, "funding": [ { @@ -6416,20 +6416,20 @@ "type": "tidelift" } ], - "time": "2024-06-01T07:50:16+00:00" + "time": "2024-06-28T09:49:33+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" + "reference": "0424dff1c58f028c451efff2045f5d92410bd540" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", - "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", + "reference": "0424dff1c58f028c451efff2045f5d92410bd540", "shasum": "" }, "require": { @@ -6479,7 +6479,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" }, "funding": [ { @@ -6495,20 +6495,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f" + "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f", - "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/64647a7c30b2283f5d49b874d84a18fc22054b7a", + "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a", "shasum": "" }, "require": { @@ -6557,7 +6557,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.30.0" }, "funding": [ { @@ -6573,20 +6573,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "a287ed7475f85bf6f61890146edbc932c0fff919" + "reference": "a6e83bdeb3c84391d1dfe16f42e40727ce524a5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a287ed7475f85bf6f61890146edbc932c0fff919", - "reference": "a287ed7475f85bf6f61890146edbc932c0fff919", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a6e83bdeb3c84391d1dfe16f42e40727ce524a5c", + "reference": "a6e83bdeb3c84391d1dfe16f42e40727ce524a5c", "shasum": "" }, "require": { @@ -6641,7 +6641,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.30.0" }, "funding": [ { @@ -6657,20 +6657,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d" + "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d", - "reference": "bc45c394692b948b4d383a08d7753968bed9a83d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", "shasum": "" }, "require": { @@ -6722,7 +6722,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" }, "funding": [ { @@ -6738,20 +6738,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" + "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", - "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", "shasum": "" }, "require": { @@ -6802,7 +6802,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" }, "funding": [ { @@ -6818,20 +6818,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-06-19T12:30:46+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "861391a8da9a04cbad2d232ddd9e4893220d6e25" + "reference": "10112722600777e02d2745716b70c5db4ca70442" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/861391a8da9a04cbad2d232ddd9e4893220d6e25", - "reference": "861391a8da9a04cbad2d232ddd9e4893220d6e25", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/10112722600777e02d2745716b70c5db4ca70442", + "reference": "10112722600777e02d2745716b70c5db4ca70442", "shasum": "" }, "require": { @@ -6875,7 +6875,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php72/tree/v1.30.0" }, "funding": [ { @@ -6891,20 +6891,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-06-19T12:30:46+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" + "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", - "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", + "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", "shasum": "" }, "require": { @@ -6955,7 +6955,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" }, "funding": [ { @@ -6971,25 +6971,24 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "86fcae159633351e5fd145d1c47de6c528f8caff" + "reference": "dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/86fcae159633351e5fd145d1c47de6c528f8caff", - "reference": "86fcae159633351e5fd145d1c47de6c528f8caff", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9", + "reference": "dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9", "shasum": "" }, "require": { - "php": ">=7.1", - "symfony/polyfill-php80": "^1.14" + "php": ">=7.1" }, "type": "library", "extra": { @@ -7032,7 +7031,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.30.0" }, "funding": [ { @@ -7048,20 +7047,20 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-06-19T12:35:24+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "3abdd21b0ceaa3000ee950097bc3cf9efc137853" + "reference": "2ba1f33797470debcda07fe9dce20a0003df18e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/3abdd21b0ceaa3000ee950097bc3cf9efc137853", - "reference": "3abdd21b0ceaa3000ee950097bc3cf9efc137853", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/2ba1f33797470debcda07fe9dce20a0003df18e9", + "reference": "2ba1f33797470debcda07fe9dce20a0003df18e9", "shasum": "" }, "require": { @@ -7111,7 +7110,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.30.0" }, "funding": [ { @@ -7127,7 +7126,7 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "symfony/process", @@ -7358,16 +7357,16 @@ }, { "name": "symfony/string", - "version": "v6.4.8", + "version": "v6.4.9", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "a147c0f826c4a1f3afb763ab8e009e37c877a44d" + "reference": "76792dbd99690a5ebef8050d9206c60c59e681d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/a147c0f826c4a1f3afb763ab8e009e37c877a44d", - "reference": "a147c0f826c4a1f3afb763ab8e009e37c877a44d", + "url": "https://api.github.com/repos/symfony/string/zipball/76792dbd99690a5ebef8050d9206c60c59e681d7", + "reference": "76792dbd99690a5ebef8050d9206c60c59e681d7", "shasum": "" }, "require": { @@ -7424,7 +7423,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.8" + "source": "https://github.com/symfony/string/tree/v6.4.9" }, "funding": [ { @@ -7440,7 +7439,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-06-28T09:25:38+00:00" }, { "name": "symfony/translation", @@ -7691,16 +7690,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.8", + "version": "v6.4.9", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "ad23ca4312395f0a8a8633c831ef4c4ee542ed25" + "reference": "c31566e4ca944271cc8d8ac6887cbf31b8c6a172" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ad23ca4312395f0a8a8633c831ef4c4ee542ed25", - "reference": "ad23ca4312395f0a8a8633c831ef4c4ee542ed25", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c31566e4ca944271cc8d8ac6887cbf31b8c6a172", + "reference": "c31566e4ca944271cc8d8ac6887cbf31b8c6a172", "shasum": "" }, "require": { @@ -7756,7 +7755,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.8" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.9" }, "funding": [ { @@ -7772,7 +7771,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-06-27T13:23:14+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8307,16 +8306,16 @@ }, { "name": "larastan/larastan", - "version": "v2.9.7", + "version": "v2.9.8", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "5c805f636095cc2e0b659e3954775cf8f1dad1bb" + "reference": "340badd89b0eb5bddbc503a4829c08cf9a2819d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/5c805f636095cc2e0b659e3954775cf8f1dad1bb", - "reference": "5c805f636095cc2e0b659e3954775cf8f1dad1bb", + "url": "https://api.github.com/repos/larastan/larastan/zipball/340badd89b0eb5bddbc503a4829c08cf9a2819d7", + "reference": "340badd89b0eb5bddbc503a4829c08cf9a2819d7", "shasum": "" }, "require": { @@ -8330,7 +8329,7 @@ "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.0", "php": "^8.0.2", "phpmyadmin/sql-parser": "^5.9.0", - "phpstan/phpstan": "^1.11.1" + "phpstan/phpstan": "^1.11.2" }, "require-dev": { "doctrine/coding-standard": "^12.0", @@ -8385,7 +8384,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v2.9.7" + "source": "https://github.com/larastan/larastan/tree/v2.9.8" }, "funding": [ { @@ -8405,7 +8404,7 @@ "type": "patreon" } ], - "time": "2024-05-27T18:33:26+00:00" + "time": "2024-07-06T17:46:02+00:00" }, { "name": "mockery/mockery", @@ -8492,16 +8491,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", "shasum": "" }, "require": { @@ -8509,11 +8508,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -8539,7 +8539,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" }, "funding": [ { @@ -8547,7 +8547,7 @@ "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2024-06-12T14:39:25+00:00" }, { "name": "nunomaduro/collision", @@ -8853,16 +8853,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.11.4", + "version": "1.11.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "9100a76ce8015b9aa7125b9171ae3a76887b6c82" + "reference": "52d2bbfdcae7f895915629e4694e9497d0f8e28d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9100a76ce8015b9aa7125b9171ae3a76887b6c82", - "reference": "9100a76ce8015b9aa7125b9171ae3a76887b6c82", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/52d2bbfdcae7f895915629e4694e9497d0f8e28d", + "reference": "52d2bbfdcae7f895915629e4694e9497d0f8e28d", "shasum": "" }, "require": { @@ -8907,20 +8907,20 @@ "type": "github" } ], - "time": "2024-06-06T12:19:22+00:00" + "time": "2024-07-06T11:17:41+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.14", + "version": "10.1.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b" + "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", - "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", + "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", "shasum": "" }, "require": { @@ -8977,7 +8977,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.14" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.15" }, "funding": [ { @@ -8985,7 +8985,7 @@ "type": "github" } ], - "time": "2024-03-12T15:33:41+00:00" + "time": "2024-06-29T08:25:15+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9232,16 +9232,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.20", + "version": "10.5.27", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "547d314dc24ec1e177720d45c6263fb226cc2ae3" + "reference": "2425f713b2a5350568ccb1a2d3984841a23e83c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/547d314dc24ec1e177720d45c6263fb226cc2ae3", - "reference": "547d314dc24ec1e177720d45c6263fb226cc2ae3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2425f713b2a5350568ccb1a2d3984841a23e83c5", + "reference": "2425f713b2a5350568ccb1a2d3984841a23e83c5", "shasum": "" }, "require": { @@ -9251,26 +9251,26 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", + "myclabs/deep-copy": "^1.12.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", "php": ">=8.1", - "phpunit/php-code-coverage": "^10.1.5", - "phpunit/php-file-iterator": "^4.0", - "phpunit/php-invoker": "^4.0", - "phpunit/php-text-template": "^3.0", - "phpunit/php-timer": "^6.0", - "sebastian/cli-parser": "^2.0", - "sebastian/code-unit": "^2.0", - "sebastian/comparator": "^5.0", - "sebastian/diff": "^5.0", - "sebastian/environment": "^6.0", - "sebastian/exporter": "^5.1", - "sebastian/global-state": "^6.0.1", - "sebastian/object-enumerator": "^5.0", - "sebastian/recursion-context": "^5.0", - "sebastian/type": "^4.0", - "sebastian/version": "^4.0" + "phpunit/php-code-coverage": "^10.1.15", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.1", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.2", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.0", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" @@ -9313,7 +9313,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.20" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.27" }, "funding": [ { @@ -9329,7 +9329,7 @@ "type": "tidelift" } ], - "time": "2024-04-24T06:32:35+00:00" + "time": "2024-07-10T11:48:06+00:00" }, { "name": "sebastian/cli-parser", diff --git a/package-lock.json b/package-lock.json index 63b0d2478e6..a117f88a636 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,19 +40,10 @@ "sass": "^1.69.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.17.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz", + "integrity": "sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -67,13 +58,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" } }, @@ -90,9 +81,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", @@ -129,9 +120,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", @@ -168,9 +159,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", @@ -189,9 +180,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", @@ -225,9 +216,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.4", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.4.tgz", + "integrity": "sha512-QScv95fiviSQ/CaVGflxAvvvDy/9wi0RFyDl4LkHHWiMr/UPebyuTspmYSeN5Nx6eujcPYwsQzA6ZIZucKZVHQ==", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -618,9 +609,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.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" @@ -662,6 +653,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", @@ -689,6 +681,7 @@ "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/@lezer/common": { @@ -707,9 +700,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", @@ -728,9 +721,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", @@ -738,9 +731,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", @@ -758,9 +751,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" } @@ -857,9 +850,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.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -1093,12 +1086,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" @@ -1346,9 +1339,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" @@ -1862,9 +1855,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" @@ -1943,9 +1936,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" @@ -2104,6 +2097,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", @@ -2148,12 +2142,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" @@ -2288,9 +2283,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": { @@ -2322,6 +2317,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", @@ -2423,12 +2419,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" @@ -3057,10 +3056,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" } @@ -3165,17 +3167,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" @@ -3483,6 +3485,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" @@ -3553,9 +3556,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.8", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", + "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", @@ -3713,9 +3716,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": { @@ -4109,6 +4112,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", @@ -4178,9 +4190,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" From 767699a0664c13fea1293654578f571ec4a08fa3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 14:21:16 +0100 Subject: [PATCH 051/800] OIDC: Fixed incorrect detection of group detail population An empty (but valid formed) groups list provided via the OIDC ID token would be considered as a lacking detail, and therefore trigger a lookup to the userinfo endpoint in an attempt to get that information. This fixes this to properly distinguish between not-provided and empty state, to avoid userinfo where provided as valid but empty. Includes test to cover. For #5101 --- app/Access/Oidc/OidcUserDetails.php | 8 ++++---- tests/Auth/OidcTest.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php index bccc49ee4d8..fae20de0b62 100644 --- a/app/Access/Oidc/OidcUserDetails.php +++ b/app/Access/Oidc/OidcUserDetails.php @@ -22,7 +22,7 @@ public function isFullyPopulated(bool $groupSyncActive): bool $hasEmpty = empty($this->externalId) || empty($this->email) || empty($this->name) - || ($groupSyncActive && empty($this->groups)); + || ($groupSyncActive && $this->groups === null); return !$hasEmpty; } @@ -57,15 +57,15 @@ protected static function getUserDisplayName(string $displayNameClaims, Provides return implode(' ', $displayName); } - protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): array + protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array { if (empty($groupsClaim)) { - return []; + return null; } $groupsList = Arr::get($token->getAllClaims(), $groupsClaim); if (!is_array($groupsList)) { - return []; + return null; } return array_values(array_filter($groupsList, function ($val) { diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index 9bde71c80ef..201f67b5333 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -849,6 +849,26 @@ public function test_userinfo_endpoint_response_with_invalid_content_type_throws $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); } + public function test_userinfo_endpoint_not_called_if_empty_groups_array_provided_in_id_token() + { + config()->set([ + 'oidc.user_to_groups' => true, + 'oidc.groups_claim' => 'groups', + 'oidc.remove_from_groups' => false, + ]); + + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + $client = $this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'groups' => [], + ])]); + + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertEquals(1, $client->requestCount()); + $this->assertTrue(auth()->check()); + } + protected function withAutodiscovery(): void { config()->set([ From 897bb338f956245e2c86bda6cd5c6a67711f9448 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 16:06:18 +0100 Subject: [PATCH 052/800] CSP: Updated handling of drawio URL to consider port Previously if a custom port was used in the DRAWIO option it would not be considered in the CSP handling, which would block loading. Added test to cover. For #5107 --- app/Util/CspService.php | 30 +++++++++++++++++++++--------- tests/SecurityHeaderTest.php | 12 ++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/Util/CspService.php b/app/Util/CspService.php index 227ec8e0b3d..4262b5c98f8 100644 --- a/app/Util/CspService.php +++ b/app/Util/CspService.php @@ -133,18 +133,30 @@ protected function getAllowedIframeHosts(): array protected function getAllowedIframeSources(): array { - $sources = config('app.iframe_sources', ''); - $hosts = array_filter(explode(' ', $sources)); + $sources = explode(' ', config('app.iframe_sources', '')); + $sources[] = $this->getDrawioHost(); - // Extract drawing service url to allow embedding if active + return array_filter($sources); + } + + /** + * Extract the host name of the configured drawio URL for use in CSP. + * Returns empty string if not in use. + */ + protected function getDrawioHost(): string + { $drawioConfigValue = config('services.drawio'); - if ($drawioConfigValue) { - $drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/'; - $drawioSourceParsed = parse_url($drawioSource); - $drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host']; - $hosts[] = $drawioHost; + if (!$drawioConfigValue) { + return ''; + } + + $drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/'; + $drawioSourceParsed = parse_url($drawioSource); + $drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host']; + if (isset($drawioSourceParsed['port'])) { + $drawioHost .= ':' . $drawioSourceParsed['port']; } - return $hosts; + return $drawioHost; } } diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php index d369e695c51..5d354e5539e 100644 --- a/tests/SecurityHeaderTest.php +++ b/tests/SecurityHeaderTest.php @@ -139,6 +139,18 @@ public function test_frame_src_csp_header_has_drawio_host_added() $this->assertEquals('frame-src \'self\' https://example.com https://diagrams.example.com', $scriptHeader); } + public function test_frame_src_csp_header_drawio_host_includes_port_if_existing() + { + config()->set([ + 'app.iframe_sources' => 'https://example.com', + 'services.drawio' => 'https://diagrams.example.com:8080/testing?cat=dog', + ]); + + $resp = $this->get('/'); + $scriptHeader = $this->getCspHeader($resp, 'frame-src'); + $this->assertEquals('frame-src \'self\' https://example.com https://diagrams.example.com:8080', $scriptHeader); + } + public function test_cache_control_headers_are_set_on_responses() { // Public access From adb7bf7016502ebdc8aafdbca422731dac0abbfc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 16:36:36 +0100 Subject: [PATCH 053/800] Codemirror: Enabled non-standard self-closing tags For #5078 --- resources/js/code/languages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/code/languages.js b/resources/js/code/languages.js index c0f9e6dd996..ef317334c2b 100644 --- a/resources/js/code/languages.js +++ b/resources/js/code/languages.js @@ -35,7 +35,7 @@ const modeMap = { go: () => legacyLoad('go'), haskell: () => legacyLoad('haskell'), hs: () => legacyLoad('haskell'), - html: async () => html(), + html: async () => html({selfClosingTags: true}), ini: () => legacyLoad('properties'), java: () => legacyLoad('java'), javascript: async () => javascript(), From 75f225d6dc843957ac73e51f1c2ae21177ec1e8b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 16:39:50 +0100 Subject: [PATCH 054/800] Updated translations with latest Crowdin changes (#5065) --- lang/cs/preferences.php | 6 ++-- lang/cy/editor.php | 4 +-- lang/de_informal/auth.php | 2 +- lang/hu/auth.php | 2 +- lang/ja/auth.php | 16 +++++------ lang/ja/errors.php | 2 +- lang/pt/common.php | 2 +- lang/pt/preferences.php | 18 ++++++------ lang/sv/activities.php | 58 +++++++++++++++++++-------------------- lang/sv/auth.php | 6 ++-- lang/sv/common.php | 10 +++---- lang/sv/components.php | 22 +++++++-------- lang/sv/editor.php | 4 +-- lang/sv/entities.php | 2 +- 14 files changed, 77 insertions(+), 77 deletions(-) diff --git a/lang/cs/preferences.php b/lang/cs/preferences.php index 9a127601e2c..ec7afaf276b 100644 --- a/lang/cs/preferences.php +++ b/lang/cs/preferences.php @@ -20,14 +20,14 @@ 'shortcuts_overview_desc' => 'Správa klávesových zkratek, které můžete použít k navigaci systémového uživatelského rozhraní.', 'notifications' => 'Nastavení upozornění', - 'notifications_desc' => 'Ovládat e-mailová oznámení, která dostáváte při provádění určité aktivity v systému.', + 'notifications_desc' => 'Nastavte si e-mailová oznámení, která dostanete při provedení určitých akcí v systému.', 'notifications_opt_own_page_changes' => 'Upozornit na změny stránek u kterých jsem vlastníkem', 'notifications_opt_own_page_comments' => 'Upozornit na komentáře na stránkách, které vlastním', 'notifications_opt_comment_replies' => 'Upozornit na odpovědi na mé komentáře', - 'notifications_save' => 'Uložit preference', + 'notifications_save' => 'Uložit nastavení', 'notifications_update_success' => 'Nastavení oznámení byla aktualizována!', 'notifications_watched' => 'Sledované a ignorované položky', - 'notifications_watched_desc' => 'Níže jsou položky, které mají vlastní nastavení hodinek. Chcete-li aktualizovat vaše předvolby, podívejte se na položku a pak najděte možnosti hodinek v postranním panelu.', + 'notifications_watched_desc' => 'Níže jsou položky, které mají vlastní nastavení sledování. Chcete-li aktualizovat vaše předvolby, zobrazte položku a pak upravte možnosti sledování v postranním panelu.', 'auth' => 'Přístup a zabezpečení', 'auth_change_password' => 'Změnit heslo', diff --git a/lang/cy/editor.php b/lang/cy/editor.php index 473759feb2d..eba7834cc73 100644 --- a/lang/cy/editor.php +++ b/lang/cy/editor.php @@ -7,7 +7,7 @@ */ return [ // General editor terms - 'general' => 'General', + 'general' => 'Cyffredin', 'advanced' => 'Advanced', 'none' => 'Dim un', 'cancel' => 'Canslo', @@ -59,7 +59,7 @@ 'list_task' => 'Task list', 'indent_increase' => 'Increase indent', 'indent_decrease' => 'Decrease indent', - 'table' => 'Table', + 'table' => 'Bwrdd', 'insert_image' => 'Insert image', 'insert_image_title' => 'Insert/Edit Image', 'insert_link' => 'Insert/edit link', diff --git a/lang/de_informal/auth.php b/lang/de_informal/auth.php index 8e05948930c..779726f4bbe 100644 --- a/lang/de_informal/auth.php +++ b/lang/de_informal/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobile App', 'mfa_option_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigst du eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Backup Code', - 'mfa_option_backup_codes_desc' => 'Erzeugt eine Reihe von einmalig nutzbaren Backup-Codes, welche Sie bei der Anmeldung eingeben, um Ihre Identität zu bestätigen. Achten Sie darauf diese an einem sicheren Ort aufzubewahren.', + 'mfa_option_backup_codes_desc' => 'Erzeugt eine Reihe von einmalig nutzbaren Backup-Codes, welche Du bei der Anmeldung eingibst, um deine Identität zu bestätigen. Achte darauf diese an einem sicheren Ort aufzubewahren.', 'mfa_gen_confirm_and_enable' => 'Bestätigen und aktivieren', 'mfa_gen_backup_codes_title' => 'Backup-Codes einrichten', 'mfa_gen_backup_codes_desc' => 'Speichere die folgende Liste der Codes an einem sicheren Ort. Wenn du auf das System zugreifst, kannst du einen der Codes als zweiten Authentifizierungsmechanismus verwenden.', diff --git a/lang/hu/auth.php b/lang/hu/auth.php index dac6065cce2..db065453ec3 100644 --- a/lang/hu/auth.php +++ b/lang/hu/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobilalkalmazás', 'mfa_option_totp_desc' => 'A többlépcsős azonosításhoz olyan mobilalkalmazásra lesz szükséged, amely támogatja a TOTP-t, például a Google Authenticator, az Authy vagy a Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Visszaállítási kulcsok', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Egyszer használatos biztonsági kódokat hoz létre, amelyeket bejelentkezéskor kell megadnia személyazonosságának igazolására. Ügyeljen arra, hogy ezeket biztonságos helyen tárolja.', 'mfa_gen_confirm_and_enable' => 'Jóváhagyás és engedélyezés', 'mfa_gen_backup_codes_title' => 'Visszaállítási kódok beállítása', 'mfa_gen_backup_codes_desc' => 'Tárolja el egy biztonságos helyen az alábbi kódokat. Bejelentkezés során fel tudja használni őket másodlagos bejelentkezési kódként.', diff --git a/lang/ja/auth.php b/lang/ja/auth.php index 09c14a04f6e..077c31ac85c 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -87,13 +87,13 @@ 'mfa_setup_reconfigure' => '再設定', 'mfa_setup_remove_confirmation' => 'この多要素認証方法を削除してもよろしいですか?', 'mfa_setup_action' => '設定', - 'mfa_backup_codes_usage_limit_warning' => 'バックアップコードは残り5つ以下です。アカウントのロックアウトを防ぐため、コードがなくなる前に新しいセットを生成して保存してください。', + 'mfa_backup_codes_usage_limit_warning' => '有効な確認コードは残り5つ以下です。アカウントのロックアウトを防ぐため、コードがなくなる前に新しいセットを生成して保存してください。', 'mfa_option_totp_title' => 'モバイルアプリ', 'mfa_option_totp_desc' => '多要素認証を使用するには、Google Authenticator、Authy、Microsoft AuthenticatorなどのTOTPをサポートするモバイルアプリケーションが必要です。', - 'mfa_option_backup_codes_title' => 'バックアップコード', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_title' => '確認コード', + 'mfa_option_backup_codes_desc' => 'ログイン時に本人確認のために追加入力する1回限りの確認コードセットを生成します。 これは安全な場所に保管してください。', 'mfa_gen_confirm_and_enable' => '確認して有効化', - 'mfa_gen_backup_codes_title' => 'バックアップコードの設定', + 'mfa_gen_backup_codes_title' => '確認コードの構成', 'mfa_gen_backup_codes_desc' => '以下のコードのリストを安全な場所に保管してください。システムにアクセスする際、コードのいずれかを第二の認証手段として使用できます。', 'mfa_gen_backup_codes_download' => 'コードをダウンロード', 'mfa_gen_backup_codes_usage_warning' => '各コードは一度だけ使用できます', @@ -108,10 +108,10 @@ 'mfa_verify_no_methods' => '手段が設定されていません', 'mfa_verify_no_methods_desc' => 'アカウントの多要素認証手段が見つかりませんでした。アクセスする前に、少なくとも1つの手段を設定する必要があります。', 'mfa_verify_use_totp' => 'モバイルアプリを利用して確認', - 'mfa_verify_use_backup_codes' => 'バックアップコードを利用して確認', - 'mfa_verify_backup_code' => 'バックアップコード', - 'mfa_verify_backup_code_desc' => '残りのバックアップコードのいずれかを入力してください:', - 'mfa_verify_backup_code_enter_here' => 'バックアップコードを入力', + 'mfa_verify_use_backup_codes' => '確認コードによる検証', + 'mfa_verify_backup_code' => '確認コード', + 'mfa_verify_backup_code_desc' => '残りの確認コードのいずれかを入力してください:', + 'mfa_verify_backup_code_enter_here' => '確認コードを入力', 'mfa_verify_totp_desc' => 'モバイルアプリを利用して生成されたコードを入力してください:', 'mfa_setup_login_notification' => '多要素認証が構成されました。設定された手段を利用して再度ログインしてください。', ]; diff --git a/lang/ja/errors.php b/lang/ja/errors.php index a944541111a..90a4f854ea8 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -37,7 +37,7 @@ 'social_driver_not_found' => 'Social driverが見つかりません', 'social_driver_not_configured' => 'あなたの:socialAccount設定は正しく構成されていません。', 'invite_token_expired' => 'この招待リンクの有効期限が切れています。 代わりにアカウントのパスワードをリセットしてみてください。', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'このアクションのユーザーが見つかりません。', // System 'path_not_writable' => 'ファイルパス :filePath へアップロードできませんでした。サーバ上での書き込みが許可されているか確認してください。', diff --git a/lang/pt/common.php b/lang/pt/common.php index 089cfea3b08..59abaea5c57 100644 --- a/lang/pt/common.php +++ b/lang/pt/common.php @@ -20,7 +20,7 @@ 'description' => 'Descrição', 'role' => 'Cargo', 'cover_image' => 'Imagem de capa', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'Esta imagem deve ser de aproximadamente 440x250px, embora seja escalada de forma flexível e cortada para caber na interface do usuário em diferentes cenários conforme necessário, então, dimensões atuais para exibição serão diferentes.', // Actions 'actions' => 'Ações', diff --git a/lang/pt/preferences.php b/lang/pt/preferences.php index ec5e54bba2b..860eec645bc 100644 --- a/lang/pt/preferences.php +++ b/lang/pt/preferences.php @@ -25,9 +25,9 @@ 'notifications_opt_own_page_comments' => 'Notificar quando comentam páginas que possuo', 'notifications_opt_comment_replies' => 'Notificar respostas aos meus comentários', 'notifications_save' => 'Guardar preferências', - 'notifications_update_success' => 'Notification preferences have been updated!', - 'notifications_watched' => 'Watched & Ignored Items', - 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + 'notifications_update_success' => 'Preferências de notificação foram atualizadas!', + 'notifications_watched' => 'Itens assistidos e ignorados', + 'notifications_watched_desc' => 'Abaixo estão os itens que possuem preferências de seguir personalizadas aplicadas. Para atualizar suas preferências para estes, veja o item e encontre as opções de seguir na barra lateral.', 'auth' => 'Acesso e Segurança', 'auth_change_password' => 'Alterar Palavra-passe', @@ -35,13 +35,13 @@ 'auth_change_password_success' => 'A palavra-passe foi atualizada!', 'profile' => 'Detalhes Do Perfil', - 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', + 'profile_desc' => 'Gerencie os detalhes de sua conta que o representam para outros usuários, além de detalhes que são usados para personalização do sistema e comunicação.', 'profile_view_public' => 'Visualizar Perfil Público', - 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', - 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', - 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', - 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', - 'profile_admin_options' => 'Administrator Options', + 'profile_name_desc' => 'Configure o seu nome de exibição que será visível para outros usuários no sistema através da atividade que você executa e do conteúdo você tem.', + 'profile_email_desc' => 'Este e-mail será usado para notificações e, dependendo da autenticação ativa do sistema, acesso do sistema.', + 'profile_email_no_permission' => 'Infelizmente você não tem permissão para alterar seu correio eletrônico. Se você quiser mudar isso, você precisa pedir a um administrador para alterar por você.', + 'profile_avatar_desc' => 'Selecione uma imagem que será usada para lhe representar aos outros usuários do sistema. Idealmente, esta imagem deve ser quadrada e sobre 256px em largura e altura.', + 'profile_admin_options' => 'Opções de administrador', 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', 'delete_account' => 'Excluir Conta', diff --git a/lang/sv/activities.php b/lang/sv/activities.php index 11b9a9efe58..abe53c856d1 100644 --- a/lang/sv/activities.php +++ b/lang/sv/activities.php @@ -64,17 +64,17 @@ // Auth 'auth_login' => 'loggade in', 'auth_register' => 'registrerad som ny användare', - 'auth_password_reset_request' => 'requested user password reset', - 'auth_password_reset_update' => 'reset user password', - 'mfa_setup_method' => 'configured MFA method', + 'auth_password_reset_request' => 'begärd återställning av användarlösenord', + 'auth_password_reset_update' => 'återställa användarens lösenord', + 'mfa_setup_method' => 'konfigurerad MFA metod', 'mfa_setup_method_notification' => 'Multifaktor-metod har konfigurerats', - 'mfa_remove_method' => 'removed MFA method', + 'mfa_remove_method' => 'tog bort MFA metod', 'mfa_remove_method_notification' => 'Multifaktor-metod har tagits bort', // Settings - 'settings_update' => 'updated settings', - 'settings_update_notification' => 'Settings successfully updated', - 'maintenance_action_run' => 'ran maintenance action', + 'settings_update' => 'uppdaterade inställningar', + 'settings_update_notification' => 'Inställningarna har uppdaterats', + 'maintenance_action_run' => 'körde underhållsåtgärder', // Webhooks 'webhook_create' => 'skapade webhook', @@ -85,39 +85,39 @@ 'webhook_delete_notification' => 'Webhook har tagits bort', // Users - 'user_create' => 'created user', - 'user_create_notification' => 'User successfully created', - 'user_update' => 'updated user', + 'user_create' => 'skapade användare', + 'user_create_notification' => 'Användare skapades', + 'user_update' => 'uppdaterad användare', 'user_update_notification' => 'Användaren har uppdaterats', - 'user_delete' => 'deleted user', + 'user_delete' => 'raderad användare', 'user_delete_notification' => 'Användaren har tagits bort', // API Tokens - 'api_token_create' => 'created API token', - 'api_token_create_notification' => 'API token successfully created', - 'api_token_update' => 'updated API token', - 'api_token_update_notification' => 'API token successfully updated', - 'api_token_delete' => 'deleted API token', - 'api_token_delete_notification' => 'API token successfully deleted', + 'api_token_create' => 'skapade API-token', + 'api_token_create_notification' => 'API-token har skapats', + 'api_token_update' => 'uppdaterad API-token', + 'api_token_update_notification' => 'API-token har uppdaterats', + 'api_token_delete' => 'raderad API-token', + 'api_token_delete_notification' => 'API-token har tagits bort', // Roles - 'role_create' => 'created role', - 'role_create_notification' => 'Role successfully created', - 'role_update' => 'updated role', - 'role_update_notification' => 'Role successfully updated', - 'role_delete' => 'deleted role', - 'role_delete_notification' => 'Role successfully deleted', + 'role_create' => 'skapad roll', + 'role_create_notification' => 'Rollen har skapats', + 'role_update' => 'uppdaterad roll', + 'role_update_notification' => 'Rollen har uppdaterats', + 'role_delete' => 'raderad roll', + 'role_delete_notification' => 'Rollen har tagits bort', // Recycle Bin - 'recycle_bin_empty' => 'emptied recycle bin', - 'recycle_bin_restore' => 'restored from recycle bin', - 'recycle_bin_destroy' => 'removed from recycle bin', + 'recycle_bin_empty' => 'tömd papperskorg', + 'recycle_bin_restore' => 'återställd från papperskorgen', + 'recycle_bin_destroy' => 'borttagen från papperskorgen', // Comments 'commented_on' => 'kommenterade', - 'comment_create' => 'added comment', - 'comment_update' => 'updated comment', - 'comment_delete' => 'deleted comment', + 'comment_create' => 'lagt till kommentar', + 'comment_update' => 'uppdaterad kommentar', + 'comment_delete' => 'raderad kommentar', // Other 'permissions_update' => 'uppdaterade behörigheter', diff --git a/lang/sv/auth.php b/lang/sv/auth.php index 813200c36f5..c9feb831218 100644 --- a/lang/sv/auth.php +++ b/lang/sv/auth.php @@ -61,8 +61,8 @@ 'email_confirm_send_error' => 'E-posten behöver bekräftas men systemet kan inte skicka mail. Kontakta adminstratören för att kontrollera att allt är konfigurerat korrekt.', 'email_confirm_success' => 'Din e-postadress har bekräftats! Du bör nu kunna logga in med denna e-postadress.', 'email_confirm_resent' => 'Bekräftelsemailet har skickats på nytt, kolla din mail', - 'email_confirm_thanks' => 'Thanks for confirming!', - 'email_confirm_thanks_desc' => 'Please wait a moment while your confirmation is handled. If you are not redirected after 3 seconds press the "Continue" link below to proceed.', + 'email_confirm_thanks' => 'Tack för att du bekräftade!', + 'email_confirm_thanks_desc' => 'Vänta en stund medan din bekräftelse hanteras. Om du inte omdirigeras efter 3 sekunder tryck på "Fortsätt" länken nedan för att fortsätta.', 'email_not_confirmed' => 'E-posadress ej bekräftad', 'email_not_confirmed_text' => 'Din e-postadress har inte bekräftats ännu.', @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobilapp', 'mfa_option_totp_desc' => 'För att använda multifaktorautentisering behöver du en mobil app som stöder TOTP så som Google Authenticator, Authy eller Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Reservkoder', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Genererar en uppsättning engångsbaserade säkerhetskopieringskoder som du anger vid inloggningen för att verifiera din identitet. Se till att förvara dessa på en säker och säker plats.', 'mfa_gen_confirm_and_enable' => 'Bekräfta och aktivera', 'mfa_gen_backup_codes_title' => 'Konfiguration av reservkoder', 'mfa_gen_backup_codes_desc' => 'Spara nedanstående koder på en säker plats. När du använder systemet kommer du att kunna använda en av koderna som en andra autentiseringsmekanism.', diff --git a/lang/sv/common.php b/lang/sv/common.php index 70e9d2500a0..ae0aded6225 100644 --- a/lang/sv/common.php +++ b/lang/sv/common.php @@ -20,13 +20,13 @@ 'description' => 'Beskrivning', 'role' => 'Roll', 'cover_image' => 'Omslagsbild', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'Denna bild bör vara ungefär 440x250px även om den kommer att vara flexibelt skalad och beskuren för att passa användargränssnittet i olika scenarier där så krävs, kommer faktiska visningsmått att skilja sig.', // Actions 'actions' => 'Åtgärder', 'view' => 'Visa', 'view_all' => 'Visa alla', - 'new' => 'New', + 'new' => 'Ny', 'create' => 'Skapa', 'update' => 'Uppdatera', 'edit' => 'Redigera', @@ -52,7 +52,7 @@ 'filter_clear' => 'Rensa filter', 'download' => 'Ladda ner', 'open_in_tab' => 'Öppna i flik', - 'open' => 'Open', + 'open' => 'Öppna', // Sort Options 'sort_options' => 'Sorteringsalternativ', @@ -84,14 +84,14 @@ 'none' => 'Inga', // Header - 'homepage' => 'Homepage', + 'homepage' => 'Startsida', 'header_menu_expand' => 'Expandera sidhuvudsmenyn', 'profile_menu' => 'Profilmeny', 'view_profile' => 'Visa profil', 'edit_profile' => 'Redigera profil', 'dark_mode' => 'Mörkt läge', 'light_mode' => 'Ljust läge', - 'global_search' => 'Global Search', + 'global_search' => 'Global sökning', // Layout tabs 'tab_info' => 'Information', diff --git a/lang/sv/components.php b/lang/sv/components.php index 5a4296868ae..dc587a44df6 100644 --- a/lang/sv/components.php +++ b/lang/sv/components.php @@ -6,34 +6,34 @@ // Image Manager 'image_select' => 'Val av bild', - 'image_list' => 'Image List', - 'image_details' => 'Image Details', - 'image_upload' => 'Upload Image', - 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', - 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_list' => 'Bildlista', + 'image_details' => 'Bilddetaljer', + 'image_upload' => 'Ladda upp bild', + 'image_intro' => 'Här kan du välja och hantera bilder som tidigare har laddats upp till systemet.', + 'image_intro_upload' => 'Ladda upp en ny bild genom att dra en bildfil till detta fönster, eller genom att använda knappen "Ladda upp bild" ovan.', 'image_all' => 'Alla', 'image_all_title' => 'Visa alla bilder', 'image_book_title' => 'Visa bilder som laddats upp till den aktuella boken', 'image_page_title' => 'Visa bilder som laddats upp till den aktuella sidan', 'image_search_hint' => 'Sök efter bildens namn', 'image_uploaded' => 'Laddades upp :uploadedDate', - 'image_uploaded_by' => 'Uploaded by :userName', - 'image_uploaded_to' => 'Uploaded to :pageLink', - 'image_updated' => 'Updated :updateDate', + 'image_uploaded_by' => 'Uppladdad av :userName', + 'image_uploaded_to' => 'Uppladdad till :pageLink', + 'image_updated' => 'Uppdaterad :updateDate', 'image_load_more' => 'Ladda fler', 'image_image_name' => 'Bildnamn', 'image_delete_used' => 'Den här bilden används på nedanstående sidor.', 'image_delete_confirm_text' => 'Är du säker på att du vill radera denna bild?', 'image_select_image' => 'Välj bild', 'image_dropzone' => 'Släpp bilder här eller klicka för att ladda upp', - 'image_dropzone_drop' => 'Drop images here to upload', + 'image_dropzone_drop' => 'Släpp filer här för att ladda upp dem', 'images_deleted' => 'Bilder borttagna', 'image_preview' => 'Förhandsgranskning', 'image_upload_success' => 'Bilden har laddats upp', 'image_update_success' => 'Bildens uppgifter har ändrats', 'image_delete_success' => 'Bilden har tagits bort', - 'image_replace' => 'Replace Image', - 'image_replace_success' => 'Image file successfully updated', + 'image_replace' => 'Ersätt bild', + 'image_replace_success' => 'Bildfilen har uppdaterats', 'image_rebuild_thumbs' => 'Regenerate Size Variations', 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', diff --git a/lang/sv/editor.php b/lang/sv/editor.php index 0ada0a4cec1..25e443511c1 100644 --- a/lang/sv/editor.php +++ b/lang/sv/editor.php @@ -82,7 +82,7 @@ 'table_properties_title' => 'Tabellegenskaper', 'delete_table' => 'Ta bort tabell', 'table_clear_formatting' => 'Rensa tabell-formatering', - 'resize_to_contents' => 'Resize to contents', + 'resize_to_contents' => 'Ändra storlek till innehåll', 'row_header' => 'Radrubrik', 'insert_row_before' => 'Infoga rad före', 'insert_row_after' => 'Infoga rad efter', @@ -165,7 +165,7 @@ 'editor_license' => 'Licens och upphovsrätt för redigerare', 'editor_tiny_license' => 'Denna redigerare är byggd med :tinyLink som tillhandahålls under MIT licensen.', 'editor_tiny_license_link' => 'Upphovsrätten och licensuppgifterna för TinyMCE hittar du här.', - 'save_continue' => 'Spara & Fortsätt', + 'save_continue' => 'Spara sida & fortsätt', 'callouts_cycle' => '(Fortsätt trycka för att växla mellan typer)', 'link_selector' => 'Länka till innehåll', 'shortcuts' => 'Genvägar', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 79aa46365df..1be2a56c6aa 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -39,7 +39,7 @@ 'export_pdf' => 'PDF-fil', 'export_text' => 'Textfil', 'export_md' => 'Markdown-fil', - 'default_template' => 'Default Page Template', + 'default_template' => 'Förvald sidmall', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', From 47ac0d5c3eadcb1d2260dac1d736b0517b6498a0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 14 Jul 2024 17:09:41 +0100 Subject: [PATCH 055/800] Updated translator & dependency attribution before release v24.05.3 --- .github/translators.txt | 4 +++- dev/licensing/js-library-licenses.txt | 18 +++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index f77b560fe71..46e2edfd4c5 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -428,4 +428,6 @@ Ohadp :: Hebrew cbridi :: Portuguese, Brazilian nanangsb :: Indonesian Michal Melich (michalmelich) :: Czech -David (david-prv) :: German +David (david-prv) :: German; German Informal +Larry (lahoje) :: Swedish +Marcia dos Santos (marciab80) :: Portuguese diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index 8211a4a44e1..0383c2fad35 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -9,7 +9,7 @@ acorn License: MIT License File: node_modules/acorn/LICENSE Copyright: Copyright (C) 2012-2022 by various contributors (see AUTHORS) -Source: https://github.com/acornjs/acorn.git +Source: git+https://github.com/acornjs/acorn.git Link: https://github.com/acornjs/acorn ----------- ajv @@ -120,7 +120,7 @@ Link: https://github.com/juliangruber/brace-expansion braces License: MIT License File: node_modules/braces/LICENSE -Copyright: Copyright (c) 2014-2018, Jon Schlinkert. +Copyright: Copyright (c) 2014-present, Jon Schlinkert. Source: micromatch/braces Link: https://github.com/micromatch/braces ----------- @@ -1665,6 +1665,13 @@ Copyright: Copyright (c) Isaac Z. Schlueter and Contributors Source: git://github.com/isaacs/node-which.git Link: git://github.com/isaacs/node-which.git ----------- +word-wrap +License: MIT +License File: node_modules/word-wrap/LICENSE +Copyright: Copyright (c) 2014-2016, Jon Schlinkert +Source: jonschlinkert/word-wrap +Link: https://github.com/jonschlinkert/word-wrap +----------- wrap-ansi License: MIT License File: node_modules/wrap-ansi/license @@ -1715,13 +1722,6 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/yocto-queue Link: sindresorhus/yocto-queue ----------- -@aashutoshrathi/word-wrap -License: MIT -License File: node_modules/@aashutoshrathi/word-wrap/LICENSE -Copyright: Copyright (c) 2014-2016, Jon Schlinkert -Source: git+https://github.com/aashutoshrathi/word-wrap.git -Link: https://github.com/aashutoshrathi/word-wrap ------------ @codemirror/autocomplete License: MIT License File: node_modules/@codemirror/autocomplete/LICENSE From ea4c50c2c22be9a8920d5dfe7f1162c3454f2d53 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 16 Jul 2024 16:36:08 +0100 Subject: [PATCH 056/800] 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 64bcb64902e..3708c2b2591 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 d1d96b17233..8cbaccd7979 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 e2c6902d338..a2c73957687 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 80dcef3bdea..11cc02e8f68 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 1e8bfd165a9..1bc1ea5437a 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 9f83fbea363..c6ea85b0d9e 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 a9917ab232f..570b8222b9c 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 6477c4a1a76..cfa94e8aeec 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 50307fa6113..74837095962 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 25a7e7815fd..d512b58e23d 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 1f932e14705..99045dd5ab1 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 e145f21512fefc80dc779a705e6b23c42e33090e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Jul 2024 11:13:39 +0100 Subject: [PATCH 057/800] Dev compose: Set image versions, removed unsupported mysql flag Quick local test performed, ran a working instance. For #5124 --- dev/docker/Dockerfile | 2 +- docker-compose.yml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index fe497e9e63e..50d528fafb8 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.1-apache +FROM php:8.3-apache ENV APACHE_DOCUMENT_ROOT /app/public WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml index 2e2fdf700de..f4c3a64c664 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,13 +6,12 @@ volumes: services: db: - image: mysql:8 + image: mysql:8.4 environment: MYSQL_DATABASE: bookstack-dev MYSQL_USER: bookstack-test MYSQL_PASSWORD: bookstack-test MYSQL_RANDOM_ROOT_PASSWORD: 'true' - command: --default-authentication-plugin=mysql_native_password volumes: - ./dev/docker/init.db:/docker-entrypoint-initdb.d - db:/var/lib/mysql @@ -41,7 +40,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" node: - image: node:alpine + image: node:22-alpine working_dir: /app user: node volumes: From b367490edc84b4aa5cd51e9481037faf0da4558b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Jul 2024 16:38:20 +0100 Subject: [PATCH 058/800] 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 3708c2b2591..600e71dd1cc 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 00000000000..67b5fb78062 --- /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 c6ea85b0d9e..d1d22dae1ea 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 d512b58e23d..9145b876193 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 059/800] 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 600e71dd1cc..a7c3e4453bc 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 11cc02e8f68..cfb2c6aefb2 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 1bc1ea5437a..2046260a023 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 d1d22dae1ea..bf725f8c8c8 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 8c28953d566..1981fcb8624 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 060/800] 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} | 62 ++++++++------ 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, 143 insertions(+), 113 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 123d6c8f5cd..812a451f233 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 da19545d110..a9b9275e92c 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 46e10327a26..75b161f75d5 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'; - -let iFrame = null; -let lastApprovedOrigin; -let onInit; -let onSave; +import {ConfirmDialog} from "../components"; + +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 761305793a1..00000000000 --- 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 00000000000..c251ee21b63 --- /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 67b5fb78062..61b592ca023 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 cfb2c6aefb2..d6947ea756d 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 2f092bd203e..9c48f8c2472 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 3ca03da303d..0be5421c7c8 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 061/800] 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 812a451f233..e08b90ba1e4 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 a9b9275e92c..1f216b7a53c 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 75b161f75d5..c0a6b5044bc 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 c251ee21b63..7d72a9f1af5 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 d95e4a59a29..00000000000 --- 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 00000000000..f9eaafc3912 --- /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 062/800] 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 00000000000..6ac78f56e7a --- /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 deb37186471..ebc142e2abc 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 c0a6b5044bc..4d7d88f1fdd 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 3b343a9586b..342cac0af74 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 8cbaccd7979..0aa04dfd907 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 15726813c9b..1aff0640037 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 61b592ca023..e0b58eef6a4 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 9c48f8c2472..0f1263f38f4 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 bf725f8c8c8..5316dacf7d3 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 465765caa31..22a821a89b2 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 74837095962..31407497f76 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 9145b876193..f5eae6b212d 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 99045dd5ab1..b577d185027 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 063/800] 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 0aa04dfd907..5f131df5725 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 e0b58eef6a4..9950254dfe6 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 22a821a89b2..c8f390c4803 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 cfa94e8aeec..29d95991075 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 c9db0d6bd7f..d7c48193479 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 31407497f76..f728ae48fa5 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 b577d185027..17e4af97bb0 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 064/800] 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 00000000000..428fc29a670 --- /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 00000000000..ee2f8a00dab --- /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 00000000000..412cf073253 --- /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 00000000000..75abd9a85c5 --- /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 00000000000..5bb38cd2935 --- /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 00000000000..df951485a50 --- /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 00000000000..b2af7759263 --- /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 a7c3e4453bc..6a55c429cda 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 9950254dfe6..7b958819471 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 5316dacf7d3..00000000000 --- 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 00000000000..2b441e5da87 --- /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 00000000000..0eb07ecf1f0 --- /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 00000000000..ad69d69d13c --- /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 00000000000..d04f72a2e57 --- /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 00000000000..ecda290a12c --- /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 00000000000..88241e92626 --- /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 00000000000..32fa49f88f3 --- /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 f728ae48fa5..5dee6b62b41 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 f5eae6b212d..5d40578a5b1 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 c31f8eb2e0e42cda136ad182ba4dbabe62ecbd33 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 22 Jul 2024 16:51:56 +0100 Subject: [PATCH 065/800] Readme: Added route4me sponsorship --- readme.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/readme.md b/readme.md index 7c68d7ec607..3f38d52c542 100644 --- a/readme.md +++ b/readme.md @@ -85,6 +85,11 @@ Big thanks to these companies for supporting the project. Transport Talent + + + + Route4Me - Route Optimizer and Route Planner Software + ## 🛠️ Development & Testing From 2cab778f19be477beebdada5096d6b945d84596a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 23 Jul 2024 12:45:58 +0100 Subject: [PATCH 066/800] 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 5f131df5725..469738e7fed 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 7b958819471..e39a4c655fe 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 8f1e978e9dc..2d995883a5b 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 29d95991075..c0357c3ea80 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 067/800] Lexical: Added common events support --- resources/js/services/events.ts | 4 +- resources/js/wysiwyg/actions.ts | 79 +++++++++++++++++++++++---- resources/js/wysiwyg/common-events.ts | 43 +++++++++++++++ resources/js/wysiwyg/index.ts | 3 + resources/js/wysiwyg/todo.md | 1 - 5 files changed, 115 insertions(+), 15 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 7d72a9f1af5..32c70f5a84b 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 3a32b82d861..0e220252567 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)); + }); +} + +export function appendHtmlToEditor(editor: LexicalEditor, html: string) { + const dom = htmlToDom(html); - // 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; + 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)); - root.append(...nodes); + 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]); + } + } }); } @@ -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 00000000000..7355d977ba6 --- /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 469738e7fed..e53b9b057dd 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 e39a4c655fe..c62a6e52462 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 068/800] 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 00000000000..0c4feea4c40 --- /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 a2c73957687..669ffe6ddc9 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 00000000000..e0c1b31412f --- /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 c62a6e52462..cd36f359ef1 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 88241e92626..3c14052baa4 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 04147a4f057..e0459b5c556 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 5dee6b62b41..a3f150e529e 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 5d40578a5b1..ae6a292a271 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 069/800] 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 e0c1b31412f..751f420fa50 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 cd36f359ef1..2125aa25878 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 e0459b5c556..a2242c33873 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 4fee787d354..b641f993bd1 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 070/800] 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 | 132 ++++++++++++++++-- resources/sass/_editor.scss | 39 ++++++ 5 files changed, 208 insertions(+), 30 deletions(-) diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 2125aa25878..49f685beabf 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 a2242c33873..6c0a54f2333 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 c8f390c4803..f644bc37a70 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 b641f993bd1..b225a3de2c2 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; + + 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 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 17e4af97bb0..1e52ad6a9b2 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 071/800] 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 0e220252567..a3d2f0ef603 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 00000000000..92dc758d8db --- /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 6a55c429cda..07755f449e6 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 e53b9b057dd..fee5365728e 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 49f685beabf..5d495e7d83d 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 072/800] 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 92dc758d8db..5d541365ab5 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 5d495e7d83d..73521df9b25 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 073/800] 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 fee5365728e..4130f41e8a3 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 73521df9b25..5e6cdd2cc88 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 c0357c3ea80..29d95991075 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 074/800] 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 4130f41e8a3..1e9dd25df7c 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 00000000000..53467e10bc9 --- /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 669ffe6ddc9..f0df08fcbb4 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 5e6cdd2cc88..dda05f1da80 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 00000000000..da8c0eae3dd --- /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 1e52ad6a9b2..4ffff3cc0c7 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 075/800] 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 | 25 +++++++++----- .../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, 100 insertions(+), 42 deletions(-) diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts index 53467e10bc9..21b9f6d5f93 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 dda05f1da80..1a367d0dd1a 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 32fa49f88f3..b6b92e1970f 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 { + $getNodeFromSelection, + $selectionContainsNodeType +} from "../../../helpers"; import {$getSelection} from "lexical"; -import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table"; +import {$isCustomTableNode} from "../../../nodes/custom-table"; import { - $deleteTableColumn, $deleteTableColumn__EXPERIMENTAL, + $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 a75cf64fef5..24659b5469c 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 52a9c380986..b0834fe4d48 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 83f394d9d86..108992db889 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 4418be6237c..9a23edfb7f6 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 ae6a292a271..d2b179eb6ce 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 4ffff3cc0c7..0cf14555977 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 076/800] 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 1e9dd25df7c..71a007f590e 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 1107f0a906b..7dda24a7a30 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 1a367d0dd1a..0354b7935ff 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 b6b92e1970f..e3f7bb570e5 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 00000000000..bcb2f5bad6e --- /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 6c0a54f2333..7a388751bc4 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 00000000000..a045ba55d61 --- /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 00000000000..30351602cc4 --- /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 00000000000..0557b37e53e --- /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 a3f150e529e..5fbaec91b55 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 d2b179eb6ce..43f00c0014b 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 077/800] 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 0354b7935ff..a0ea2e1ebfb 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 e3f7bb570e5..b0f0bf346a9 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 a045ba55d61..9951bfe7ff6 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: '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: 'Border style', - name: 'border_style', + 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: '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: 'Height', + name: 'height', + type: 'text', + }, { - label: 'Border color', - name: 'border_color', + label: 'Cell spacing', + name: 'cell_spacing', type: 'text', }, { - label: 'Background color', - name: 'background_color', + 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 30351602cc4..44d4e036094 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 24659b5469c..da0d3e5d0cf 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 45c3f39d190..e8cef3c8d20 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 43f00c0014b..3346e0a0724 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 078/800] 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 71a007f590e..9da646a771a 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 2478ba24900..e240a38870a 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 21b9f6d5f93..2b4d7414632 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 7dda24a7a30..32f3ec4fa57 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 a18c4d858d5..8071d5e8fd9 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 1aff0640037..76d9392480f 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 92d5518dbc7..ef6bf35724a 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 751f420fa50..aba4f6c37b4 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 7355d977ba6..16522d66b71 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 5d541365ab5..7c9bb2713ba 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 d6947ea756d..650bd64c550 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 0f1263f38f4..7c79f9f41d8 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 2046260a023..d110bc49930 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 2b441e5da87..40d9c89dce8 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 0eb07ecf1f0..eba903263b3 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 ad69d69d13c..2a2fecc40c4 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 d04f72a2e57..a967ecb2fc0 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 ecda290a12c..10500eb6718 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 3c14052baa4..0eac497fce8 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 b0f0bf346a9..2cc2e701b69 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 bcb2f5bad6e..fc461f6626f 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 7a388751bc4..dbb89b18f9d 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 6972d7a8e44..48e313f5cca 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 da0d3e5d0cf..a7905a6dde2 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 b0834fe4d48..d666954bf05 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 f83035aa6bd..2371983dd5f 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 108992db889..cd078053415 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 1981fcb8624..a8a142df5a1 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 9a23edfb7f6..cf114aa0212 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 f644bc37a70..3e9f1e3d935 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 b225a3de2c2..a2db34dd7a1 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 2d995883a5b..f312294c59d 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 6b09accdc8b..1768f6f545b 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 d7c48193479..b4e49af950c 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 3346e0a0724..48e11837c95 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 a3d2f0ef603..ae829bae3a3 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 00000000000..dc0872e89c8 --- /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 00000000000..8e6c666109b --- /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 07755f449e6..e34afbe36e8 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); - } - } - - for (const child of children) { - if (typeof child === 'string') { - el.append(document.createTextNode(child)); - } else { - el.append(child); - } - } - return el; -} +import {$getParentOfType} from "./nodes"; -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 079/800] 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 f13cef56f31..97647bf5e8d 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 00000000000..693ef5f5be1 --- /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 f0df08fcbb4..92f6d2336cf 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 a0ea2e1ebfb..d925711e17b 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 2cc2e701b69..3b431141f4b 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 9951bfe7ff6..291b355e708 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 080/800] 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 32f3ec4fa57..99351d8527a 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 d925711e17b..086ca14622d 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 291b355e708..1d637b0eea7 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 f312294c59d..37f1b6f01ee 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 dc0872e89c8..7426ac59252 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 00000000000..959c8a4236b --- /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 081/800] Lexical: Added table cell node import logic --- .../wysiwyg/nodes/custom-table-cell-node.ts | 146 +++++++++++++++++- resources/js/wysiwyg/todo.md | 1 - 2 files changed, 140 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/nodes/custom-table-cell-node.ts b/resources/js/wysiwyg/nodes/custom-table-cell-node.ts index 693ef5f5be1..31504374ad0 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 {SerializedTableCellNode, TableCellHeaderStates, TableCellNode} from "@lexical/table"; +import { + $createParagraphNode, + $isElementNode, + $isLineBreakNode, + $isTextNode, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + Spread +} from "lexical"; + +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 086ca14622d..ef86bfe53aa 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 6604e7365f78dcb683241715624d3061007929d0 Mon Sep 17 00:00:00 2001 From: Alexander Wilms Date: Tue, 6 Aug 2024 23:28:21 +0000 Subject: [PATCH 082/800] Update sponsor image URLs in readme --- readme.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 3f38d52c542..3fbc873c2bf 100644 --- a/readme.md +++ b/readme.md @@ -49,7 +49,7 @@ Big thanks to these companies for supporting the project.
    - Federated.computer + Federated.computer
    @@ -57,38 +57,38 @@ Big thanks to these companies for supporting the project.
    - Diagrams.net + Diagrams.net - Cloudabove + Cloudabove
    - Practicali + Practicali - Stellar Hosted + Stellar Hosted
    - NETWAYS Web Services + NETWAYS Web Services - Schroeck IT Consulting + Schroeck IT Consulting
    - Practinet + Practinet - Transport Talent + Transport Talent
    - Route4Me - Route Optimizer and Route Planner Software + Route4Me - Route Optimizer and Route Planner Software
    From fa6d66db4972dcebc3d58ed2faa999281075389f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 7 Aug 2024 10:53:20 +0100 Subject: [PATCH 083/800] Readme: Updated sponsor image links to use website --- readme.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 3fbc873c2bf..b3768a59ad0 100644 --- a/readme.md +++ b/readme.md @@ -49,7 +49,7 @@ Big thanks to these companies for supporting the project.
    - Federated.computer + Federated.computer
    @@ -57,38 +57,38 @@ Big thanks to these companies for supporting the project.
    - Diagrams.net + Diagrams.net - Cloudabove + Cloudabove
    - Practicali + Practicali - Stellar Hosted + Stellar Hosted
    - NETWAYS Web Services + NETWAYS Web Services - Schroeck IT Consulting + Schroeck IT Consulting
    - Practinet + Practinet - Transport Talent + Transport Talent
    - Route4Me - Route Optimizer and Route Planner Software + Route4Me - Route Optimizer and Route Planner Software
    From e8532ef4de7d641fabfe86ff313d379711f2209a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 7 Aug 2024 20:32:54 +0100 Subject: [PATCH 084/800] 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 ef86bfe53aa..2ca9b97dcb7 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 3b431141f4b..69d811ce247 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 0557b37e53e..f631fb804a5 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 00000000000..77c4eba45dc --- /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 959c8a4236b..d4ef80f7fa1 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 085/800] 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 31504374ad0..b73a2180716 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 00000000000..effaaa50d01 --- /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 92f6d2336cf..81a0c1a0d8e 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 69d811ce247..88ea56186ad 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 1d637b0eea7..1c577b72a50 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 00000000000..8767a799821 --- /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 77c4eba45dc..2b7eba62c03 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 d4ef80f7fa1..d92f56c8205 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 086/800] 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 2ca9b97dcb7..cf24ad677f6 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 88ea56186ad..50353961f0f 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 1c577b72a50..c4879efae66 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 d92f56c8205..e808fd595cc 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 087/800] 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 00000000000..7d880db9889 --- /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 cf24ad677f6..b6325688e58 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 50353961f0f..c98f6c02f37 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 088/800] 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 | 99 +++++++++++++++++++ 9 files changed, 241 insertions(+), 77 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 b73a2180716..c8fe58c772d 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 effaaa50d01..f4702f36dd5 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 99351d8527a..1d95b789625 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 b6325688e58..9e501fb2421 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 c98f6c02f37..6242f0b1dbc 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 c4879efae66..5a41c85b3da 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 7426ac59252..a307bdd7531 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 8767a799821..00000000000 --- 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 e808fd595cc..d0fd17e2ce7 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 089/800] 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 9da646a771a..a07fbd78980 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 e39dcc3ee94..b720b5c43ce 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 e240a38870a..a71e21e2e73 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); + + if (element.id) { + node.setId(element.id); + } - return { - node: $createCodeBlockNode(language, code), - }; + 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 00000000000..dba49898cc5 --- /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 00000000000..953bcb8cd15 --- /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 97647bf5e8d..cb936a5599b 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 00000000000..58c62f76919 --- /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 8071d5e8fd9..119619da6ad 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 76d9392480f..e2ffeaaddaf 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 fbd019e7299..e881d4688df 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 81a0c1a0d8e..8cbec20dad2 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 aba4f6c37b4..73208cb2e43 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 9e501fb2421..c8a0293d51d 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 090/800] 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 | 32 ++++----- .../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, 230 insertions(+), 108 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 00000000000..b8cb2cfc870 --- /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 e2ffeaaddaf..bd37b200c80 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 c8a0293d51d..1b10ef91be2 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 7c79f9f41d8..44d332939e8 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 0eac497fce8..f4075a7403d 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(), - } + $showImageForm(selectedImage, context); + return; + } + showImageManager((image) => { context.editor.update(() => { - const selection = $createNodeSelection(); - selection.add(selectedImage.getKey()); - $setSelection(selection); + const link = $createLinkedImageNodeFromImageData(image); + $insertNodes([link]); }); - } - - imageModal.show(formDefaults); + }) }); }, 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 dbb89b18f9d..c37696695a7 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 00000000000..1f40c2864d1 --- /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 29d95991075..92891b54084 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 1768f6f545b..ae69302f6d0 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 00000000000..50d7d5b3f36 --- /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 00000000000..89a4a60f091 --- /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 0cf14555977..379c436f466 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 091/800] 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 1b10ef91be2..194832d5f62 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 c37696695a7..6bd265e6c2a 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 00000000000..03c4a5ef075 --- /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 092/800] 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 942456d9ded..1264d105860 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 dba49898cc5..f069ff16048 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 194832d5f62..70a3744f3bc 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 6bd265e6c2a..2ad27f749c1 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 1f40c2864d1..b7741321bae 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 00000000000..5a64cdc30fc --- /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 8e6c666109b..6278186ca12 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 093/800] 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 00000000000..a15ec42c8f6 --- /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 70a3744f3bc..f3a8da404e6 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 f4075a7403d..96a92ff2285 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 00000000000..30dd237f60f --- /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 48e11837c95..87ecae03e2b 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 50d7d5b3f36..2dee3ab6b0d 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 89a4a60f091..a83d554186e 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 379c436f466..78e518bd566 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 094/800] 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 | 37 +++++++---- 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, 251 insertions(+), 82 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 00000000000..cc45dc9107c --- /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 b720b5c43ce..8018190c8a7 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 f069ff16048..885622ad338 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 cb936a5599b..663f32dfc68 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 58c62f76919..cee289dbe72 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 c8fe58c772d..15c305dcb0f 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 1d95b789625..b699763d976 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"; - -export type SerializedCustomTableNode = Spread<{ - id: string; +import { + CommonBlockAlignment, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; + +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 8cbec20dad2..b5483c5009c 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 f3a8da404e6..fec38271a06 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 40d9c89dce8..78de3c9a243 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 6278186ca12..e33cfda7ce6 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 3aa4ac653d9..b187d6408cc 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 095/800] 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 32c70f5a84b..be9fba7eca5 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 a07fbd78980..0a939baf4d0 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 00000000000..235c2788ab1 --- /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 fec38271a06..75263c927dc 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 eba903263b3..80e49348616 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 96a92ff2285..3494096a2c2 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 2ad27f749c1..2aefe541450 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 3e9f1e3d935..b6fe52dcdff 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 92891b54084..f10e85b470a 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 5fbaec91b55..116d6e1fc82 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 2dee3ab6b0d..fb5543005c2 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 00000000000..340be393da0 --- /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 e34afbe36e8..74dd9452743 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 096/800] 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 | 58 +++++++++---------- resources/js/wysiwyg/utils/formats.ts | 48 ++++++++++++++- 7 files changed, 123 insertions(+), 84 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 0a939baf4d0..d7f873ea5cc 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 235c2788ab1..b17ec1bf7a8 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 75263c927dc..f05e79baa0f 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 10500eb6718..edec3ea0076 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 3494096a2c2..46556d3d136 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 2aefe541450..714d5f64b91 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 const link: EditorFormDefinition = { - submitText: 'Apply', - async action(formData, context: EditorUiContext) { - context.editor.update(() => { +export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('link'); - const url = formData.get('url')?.toString() || ''; - const title = formData.get('title')?.toString() || '' - const target = formData.get('target')?.toString() || ''; - const text = formData.get('text')?.toString() || ''; + let formDefaults = {}; + if (link) { + formDefaults = { + url: link.getURL(), + text: link.getTextContent(), + title: link.getTitle(), + target: link.getTarget(), + } - 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, - }); + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(link.getKey()); + $setSelection(selection); + }); + } - $insertNodes([link]); - } + linkModal.show(formDefaults); +} - if ($isLinkNode(link)) { - for (const child of link.getChildren()) { - child.remove(true); - } - link.append($createTextNode(text)); - } +export const link: EditorFormDefinition = { + submitText: 'Apply', + async action(formData, context: EditorUiContext) { + 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 340be393da0..97038f07bad 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 097/800] 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 d7f873ea5cc..fdcfa5b7ef0 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 7c9bb2713ba..00000000000 --- 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 00000000000..85d0235d88b --- /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 f05e79baa0f..f339a6ed470 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 a83d554186e..2c13427d967 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 098/800] 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 15c305dcb0f..793302cfec4 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 f339a6ed470..dcc86688893 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 6242f0b1dbc..1a9ffb0d33d 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 b6fe52dcdff..a04f3c74a8e 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 116d6e1fc82..bfa76bb82a8 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 7d880db9889..385c4c46c75 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 00000000000..ae8ef3d3587 --- /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 2b7eba62c03..bc9721d96d6 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 099/800] 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 dcc86688893..5df26bd8cfd 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 1a9ffb0d33d..49e36bdacbe 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 a04f3c74a8e..3433b96e8d7 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 bfa76bb82a8..3b6d195b76d 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 385c4c46c75..dd3b4dfbedb 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 ae8ef3d3587..12c19b0fb80 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 bc9721d96d6..607deffe1ca 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 d0fd17e2ce7..aa8ec89ba77 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 9f68ca5358722e25bcfd3f53478c93b81347cc6c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 Aug 2024 11:49:02 +0100 Subject: [PATCH 100/800] Dependancies: Updated PHP and JS packages --- composer.lock | 457 +++++++++++++++++++++++----------------------- package-lock.json | 323 ++++++++++++++++---------------- package.json | 2 +- 3 files changed, 403 insertions(+), 379 deletions(-) diff --git a/composer.lock b/composer.lock index 0a1ed3e7b0e..c5649d7c8d7 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.316.3", + "version": "3.320.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "e832e594b3c213760e067e15ef2739f77505e832" + "reference": "702b9955160d2dacdf2cdf4d4476fcf95eae1aaf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e832e594b3c213760e067e15ef2739f77505e832", - "reference": "e832e594b3c213760e067e15ef2739f77505e832", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/702b9955160d2dacdf2cdf4d4476fcf95eae1aaf", + "reference": "702b9955160d2dacdf2cdf4d4476fcf95eae1aaf", "shasum": "" }, "require": { @@ -124,7 +124,10 @@ ], "psr-4": { "Aws\\": "src/" - } + }, + "exclude-from-classmap": [ + "src/data/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -151,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.316.3" + "source": "https://github.com/aws/aws-sdk-php/tree/3.320.7" }, - "time": "2024-07-12T18:07:23+00:00" + "time": "2024-08-23T18:13:50+00:00" }, { "name": "bacon/bacon-qr-code", @@ -340,23 +343,23 @@ }, { "name": "dasprid/enum", - "version": "1.0.5", + "version": "1.0.6", "source": { "type": "git", "url": "https://github.com/DASPRiD/Enum.git", - "reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016" + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/6faf451159fb8ba4126b925ed2d78acfce0dc016", - "reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90", "shasum": "" }, "require": { "php": ">=7.1 <9.0" }, "require-dev": { - "phpunit/phpunit": "^7 | ^8 | ^9", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", "squizlabs/php_codesniffer": "*" }, "type": "library", @@ -384,9 +387,9 @@ ], "support": { "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.5" + "source": "https://github.com/DASPRiD/Enum/tree/1.0.6" }, - "time": "2023-08-25T16:18:39+00:00" + "time": "2024-08-09T14:30:48+00:00" }, { "name": "dflydev/dot-access-data", @@ -558,16 +561,16 @@ }, { "name": "doctrine/dbal", - "version": "3.8.6", + "version": "3.9.0", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "b7411825cf7efb7e51f9791dea19d86e43b399a1" + "reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/b7411825cf7efb7e51f9791dea19d86e43b399a1", - "reference": "b7411825cf7efb7e51f9791dea19d86e43b399a1", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/d8f68ea6cc00912e5313237130b8c8decf4d28c6", + "reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6", "shasum": "" }, "require": { @@ -583,12 +586,12 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.11.5", + "phpstan/phpstan": "1.11.7", "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "9.6.19", + "phpunit/phpunit": "9.6.20", "psalm/plugin-phpunit": "0.18.4", "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.10.1", + "squizlabs/php_codesniffer": "3.10.2", "symfony/cache": "^5.4|^6.0|^7.0", "symfony/console": "^4.4|^5.4|^6.0|^7.0", "vimeo/psalm": "4.30.0" @@ -651,7 +654,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.8.6" + "source": "https://github.com/doctrine/dbal/tree/3.9.0" }, "funding": [ { @@ -667,7 +670,7 @@ "type": "tidelift" } ], - "time": "2024-06-19T10:38:17+00:00" + "time": "2024-08-15T07:34:42+00:00" }, { "name": "doctrine/deprecations", @@ -1301,24 +1304,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.2", + "version": "v1.1.3", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "fbd48bce38f73f8a4ec8583362e732e4095e5862" + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/fbd48bce38f73f8a4ec8583362e732e4095e5862", - "reference": "fbd48bce38f73f8a4ec8583362e732e4095e5862", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.2" + "phpoption/phpoption": "^1.9.3" }, "require-dev": { - "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" }, "type": "library", "autoload": { @@ -1347,7 +1350,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.2" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" }, "funding": [ { @@ -1359,26 +1362,26 @@ "type": "tidelift" } ], - "time": "2023-11-12T22:16:48+00:00" + "time": "2024-07-20T21:45:45+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.8.1", + "version": "7.9.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104" + "reference": "d281ed313b989f213357e3be1a179f02196ac99b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.1", - "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -1389,9 +1392,9 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", + "guzzle/client-integration-tests": "3.0.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1469,7 +1472,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.8.1" + "source": "https://github.com/guzzle/guzzle/tree/7.9.2" }, "funding": [ { @@ -1485,20 +1488,20 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:35:24+00:00" + "time": "2024-07-24T11:22:20+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.2", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223" + "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223", + "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", + "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", "shasum": "" }, "require": { @@ -1506,7 +1509,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" + "phpunit/phpunit": "^8.5.39 || ^9.6.20" }, "type": "library", "extra": { @@ -1552,7 +1555,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.2" + "source": "https://github.com/guzzle/promises/tree/2.0.3" }, "funding": [ { @@ -1568,20 +1571,20 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:19:20+00:00" + "time": "2024-07-18T10:29:17+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221" + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221", - "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", "shasum": "" }, "require": { @@ -1596,8 +1599,8 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1668,7 +1671,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.6.2" + "source": "https://github.com/guzzle/psr7/tree/2.7.0" }, "funding": [ { @@ -1684,7 +1687,7 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:05:35+00:00" + "time": "2024-07-18T11:15:46+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1838,16 +1841,16 @@ }, { "name": "intervention/image", - "version": "3.7.2", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c" + "reference": "1786ad5e1789050939d73cd195de4b8eaeeb34ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/5451ff9f909c2fc836722e5ed6831b9f9a6db68c", - "reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c", + "url": "https://api.github.com/repos/Intervention/image/zipball/1786ad5e1789050939d73cd195de4b8eaeeb34ed", + "reference": "1786ad5e1789050939d73cd195de4b8eaeeb34ed", "shasum": "" }, "require": { @@ -1894,7 +1897,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.7.2" + "source": "https://github.com/Intervention/image/tree/3.8.0" }, "funding": [ { @@ -1904,9 +1907,13 @@ { "url": "https://github.com/Intervention", "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" } ], - "time": "2024-07-05T13:35:01+00:00" + "time": "2024-08-16T14:57:26+00:00" }, { "name": "knplabs/knp-snappy", @@ -1977,16 +1984,16 @@ }, { "name": "laravel/framework", - "version": "v10.48.16", + "version": "v10.48.20", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f9a3a50fae399d75e125b0eea637dda90c99eae7" + "reference": "be2be342d4c74db6a8d2bd18469cd6d488ab9c98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f9a3a50fae399d75e125b0eea637dda90c99eae7", - "reference": "f9a3a50fae399d75e125b0eea637dda90c99eae7", + "url": "https://api.github.com/repos/laravel/framework/zipball/be2be342d4c74db6a8d2bd18469cd6d488ab9c98", + "reference": "be2be342d4c74db6a8d2bd18469cd6d488ab9c98", "shasum": "" }, "require": { @@ -2180,20 +2187,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-07-09T15:25:22+00:00" + "time": "2024-08-09T07:55:45+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.24", + "version": "v0.1.25", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "409b0b4305273472f3754826e68f4edbd0150149" + "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/409b0b4305273472f3754826e68f4edbd0150149", - "reference": "409b0b4305273472f3754826e68f4edbd0150149", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", + "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", "shasum": "" }, "require": { @@ -2236,32 +2243,33 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.24" + "source": "https://github.com/laravel/prompts/tree/v0.1.25" }, - "time": "2024-06-17T13:58:22+00:00" + "time": "2024-08-12T22:06:33+00:00" }, { "name": "laravel/serializable-closure", - "version": "v1.3.3", + "version": "v1.3.4", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "3dbf8a8e914634c48d389c1234552666b3d43754" + "reference": "61b87392d986dc49ad5ef64e75b1ff5fee24ef81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/3dbf8a8e914634c48d389c1234552666b3d43754", - "reference": "3dbf8a8e914634c48d389c1234552666b3d43754", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/61b87392d986dc49ad5ef64e75b1ff5fee24ef81", + "reference": "61b87392d986dc49ad5ef64e75b1ff5fee24ef81", "shasum": "" }, "require": { "php": "^7.3|^8.0" }, "require-dev": { - "nesbot/carbon": "^2.61", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0", + "nesbot/carbon": "^2.61|^3.0", "pestphp/pest": "^1.21.3", "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11" + "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" }, "type": "library", "extra": { @@ -2298,7 +2306,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2023-11-08T14:08:06+00:00" + "time": "2024-08-02T07:48:17+00:00" }, { "name": "laravel/socialite", @@ -2440,16 +2448,16 @@ }, { "name": "league/commonmark", - "version": "2.4.2", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "91c24291965bd6d7c46c46a12ba7492f83b1cadf" + "reference": "b650144166dfa7703e62a22e493b853b58d874b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/91c24291965bd6d7c46c46a12ba7492f83b1cadf", - "reference": "91c24291965bd6d7c46c46a12ba7492f83b1cadf", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/b650144166dfa7703e62a22e493b853b58d874b0", + "reference": "b650144166dfa7703e62a22e493b853b58d874b0", "shasum": "" }, "require": { @@ -2462,8 +2470,8 @@ }, "require-dev": { "cebe/markdown": "^1.0", - "commonmark/cmark": "0.30.3", - "commonmark/commonmark.js": "0.30.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", "composer/package-versions-deprecated": "^1.8", "embed/embed": "^4.4", "erusev/parsedown": "^1.0", @@ -2485,7 +2493,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "2.6-dev" } }, "autoload": { @@ -2542,7 +2550,7 @@ "type": "tidelift" } ], - "time": "2024-02-02T11:59:32+00:00" + "time": "2024-08-16T11:46:16+00:00" }, { "name": "league/config", @@ -3509,20 +3517,20 @@ }, { "name": "nette/utils", - "version": "v4.0.4", + "version": "v4.0.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "d3ad0aa3b9f934602cb3e3902ebccf10be34d218" + "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/d3ad0aa3b9f934602cb3e3902ebccf10be34d218", - "reference": "d3ad0aa3b9f934602cb3e3902ebccf10be34d218", + "url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", + "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", "shasum": "" }, "require": { - "php": ">=8.0 <8.4" + "php": "8.0 - 8.4" }, "conflict": { "nette/finder": "<3", @@ -3589,9 +3597,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.4" + "source": "https://github.com/nette/utils/tree/v4.0.5" }, - "time": "2024-01-17T16:50:36+00:00" + "time": "2024-08-07T15:39:19+00:00" }, { "name": "nikic/php-parser", @@ -4010,16 +4018,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.2", + "version": "1.9.3", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "80735db690fe4fc5c76dfa7f9b770634285fa820" + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/80735db690fe4fc5c76dfa7f9b770634285fa820", - "reference": "80735db690fe4fc5c76dfa7f9b770634285fa820", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", "shasum": "" }, "require": { @@ -4027,13 +4035,13 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" }, "type": "library", "extra": { "bamarni-bin": { "bin-links": true, - "forward-command": true + "forward-command": false }, "branch-alias": { "dev-master": "1.9-dev" @@ -4069,7 +4077,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.2" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" }, "funding": [ { @@ -4081,20 +4089,20 @@ "type": "tidelift" } ], - "time": "2023-11-12T21:59:55+00:00" + "time": "2024-07-20T21:41:07+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.39", + "version": "3.0.41", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "211ebc399c6e73c225a018435fe5ae209d1d1485" + "reference": "621c73f7dcb310b61de34d1da4c4204e8ace6ceb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/211ebc399c6e73c225a018435fe5ae209d1d1485", - "reference": "211ebc399c6e73c225a018435fe5ae209d1d1485", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/621c73f7dcb310b61de34d1da4c4204e8ace6ceb", + "reference": "621c73f7dcb310b61de34d1da4c4204e8ace6ceb", "shasum": "" }, "require": { @@ -4175,7 +4183,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.39" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.41" }, "funding": [ { @@ -4191,7 +4199,7 @@ "type": "tidelift" } ], - "time": "2024-06-24T06:27:33+00:00" + "time": "2024-08-12T00:13:54+00:00" }, { "name": "pragmarx/google2fa", @@ -4668,16 +4676,16 @@ }, { "name": "psr/log", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + "reference": "79dff0b268932c640297f5208d6298f71855c03e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", + "reference": "79dff0b268932c640297f5208d6298f71855c03e", "shasum": "" }, "require": { @@ -4712,9 +4720,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.1" }, - "time": "2021-07-14T16:46:02+00:00" + "time": "2024-08-21T13:31:24+00:00" }, { "name": "psr/simple-cache", @@ -5623,16 +5631,16 @@ }, { "name": "symfony/console", - "version": "v6.4.9", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6edb5363ec0c78ad4d48c5128ebf4d083d89d3a9" + "reference": "504974cbe43d05f83b201d6498c206f16fc0cdbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6edb5363ec0c78ad4d48c5128ebf4d083d89d3a9", - "reference": "6edb5363ec0c78ad4d48c5128ebf4d083d89d3a9", + "url": "https://api.github.com/repos/symfony/console/zipball/504974cbe43d05f83b201d6498c206f16fc0cdbc", + "reference": "504974cbe43d05f83b201d6498c206f16fc0cdbc", "shasum": "" }, "require": { @@ -5697,7 +5705,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.9" + "source": "https://github.com/symfony/console/tree/v6.4.10" }, "funding": [ { @@ -5713,7 +5721,7 @@ "type": "tidelift" } ], - "time": "2024-06-28T09:49:33+00:00" + "time": "2024-07-26T12:30:32+00:00" }, { "name": "symfony/css-selector", @@ -5849,16 +5857,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.9", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "c9b7cc075b3ab484239855622ca05cb0b99c13ec" + "reference": "231f1b2ee80f72daa1972f7340297d67439224f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/c9b7cc075b3ab484239855622ca05cb0b99c13ec", - "reference": "c9b7cc075b3ab484239855622ca05cb0b99c13ec", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/231f1b2ee80f72daa1972f7340297d67439224f0", + "reference": "231f1b2ee80f72daa1972f7340297d67439224f0", "shasum": "" }, "require": { @@ -5904,7 +5912,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.9" + "source": "https://github.com/symfony/error-handler/tree/v6.4.10" }, "funding": [ { @@ -5920,7 +5928,7 @@ "type": "tidelift" } ], - "time": "2024-06-21T16:04:15+00:00" + "time": "2024-07-26T12:30:32+00:00" }, { "name": "symfony/event-dispatcher", @@ -6080,16 +6088,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.8", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "3ef977a43883215d560a2cecb82ec8e62131471c" + "reference": "af29198d87112bebdd397bd7735fbd115997824c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/3ef977a43883215d560a2cecb82ec8e62131471c", - "reference": "3ef977a43883215d560a2cecb82ec8e62131471c", + "url": "https://api.github.com/repos/symfony/finder/zipball/af29198d87112bebdd397bd7735fbd115997824c", + "reference": "af29198d87112bebdd397bd7735fbd115997824c", "shasum": "" }, "require": { @@ -6124,7 +6132,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.8" + "source": "https://github.com/symfony/finder/tree/v6.4.10" }, "funding": [ { @@ -6140,20 +6148,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-07-24T07:06:38+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.8", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "27de8cc95e11db7a50b027e71caaab9024545947" + "reference": "117f1f20a7ade7bcea28b861fb79160a21a1e37b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/27de8cc95e11db7a50b027e71caaab9024545947", - "reference": "27de8cc95e11db7a50b027e71caaab9024545947", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/117f1f20a7ade7bcea28b861fb79160a21a1e37b", + "reference": "117f1f20a7ade7bcea28b861fb79160a21a1e37b", "shasum": "" }, "require": { @@ -6201,7 +6209,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.8" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.10" }, "funding": [ { @@ -6217,20 +6225,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-07-26T12:36:27+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.9", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "cc4a9bec6e1bdd2405f40277a68a6ed1bb393005" + "reference": "147e0daf618d7575b5007055340d09aece5cf068" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cc4a9bec6e1bdd2405f40277a68a6ed1bb393005", - "reference": "cc4a9bec6e1bdd2405f40277a68a6ed1bb393005", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/147e0daf618d7575b5007055340d09aece5cf068", + "reference": "147e0daf618d7575b5007055340d09aece5cf068", "shasum": "" }, "require": { @@ -6315,7 +6323,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.9" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.10" }, "funding": [ { @@ -6331,7 +6339,7 @@ "type": "tidelift" } ], - "time": "2024-06-28T11:48:06+00:00" + "time": "2024-07-26T14:52:04+00:00" }, { "name": "symfony/mime", @@ -7191,16 +7199,16 @@ }, { "name": "symfony/routing", - "version": "v6.4.8", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8a40d0f9b01f0fbb80885d3ce0ad6714fb603a58" + "reference": "aad19fe10753ba842f0d653a8db819c4b3affa87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8a40d0f9b01f0fbb80885d3ce0ad6714fb603a58", - "reference": "8a40d0f9b01f0fbb80885d3ce0ad6714fb603a58", + "url": "https://api.github.com/repos/symfony/routing/zipball/aad19fe10753ba842f0d653a8db819c4b3affa87", + "reference": "aad19fe10753ba842f0d653a8db819c4b3affa87", "shasum": "" }, "require": { @@ -7254,7 +7262,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.8" + "source": "https://github.com/symfony/routing/tree/v6.4.10" }, "funding": [ { @@ -7270,7 +7278,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-07-15T09:26:24+00:00" }, { "name": "symfony/service-contracts", @@ -7357,16 +7365,16 @@ }, { "name": "symfony/string", - "version": "v6.4.9", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "76792dbd99690a5ebef8050d9206c60c59e681d7" + "reference": "ccf9b30251719567bfd46494138327522b9a9446" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/76792dbd99690a5ebef8050d9206c60c59e681d7", - "reference": "76792dbd99690a5ebef8050d9206c60c59e681d7", + "url": "https://api.github.com/repos/symfony/string/zipball/ccf9b30251719567bfd46494138327522b9a9446", + "reference": "ccf9b30251719567bfd46494138327522b9a9446", "shasum": "" }, "require": { @@ -7423,7 +7431,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.9" + "source": "https://github.com/symfony/string/tree/v6.4.10" }, "funding": [ { @@ -7439,20 +7447,20 @@ "type": "tidelift" } ], - "time": "2024-06-28T09:25:38+00:00" + "time": "2024-07-22T10:21:14+00:00" }, { "name": "symfony/translation", - "version": "v6.4.8", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "a002933b13989fc4bd0b58e04bf7eec5210e438a" + "reference": "94041203f8ac200ae9e7c6a18fa6137814ccecc9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/a002933b13989fc4bd0b58e04bf7eec5210e438a", - "reference": "a002933b13989fc4bd0b58e04bf7eec5210e438a", + "url": "https://api.github.com/repos/symfony/translation/zipball/94041203f8ac200ae9e7c6a18fa6137814ccecc9", + "reference": "94041203f8ac200ae9e7c6a18fa6137814ccecc9", "shasum": "" }, "require": { @@ -7518,7 +7526,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.8" + "source": "https://github.com/symfony/translation/tree/v6.4.10" }, "funding": [ { @@ -7534,7 +7542,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-07-26T12:30:32+00:00" }, { "name": "symfony/translation-contracts", @@ -7690,16 +7698,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.9", + "version": "v6.4.10", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "c31566e4ca944271cc8d8ac6887cbf31b8c6a172" + "reference": "a71cc3374f5fb9759da1961d28c452373b343dd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c31566e4ca944271cc8d8ac6887cbf31b8c6a172", - "reference": "c31566e4ca944271cc8d8ac6887cbf31b8c6a172", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/a71cc3374f5fb9759da1961d28c452373b343dd4", + "reference": "a71cc3374f5fb9759da1961d28c452373b343dd4", "shasum": "" }, "require": { @@ -7755,7 +7763,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.9" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.10" }, "funding": [ { @@ -7771,7 +7779,7 @@ "type": "tidelift" } ], - "time": "2024-06-27T13:23:14+00:00" + "time": "2024-07-26T12:30:32+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7828,23 +7836,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.0", + "version": "v5.6.1", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "2cf9fb6054c2bb1d59d1f3817706ecdb9d2934c4" + "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/2cf9fb6054c2bb1d59d1f3817706ecdb9d2934c4", - "reference": "2cf9fb6054c2bb1d59d1f3817706ecdb9d2934c4", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.2", + "graham-campbell/result-type": "^1.1.3", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.2", + "phpoption/phpoption": "^1.9.3", "symfony/polyfill-ctype": "^1.24", "symfony/polyfill-mbstring": "^1.24", "symfony/polyfill-php80": "^1.24" @@ -7861,7 +7869,7 @@ "extra": { "bamarni-bin": { "bin-links": true, - "forward-command": true + "forward-command": false }, "branch-alias": { "dev-master": "5.6-dev" @@ -7896,7 +7904,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.0" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" }, "funding": [ { @@ -7908,7 +7916,7 @@ "type": "tidelift" } ], - "time": "2023-11-12T22:43:29+00:00" + "time": "2024-07-20T21:52:34+00:00" }, { "name": "voku/portable-ascii", @@ -8765,16 +8773,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.9.0", + "version": "5.9.1", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "011fa18a4e55591fac6545a821921dd1d61c6984" + "reference": "169a9f11f1957ea36607c9b29eac1b48679f1ecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/011fa18a4e55591fac6545a821921dd1d61c6984", - "reference": "011fa18a4e55591fac6545a821921dd1d61c6984", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/169a9f11f1957ea36607c9b29eac1b48679f1ecc", + "reference": "169a9f11f1957ea36607c9b29eac1b48679f1ecc", "shasum": "" }, "require": { @@ -8792,8 +8800,7 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.9.12", "phpstan/phpstan-phpunit": "^1.3.3", - "phpunit/php-code-coverage": "*", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^8.5 || ^9.6", "psalm/plugin-phpunit": "^0.16.1", "vimeo/psalm": "^4.11", "zumba/json-serializer": "~3.0.2" @@ -8849,20 +8856,20 @@ "type": "other" } ], - "time": "2024-01-20T20:34:02+00:00" + "time": "2024-08-13T19:01:01+00:00" }, { "name": "phpstan/phpstan", - "version": "1.11.7", + "version": "1.11.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "52d2bbfdcae7f895915629e4694e9497d0f8e28d" + "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/52d2bbfdcae7f895915629e4694e9497d0f8e28d", - "reference": "52d2bbfdcae7f895915629e4694e9497d0f8e28d", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3", + "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3", "shasum": "" }, "require": { @@ -8907,36 +8914,36 @@ "type": "github" } ], - "time": "2024-07-06T11:17:41+00:00" + "time": "2024-08-19T14:37:29+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.15", + "version": "10.1.16", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae" + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", - "reference": "5da8b1728acd1e6ffdf2ff32ffbdfd04307f26ae", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.18 || ^5.0", + "nikic/php-parser": "^4.19.1 || ^5.1.0", "php": ">=8.1", - "phpunit/php-file-iterator": "^4.0", - "phpunit/php-text-template": "^3.0", - "sebastian/code-unit-reverse-lookup": "^3.0", - "sebastian/complexity": "^3.0", - "sebastian/environment": "^6.0", - "sebastian/lines-of-code": "^2.0", - "sebastian/version": "^4.0", - "theseer/tokenizer": "^1.2.0" + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { "phpunit/phpunit": "^10.1" @@ -8948,7 +8955,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1-dev" + "dev-main": "10.1.x-dev" } }, "autoload": { @@ -8977,7 +8984,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.15" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" }, "funding": [ { @@ -8985,7 +8992,7 @@ "type": "github" } ], - "time": "2024-06-29T08:25:15+00:00" + "time": "2024-08-22T04:31:57+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9232,16 +9239,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.27", + "version": "10.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2425f713b2a5350568ccb1a2d3984841a23e83c5" + "reference": "b15524febac0153876b4ba9aab3326c2ee94c897" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2425f713b2a5350568ccb1a2d3984841a23e83c5", - "reference": "2425f713b2a5350568ccb1a2d3984841a23e83c5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b15524febac0153876b4ba9aab3326c2ee94c897", + "reference": "b15524febac0153876b4ba9aab3326c2ee94c897", "shasum": "" }, "require": { @@ -9262,7 +9269,7 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.1", + "sebastian/comparator": "^5.0.2", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", "sebastian/exporter": "^5.1.2", @@ -9313,7 +9320,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.27" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.30" }, "funding": [ { @@ -9329,7 +9336,7 @@ "type": "tidelift" } ], - "time": "2024-07-10T11:48:06+00:00" + "time": "2024-08-13T06:09:37+00:00" }, { "name": "sebastian/cli-parser", @@ -9501,16 +9508,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372" + "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2db5010a484d53ebf536087a70b4a5423c102372", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", + "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", "shasum": "" }, "require": { @@ -9521,7 +9528,7 @@ "sebastian/exporter": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.3" + "phpunit/phpunit": "^10.4" }, "type": "library", "extra": { @@ -9566,7 +9573,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.2" }, "funding": [ { @@ -9574,7 +9581,7 @@ "type": "github" } ], - "time": "2023-08-14T13:18:12+00:00" + "time": "2024-08-12T06:03:08+00:00" }, { "name": "sebastian/complexity", @@ -10249,16 +10256,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.1", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "8f90f7a53ce271935282967f53d0894f8f1ff877" + "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/8f90f7a53ce271935282967f53d0894f8f1ff877", - "reference": "8f90f7a53ce271935282967f53d0894f8f1ff877", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", "shasum": "" }, "require": { @@ -10325,7 +10332,7 @@ "type": "open_collective" } ], - "time": "2024-05-22T21:24:41+00:00" + "time": "2024-07-21T23:26:44+00:00" }, { "name": "ssddanbrown/asserthtml", diff --git a/package-lock.json b/package-lock.json index a117f88a636..0f007659aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "devDependencies": { "@lezer/generator": "^1.5.1", "chokidar-cli": "^3.0", - "esbuild": "^0.20", + "esbuild": "^0.23.0", "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", @@ -41,9 +41,9 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz", - "integrity": "sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==", + "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", @@ -172,9 +172,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" } @@ -216,9 +216,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.28.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.4.tgz", - "integrity": "sha512-QScv95fiviSQ/CaVGflxAvvvDy/9wi0RFyDl4LkHHWiMr/UPebyuTspmYSeN5Nx6eujcPYwsQzA6ZIZucKZVHQ==", + "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", @@ -226,9 +226,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", "cpu": [ "ppc64" ], @@ -238,13 +238,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", "cpu": [ "arm" ], @@ -254,13 +254,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", "cpu": [ "arm64" ], @@ -270,13 +270,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", "cpu": [ "x64" ], @@ -286,13 +286,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", "cpu": [ "arm64" ], @@ -302,13 +302,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", "cpu": [ "x64" ], @@ -318,13 +318,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", "cpu": [ "arm64" ], @@ -334,13 +334,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", "cpu": [ "x64" ], @@ -350,13 +350,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", "cpu": [ "arm" ], @@ -366,13 +366,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", "cpu": [ "arm64" ], @@ -382,13 +382,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", "cpu": [ "ia32" ], @@ -398,13 +398,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", "cpu": [ "loong64" ], @@ -414,13 +414,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", "cpu": [ "mips64el" ], @@ -430,13 +430,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", "cpu": [ "ppc64" ], @@ -446,13 +446,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", "cpu": [ "riscv64" ], @@ -462,13 +462,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", "cpu": [ "s390x" ], @@ -478,13 +478,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", "cpu": [ "x64" ], @@ -494,13 +494,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", "cpu": [ "x64" ], @@ -510,13 +510,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", "cpu": [ "x64" ], @@ -526,13 +542,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", "cpu": [ "x64" ], @@ -542,13 +558,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", "cpu": [ "arm64" ], @@ -558,13 +574,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", "cpu": [ "ia32" ], @@ -574,13 +590,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", "cpu": [ "x64" ], @@ -590,7 +606,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -713,9 +729,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" } @@ -751,9 +767,9 @@ } }, "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" } @@ -1339,9 +1355,9 @@ } }, "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.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -1576,41 +1592,42 @@ } }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" } }, "node_modules/escape-string-regexp": { @@ -1720,9 +1737,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.8.2", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.2.tgz", + "integrity": "sha512-3XnC5fDyc8M4J2E8pt8pmSVRX2M+5yWMCfI/kDZwauQeFgzQOuhcRBFKjTeJagqgk4sFKxe1mvNVnaWwImx/Tg==", "dev": true, "dependencies": { "debug": "^3.2.7" @@ -2274,18 +2291,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": { @@ -2419,9 +2436,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" @@ -3716,9 +3733,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": { diff --git a/package.json b/package.json index ba2de38ba3c..aa826b416ea 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@lezer/generator": "^1.5.1", "chokidar-cli": "^3.0", - "esbuild": "^0.20", + "esbuild": "^0.23.0", "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.0", From 1f2506221ad7207b37bd093ca3233347d6d76ca1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 27 Aug 2024 12:23:36 +0100 Subject: [PATCH 101/800] API: Updated docs with consistent types, fixed users response example For #5178 and #5183 --- app/Uploads/Controllers/AttachmentApiController.php | 8 ++++---- app/Users/Controllers/UserApiController.php | 13 +++++++------ dev/api/responses/users-list.json | 2 -- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index 9040ba6d3a9..87e00257cb4 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -171,16 +171,16 @@ protected function rules(): array { return [ 'create' => [ - 'name' => ['required', 'min:1', 'max:255', 'string'], + 'name' => ['required', 'string', 'min:1', 'max:255'], 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], 'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()), - 'link' => ['required_without:file', 'min:1', 'max:2000', 'safe_url'], + 'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'], ], 'update' => [ - 'name' => ['min:1', 'max:255', 'string'], + 'name' => ['string', 'min:1', 'max:255'], 'uploaded_to' => ['integer', 'exists:pages,id'], 'file' => $this->attachmentService->getFileValidationRules(), - 'link' => ['min:1', 'max:2000', 'safe_url'], + 'link' => ['string', 'min:1', 'max:2000', 'safe_url'], ], ]; } diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index 1ccfecd7335..6ee8f16d15e 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -37,27 +37,28 @@ protected function rules(int $userId = null): array { return [ 'create' => [ - 'name' => ['required', 'min:2', 'max:100'], + 'name' => ['required', 'string', 'min:2', 'max:100'], 'email' => [ - 'required', 'min:2', 'email', new Unique('users', 'email'), + 'required', 'string', 'email', 'min:2', new Unique('users', 'email'), ], 'external_auth_id' => ['string'], 'language' => ['string', 'max:15', 'alpha_dash'], - 'password' => [Password::default()], + 'password' => ['string', Password::default()], 'roles' => ['array'], 'roles.*' => ['integer'], 'send_invite' => ['boolean'], ], 'update' => [ - 'name' => ['min:2', 'max:100'], + 'name' => ['string', 'min:2', 'max:100'], 'email' => [ - 'min:2', + 'string', 'email', + 'min:2', (new Unique('users', 'email'))->ignore($userId ?? null), ], 'external_auth_id' => ['string'], 'language' => ['string', 'max:15', 'alpha_dash'], - 'password' => [Password::default()], + 'password' => ['string', Password::default()], 'roles' => ['array'], 'roles.*' => ['integer'], ], diff --git a/dev/api/responses/users-list.json b/dev/api/responses/users-list.json index cbc7fb1043e..9cc2517b7ff 100644 --- a/dev/api/responses/users-list.json +++ b/dev/api/responses/users-list.json @@ -8,7 +8,6 @@ "updated_at": "2022-02-03T16:27:55.000000Z", "external_auth_id": "abc123456", "slug": "dan-brown", - "user_id": 1, "last_activity_at": "2022-02-03T16:27:55.000000Z", "profile_url": "https://docs.example.com/user/dan-brown", "edit_url": "https://docs.example.com/settings/users/1", @@ -22,7 +21,6 @@ "updated_at": "2021-11-18T17:10:58.000000Z", "external_auth_id": "", "slug": "benny", - "user_id": 2, "last_activity_at": "2022-01-31T20:39:24.000000Z", "profile_url": "https://docs.example.com/user/benny", "edit_url": "https://docs.example.com/settings/users/2", From 90d1223acd7e37f4cb5d87284e00877473aa43a6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 27 Aug 2024 13:32:16 +0100 Subject: [PATCH 102/800] Styles: Added max-width for iframes in content For #5130 --- resources/sass/_content.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index be5a6517e0b..84fd4eae635 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -106,7 +106,7 @@ margin-bottom: 1.375em; } - video { + video, iframe { max-width: 100%; } From 8cef998f4977d23ba87f10e13260f8c7537e53a9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 27 Aug 2024 14:13:33 +0100 Subject: [PATCH 103/800] RTL: Fixed lacking task list RTL support Added with fallback to old LTR styles. For #5134 --- resources/sass/_text.scss | 11 +++++++++++ resources/sass/_tinymce.scss | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 9f367984ad5..9acfe2ceed3 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -309,15 +309,26 @@ li > ol, li > ul { padding-right: $-m * 1.2; } +/** + * Checkbox lists + * Some styles duplicated for supporting logical units (eg. inline-end) while + * providing fallbacks to non-logical rules, so RTL is natively supported where possible. + */ li.checkbox-item, li.task-list-item { display: list-item; list-style: none; margin-left: -($-m * 1.2); + margin-inline-start: -($-m * 1.2); + margin-inline-end: 0; input[type="checkbox"] { margin-right: $-xs; + margin-inline-end: $-xs; + margin-inline-start: 0; } li.checkbox-item, li.task-list-item { margin-left: $-xs; + margin-inline-start: $-xs; + margin-inline-end: 0; } } diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 95294cdf2c0..129e478212b 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -173,7 +173,7 @@ body.page-content.mce-content-body { * Fake task list checkboxes */ .page-content.mce-content-body .task-list-item { - margin-left: 0; + margin-inline-start: 0; position: relative; } .page-content.mce-content-body .task-list-item > input[type="checkbox"] { @@ -186,11 +186,11 @@ body.page-content.mce-content-body { width: 12px; height: 12px; border-radius: 2px; - margin-right: 8px; + margin-inline-end: 8px; vertical-align: text-top; cursor: pointer; position: absolute; - left: -24px; + inset-inline-start: -24px; top: 4px; } From 1b4ed69f41955d27794718cd74b3f56fba9be63f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 28 Aug 2024 15:39:05 +0100 Subject: [PATCH 104/800] LDAP: Updated recursive group search to query by DN Added test to cover, added pre-change. Need to test post-changes and fix tests. --- app/Access/Ldap.php | 14 ++++++- app/Access/LdapService.php | 79 ++++++++++++++++++++++---------------- tests/Auth/LdapTest.php | 38 ++++++++++++++++++ 3 files changed, 96 insertions(+), 35 deletions(-) diff --git a/app/Access/Ldap.php b/app/Access/Ldap.php index 12a3d1e7122..702d629cef0 100644 --- a/app/Access/Ldap.php +++ b/app/Access/Ldap.php @@ -52,13 +52,25 @@ public function setVersion($ldapConnection, int $version): bool * * @param resource|\LDAP\Connection $ldapConnection * - * @return resource|\LDAP\Result + * @return \LDAP\Result|array|false */ public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null) { return ldap_search($ldapConnection, $baseDn, $filter, $attributes); } + /** + * Read an entry from the LDAP tree. + * + * @param resource|\Ldap\Connection $ldapConnection + * + * @return \LDAP\Result|array|false + */ + public function read($ldapConnection, string $baseDn, string $filter, array $attributes = null) + { + return ldap_read($ldapConnection, $baseDn, $filter, $attributes); + } + /** * Get entries from an LDAP search result. * diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index e822b09a67c..7c8d8b18f97 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -321,94 +321,105 @@ public function getUserGroups(string $userName): array return []; } - $userGroups = $this->groupFilter($user); + $userGroups = $this->extractGroupsFromSearchResponseEntry($user); $allGroups = $this->getGroupsRecursive($userGroups, []); + $formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups); if ($this->config['dump_user_groups']) { throw new JsonDebugException([ - 'details_from_ldap' => $user, - 'parsed_direct_user_groups' => $userGroups, - 'parsed_recursive_user_groups' => $allGroups, + 'details_from_ldap' => $user, + 'parsed_direct_user_groups' => $userGroups, + 'parsed_recursive_user_groups' => $allGroups, + 'parsed_resulting_group_names' => $formattedGroups, ]); } return $allGroups; } + protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array + { + $names = []; + + foreach ($groupDNs as $groupDN) { + $exploded = $this->ldap->explodeDn($groupDN, 1); + if ($exploded !== false && count($exploded) > 0) { + $names[] = $exploded[0]; + } + } + + return array_unique($names); + } + /** - * Get the parent groups of an array of groups. + * Build an array of all relevant groups DNs after recursively scanning + * across parents of the groups given. * * @throws LdapException */ - private function getGroupsRecursive(array $groupsArray, array $checked): array + protected function getGroupsRecursive(array $groupDNs, array $checked): array { $groupsToAdd = []; - foreach ($groupsArray as $groupName) { - if (in_array($groupName, $checked)) { + foreach ($groupDNs as $groupDN) { + if (in_array($groupDN, $checked)) { continue; } - $parentGroups = $this->getGroupGroups($groupName); + $parentGroups = $this->getParentsOfGroup($groupDN); $groupsToAdd = array_merge($groupsToAdd, $parentGroups); - $checked[] = $groupName; + $checked[] = $groupDN; } - $groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR); + $uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR); if (empty($groupsToAdd)) { - return $groupsArray; + return $uniqueDNs; } - return $this->getGroupsRecursive($groupsArray, $checked); + return $this->getGroupsRecursive($uniqueDNs, $checked); } /** - * Get the parent groups of a single group. - * * @throws LdapException */ - private function getGroupGroups(string $groupName): array + protected function getParentsOfGroup(string $groupDN): array { + $groupsAttr = strtolower($this->config['group_attribute']); $ldapConnection = $this->getConnection(); $this->bindSystemUser($ldapConnection); $followReferrals = $this->config['follow_referrals'] ? 1 : 0; $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals); - - $baseDn = $this->config['base_dn']; - $groupsAttr = strtolower($this->config['group_attribute']); - - $groupFilter = 'CN=' . $this->ldap->escape($groupName); - $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]); - if ($groups['count'] === 0) { + $read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]); + $results = $this->ldap->getEntries($ldapConnection, $read); + if ($results['count'] === 0) { return []; } - return $this->groupFilter($groups[0]); + return $this->extractGroupsFromSearchResponseEntry($results[0]); } /** - * Filter out LDAP CN and DN language in a ldap search return. - * Gets the base CN (common name) of the string. + * Extract an array of group DN values from the given LDAP search response entry */ - protected function groupFilter(array $userGroupSearchResponse): array + protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array { $groupsAttr = strtolower($this->config['group_attribute']); - $ldapGroups = []; + $groupDNs = []; $count = 0; - if (isset($userGroupSearchResponse[$groupsAttr]['count'])) { - $count = (int) $userGroupSearchResponse[$groupsAttr]['count']; + if (isset($ldapEntry[$groupsAttr]['count'])) { + $count = (int) $ldapEntry[$groupsAttr]['count']; } for ($i = 0; $i < $count; $i++) { - $dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1); - if (!in_array($dnComponents[0], $ldapGroups)) { - $ldapGroups[] = $dnComponents[0]; + $dn = $ldapEntry[$groupsAttr][$i]; + if (!in_array($dn, $groupDNs)) { + $groupDNs[] = $dn; } } - return $ldapGroups; + return $groupDNs; } /** diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 3f80f00f41e..7c2510eb14c 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -435,6 +435,44 @@ public function test_dump_user_groups_shows_group_related_details_as_json() ]); } + public function test_recursive_group_search_queries_via_full_dn() + { + app('config')->set([ + 'services.ldap.user_to_groups' => true, + 'services.ldap.group_attribute' => 'memberOf', + ]); + + $userResp = ['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => 'dc=test,' . config('services.ldap.base_dn'), + 'mail' => [$this->mockUser->email], + ]]; + $groupResp = ['count' => 1, + 0 => [ + 'dn' => 'dc=test,' . config('services.ldap.base_dn'), + 'memberof' => [ + 'count' => 1, + 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com', + ], + ], + ]; + + $this->commonLdapMocks(1, 1, 3, 4, 3, 1); + + $escapedName = ldap_escape($this->mockUser->name); + $this->mockLdap->shouldReceive('searchAndGetEntries')->twice() + ->with($this->resourceId, config('services.ldap.base_dn'), "(&(uid={$escapedName}))", \Mockery::type('array')) + ->andReturn($userResp, $groupResp); + + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), $groupResp[0]['dn'], ['memberof']) + ->andReturn(['count' => 0]); + + $resp = $this->mockUserLogin(); + $resp->assertRedirect('/'); + } + public function test_external_auth_id_visible_in_roles_page_when_ldap_active() { $role = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']); From c68d154f0f6bf647c27305597ac48b23f126c08a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 28 Aug 2024 21:16:18 +0100 Subject: [PATCH 105/800] LDAP: Updated tests for recursive group changes --- app/Access/LdapService.php | 2 +- tests/Auth/LdapTest.php | 66 ++++++++++++++++++++++++-------------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index 7c8d8b18f97..365cb1db015 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -334,7 +334,7 @@ public function getUserGroups(string $userName): array ]); } - return $allGroups; + return $formattedGroups; } protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 7c2510eb14c..ef95bc2e8f4 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -76,7 +76,7 @@ protected function mockUserLogin(?string $email = null): TestResponse /** * Set LDAP method mocks for things we commonly call without altering. */ - protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0) + protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0, int $groups = 0) { $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId); $this->mockLdap->shouldReceive('setVersion')->times($versions); @@ -84,6 +84,13 @@ protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $op $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true); $this->mockEscapes($escapes); $this->mockExplodes($explodes); + $this->mockGroupLookups($groups); + } + + protected function mockGroupLookups(int $times = 1): void + { + $this->mockLdap->shouldReceive('read')->times($times)->andReturn(['count' => 0]); + $this->mockLdap->shouldReceive('getEntries')->times($times)->andReturn(['count' => 0]); } public function test_login() @@ -307,8 +314,8 @@ public function test_login_maps_roles_and_retains_existing_roles() 'services.ldap.remove_from_groups' => false, ]); - $this->commonLdapMocks(1, 1, 4, 5, 4, 6); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -352,8 +359,8 @@ public function test_login_maps_roles_and_removes_old_roles_if_set() 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 3, 4, 3, 2); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) + $this->commonLdapMocks(1, 1, 3, 4, 2, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -394,22 +401,26 @@ public function test_dump_user_groups_shows_group_related_details_as_json() 'dn' => 'dc=test,' . config('services.ldap.base_dn'), 'mail' => [$this->mockUser->email], ]]; - $this->commonLdapMocks(1, 1, 4, 5, 4, 2); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 0); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn($userResp, ['count' => 1, - 0 => [ - 'dn' => 'dc=test,' . config('services.ldap.base_dn'), + 0 => [ + 'dn' => 'dc=test,' . config('services.ldap.base_dn'), 'memberof' => [ 'count' => 1, - 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com', + 0 => 'cn=ldaptester,ou=groups,dc=example,dc=com', ], ], - ], [ + ]); + + $this->mockLdap->shouldReceive('read')->times(2); + $this->mockLdap->shouldReceive('getEntries')->times(2) + ->andReturn([ 'count' => 1, - 0 => [ - 'dn' => 'cn=ldaptester,ou=groups,dc=example,dc=com', - 'memberof' => [ + 0 => [ + 'dn' => 'cn=ldaptester,ou=groups,dc=example,dc=com', + 'memberof' => [ 'count' => 1, 0 => 'cn=monsters,ou=groups,dc=example,dc=com', ], @@ -426,9 +437,13 @@ public function test_dump_user_groups_shows_group_related_details_as_json() ], ], 'parsed_direct_user_groups' => [ - 'ldaptester', + 'cn=ldaptester,ou=groups,dc=example,dc=com', ], 'parsed_recursive_user_groups' => [ + 'cn=ldaptester,ou=groups,dc=example,dc=com', + 'cn=monsters,ou=groups,dc=example,dc=com', + ], + 'parsed_resulting_group_names' => [ 'ldaptester', 'monsters', ], @@ -458,15 +473,18 @@ public function test_recursive_group_search_queries_via_full_dn() ], ]; - $this->commonLdapMocks(1, 1, 3, 4, 3, 1); + $this->commonLdapMocks(1, 1, 3, 4, 2, 1); $escapedName = ldap_escape($this->mockUser->name); $this->mockLdap->shouldReceive('searchAndGetEntries')->twice() ->with($this->resourceId, config('services.ldap.base_dn'), "(&(uid={$escapedName}))", \Mockery::type('array')) ->andReturn($userResp, $groupResp); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) - ->with($this->resourceId, config('services.ldap.base_dn'), $groupResp[0]['dn'], ['memberof']) + $this->mockLdap->shouldReceive('read')->times(1) + ->with($this->resourceId, 'cn=ldaptester,ou=groups,dc=example,dc=com', '(objectClass=*)', ['memberof']) + ->andReturn(['count' => 0]); + $this->mockLdap->shouldReceive('getEntries')->times(1) + ->with($this->resourceId, ['count' => 0]) ->andReturn(['count' => 0]); $resp = $this->mockUserLogin(); @@ -491,8 +509,8 @@ public function test_login_maps_roles_using_external_auth_ids_if_set() 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 3, 4, 3, 2); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) + $this->commonLdapMocks(1, 1, 3, 4, 2, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -532,8 +550,8 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 4, 5, 4, 6); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + $this->commonLdapMocks(1, 1, 4, 5, 2, 2, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], @@ -772,9 +790,9 @@ public function test_login_with_email_confirmation_required_maps_groups_but_show 'services.ldap.remove_from_groups' => true, ]); - $this->commonLdapMocks(1, 1, 6, 8, 6, 4); + $this->commonLdapMocks(1, 1, 6, 8, 4, 2, 2); $this->mockLdap->shouldReceive('searchAndGetEntries') - ->times(6) + ->times(4) ->andReturn(['count' => 1, 0 => [ 'uid' => [$user->name], 'cn' => [$user->name], From 9aa3442a170d486c7ff5d8671ac1b11d3c886af1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 29 Aug 2024 14:43:21 +0100 Subject: [PATCH 106/800] API: Fixed lacking permission enforcement on book contents --- app/Entities/Controllers/BookApiController.php | 5 ++++- tests/Api/BooksApiTest.php | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 15e67a0f723..c1e38e72fe7 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\BookQueries; +use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Http\ApiController; @@ -18,6 +19,7 @@ class BookApiController extends ApiController public function __construct( protected BookRepo $bookRepo, protected BookQueries $queries, + protected PageQueries $pageQueries, ) { } @@ -69,7 +71,8 @@ public function read(string $id) ->withType() ->withField('pages', function (Entity $entity) { if ($entity instanceof Chapter) { - return (new ApiEntityListFormatter($entity->pages->all()))->format(); + $pages = $this->pageQueries->visibleForChapterList($entity->id)->get()->all(); + return (new ApiEntityListFormatter($pages))->format(); } return null; })->format(); diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index b8c2b613387..0de98dc323b 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -149,6 +149,23 @@ public function test_read_endpoint_includes_chapter_and_page_contents() ]); } + public function test_read_endpoint_contents_nested_pages_has_permissions_applied() + { + $this->actingAsApiEditor(); + + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $chapterPage = $chapter->pages()->first(); + $customName = 'MyNonVisiblePageWithinAChapter'; + $chapterPage->name = $customName; + $chapterPage->save(); + + $this->permissions->disableEntityInheritedPermissions($chapterPage); + + $resp = $this->getJson($this->baseEndpoint . "/{$book->id}"); + $resp->assertJsonMissing(['name' => $customName]); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); From aac7d564c8f7d811baf260281e286e2e635600b6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 29 Aug 2024 15:08:27 +0100 Subject: [PATCH 107/800] Updated translations with latest Crowdin changes (#5118) --- lang/ca/activities.php | 122 ++++----- lang/ca/auth.php | 146 +++++------ lang/ca/common.php | 46 ++-- lang/ca/components.php | 36 +-- lang/ca/editor.php | 136 +++++----- lang/ca/entities.php | 470 +++++++++++++++++----------------- lang/ca/errors.php | 146 +++++------ lang/ca/notifications.php | 22 +- lang/ca/passwords.php | 6 +- lang/ca/preferences.php | 56 ++--- lang/ca/settings.php | 314 +++++++++++------------ lang/ca/validation.php | 104 ++++---- lang/cs/activities.php | 14 +- lang/cs/entities.php | 64 ++--- lang/cs/errors.php | 2 +- lang/cs/notifications.php | 20 +- lang/cs/settings.php | 4 +- lang/cy/activities.php | 44 ++-- lang/cy/auth.php | 30 +-- lang/cy/common.php | 50 ++-- lang/cy/editor.php | 2 +- lang/cy/entities.php | 78 +++--- lang/cy/preferences.php | 2 +- lang/cy/settings.php | 474 +++++++++++++++++------------------ lang/cy/validation.php | 106 ++++---- lang/de/entities.php | 2 +- lang/et/auth.php | 2 +- lang/fa/common.php | 2 +- lang/fa/errors.php | 10 +- lang/fa/settings.php | 22 +- lang/fr/entities.php | 6 +- lang/he/activities.php | 14 +- lang/lv/auth.php | 2 +- lang/lv/common.php | 2 +- lang/lv/editor.php | 6 +- lang/lv/entities.php | 20 +- lang/lv/errors.php | 4 +- lang/lv/notifications.php | 6 +- lang/lv/settings.php | 16 +- lang/nb/auth.php | 2 +- lang/nb/errors.php | 4 +- lang/nl/activities.php | 10 +- lang/nl/auth.php | 6 +- lang/nl/errors.php | 2 +- lang/nl/passwords.php | 6 +- lang/ru/auth.php | 4 +- lang/ru/errors.php | 2 +- lang/sr/auth.php | 2 +- lang/sr/entities.php | 96 +++---- lang/sr/errors.php | 6 +- lang/sv/components.php | 4 +- lang/sv/entities.php | 30 +-- lang/sv/preferences.php | 12 +- lang/sv/settings.php | 12 +- lang/zh_TW/auth.php | 2 +- lang/zh_TW/components.php | 6 +- lang/zh_TW/editor.php | 130 +++++----- lang/zh_TW/entities.php | 232 ++++++++--------- lang/zh_TW/errors.php | 18 +- lang/zh_TW/notifications.php | 34 +-- lang/zh_TW/settings.php | 66 ++--- 61 files changed, 1647 insertions(+), 1647 deletions(-) diff --git a/lang/ca/activities.php b/lang/ca/activities.php index a3116ef0d63..c288384826c 100644 --- a/lang/ca/activities.php +++ b/lang/ca/activities.php @@ -7,116 +7,116 @@ // Pages 'page_create' => 'ha creat la pàgina', - 'page_create_notification' => 'Pàgina creada correctament', + 'page_create_notification' => 'S’ha creat la pàgina', 'page_update' => 'ha actualitzat la pàgina', - 'page_update_notification' => 'Pàgina actualitzada correctament', - 'page_delete' => 'ha suprimit una pàgina', - 'page_delete_notification' => 'Pàgina suprimida correctament', + 'page_update_notification' => 'S’ha actualitzat la pàgina', + 'page_delete' => 'ha suprimit la pàgina', + 'page_delete_notification' => 'S’ha suprimit la pàgina', 'page_restore' => 'ha restaurat la pàgina', - 'page_restore_notification' => 'Pàgina restaurada correctament', + 'page_restore_notification' => 'S’ha restaurat la pàgina', 'page_move' => 'ha mogut la pàgina', - 'page_move_notification' => 'Pàgina moguda correctament', + 'page_move_notification' => 'S’ha mogut la pàgina', // Chapters 'chapter_create' => 'ha creat el capítol', - 'chapter_create_notification' => 'Capítol creat correctament', + 'chapter_create_notification' => 'S’ha creat el capítol', 'chapter_update' => 'ha actualitzat el capítol', - 'chapter_update_notification' => 'Capítol actualitzat correctament', - 'chapter_delete' => 'ha suprimit un capítol', - 'chapter_delete_notification' => 'Capítol suprimit correctament', + 'chapter_update_notification' => 'S’ha actualitzat el capítol', + 'chapter_delete' => 'ha suprimit el capítol', + 'chapter_delete_notification' => 'S’ha suprimit el capítol', 'chapter_move' => 'ha mogut el capítol', - 'chapter_move_notification' => 'Capítol mogut correctament', + 'chapter_move_notification' => 'S’ha mogut el capítol', // Books 'book_create' => 'ha creat el llibre', - 'book_create_notification' => 'Llibre creat correctament', - 'book_create_from_chapter' => 'ha convertit un capítol en el llibre', - 'book_create_from_chapter_notification' => 'Capítol convertit en llibre correctament', + 'book_create_notification' => 'S’ha creat el llibre', + 'book_create_from_chapter' => 'ha convertit el capítol a llibre', + 'book_create_from_chapter_notification' => 'S’ha convertit el capítol a llibre', 'book_update' => 'ha actualitzat el llibre', - 'book_update_notification' => 'Llibre actualitzat correctament', - 'book_delete' => 'ha suprimit un llibre', - 'book_delete_notification' => 'Llibre suprimit correctament', + 'book_update_notification' => 'S’ha actualitzat el llibre', + 'book_delete' => 'ha suprimit el llibre', + 'book_delete_notification' => 'S’ha suprimit el llibre', 'book_sort' => 'ha ordenat el llibre', - 'book_sort_notification' => 'Llibre reordenat correctament', + 'book_sort_notification' => 'S’ha tornat a ordenar el llibre', // Bookshelves 'bookshelf_create' => 'ha creat el prestatge', - 'bookshelf_create_notification' => 'Prestatge creat correctament', - 'bookshelf_create_from_book' => 'ha convertit un llibre en el prestatge', - 'bookshelf_create_from_book_notification' => 'Llibre convertit en prestatge correctament', + 'bookshelf_create_notification' => 'S’ha creat el prestatge', + 'bookshelf_create_from_book' => 'ha convertit el llibre a prestatge', + 'bookshelf_create_from_book_notification' => 'S’ha convertit el llibre a prestatge', 'bookshelf_update' => 'ha actualitzat el prestatge', - 'bookshelf_update_notification' => 'Prestatge actualitzat correctament', - 'bookshelf_delete' => 'ha suprimit un prestatge', - 'bookshelf_delete_notification' => 'Prestatge suprimit correctament', + 'bookshelf_update_notification' => 'S’ha actualitzat el prestatge', + 'bookshelf_delete' => 'ha suprimit el prestatge', + 'bookshelf_delete_notification' => 'S’ha suprimit el prestatge', // Revisions 'revision_restore' => 'ha restaurat la revisió', - 'revision_delete' => 'ha suprimit una revisió', - 'revision_delete_notification' => 'Revisió suprimida correctament', + 'revision_delete' => 'ha suprimit la revisió', + 'revision_delete_notification' => 'S’ha suprimit la revisió', // Favourites - 'favourite_add_notification' => 'S’ha afegit «:name» als vostres preferits', - 'favourite_remove_notification' => 'S’ha suprimit «:name» dels vostres preferits', + 'favourite_add_notification' => 'S’ha afegit «:name» als favorits.', + 'favourite_remove_notification' => 'S’ha eliminat «:name» dels favorits.', // Watching - 'watch_update_level_notification' => 'Preferències de seguiment actualitzades correctament', + 'watch_update_level_notification' => 'S’han actualitzat les preferències de seguiment', // Auth - 'auth_login' => 'ha iniciat la sessió', + 'auth_login' => 'ha iniciat sessió', 'auth_register' => 's’ha registrat com a usuari nou', - 'auth_password_reset_request' => 'ha sol·licitat un restabliment de la contrasenya', - 'auth_password_reset_update' => 'ha restablert la contrasenya de l’usuari', - 'mfa_setup_method' => 'ha configurat un mètode d’autenticació de múltiple factor', - 'mfa_setup_method_notification' => 'Mètode d’autenticació de múltiple factor configurat correctament', - 'mfa_remove_method' => 'ha suprimit un mètode d’autenticació de múltiple factor', - 'mfa_remove_method_notification' => 'Mètode d’autenticació de múltiple factor suprimit correctament', + 'auth_password_reset_request' => 'ha sol·licitat la reinicialització de la contrasenya', + 'auth_password_reset_update' => 'ha reinicialitzat la contrasenya', + 'mfa_setup_method' => 'ha configurat un mètode d’autenticació multifactorial', + 'mfa_setup_method_notification' => 'S’ha configurat un mètode d’autenticació multifactorial', + 'mfa_remove_method' => 'ha eliminat un mètode d’autenticació multifactorial', + 'mfa_remove_method_notification' => 'S’ha eliminat un mètode d’autenticació multifactorial', // Settings 'settings_update' => 'ha actualitzat la configuració', - 'settings_update_notification' => 'Configuració actualitzada correctament', + 'settings_update_notification' => 'S’ha actualitzat la configuració', 'maintenance_action_run' => 'ha executat una acció de manteniment', // Webhooks 'webhook_create' => 'ha creat un webhook', - 'webhook_create_notification' => 'Webhook creat correctament', - 'webhook_update' => 'ha actualitzat un webhook', - 'webhook_update_notification' => 'Webhook actualitzat correctament', - 'webhook_delete' => 'ha suprimit un webhook', - 'webhook_delete_notification' => 'Webhook suprimit correctament', + 'webhook_create_notification' => 'S’ha creat el webhook', + 'webhook_update' => 'ha actualitzat el webhook', + 'webhook_update_notification' => 'S’ha actualitzat el webhook', + 'webhook_delete' => 'ha suprimit el webhook', + 'webhook_delete_notification' => 'S’ha suprimit el webhook', // Users 'user_create' => 'ha creat l’usuari', - 'user_create_notification' => 'Usuari creat correctament', + 'user_create_notification' => 'S’ha creat l’usuari', 'user_update' => 'ha actualitzat l’usuari', - 'user_update_notification' => 'Usuari actualitzat correctament', - 'user_delete' => 'ha suprimit un usuari', - 'user_delete_notification' => 'Usuari suprimit correctament', + 'user_update_notification' => 'S’ha actualitzat l’usuari', + 'user_delete' => 'ha suprimit l’usuari', + 'user_delete_notification' => 'S’ha suprimit l’usuari', // API Tokens - 'api_token_create' => 'created API token', - 'api_token_create_notification' => 'Testimoni d’API creat correctament', - 'api_token_update' => 'updated API token', - 'api_token_update_notification' => 'Testimoni d’API actualitzat correctament', - 'api_token_delete' => 'deleted API token', - 'api_token_delete_notification' => 'Testimoni d’API suprimit correctament', + 'api_token_create' => 'ha creat el testimoni API', + 'api_token_create_notification' => 'S’ha creat el testimoni API', + 'api_token_update' => 'ha actualitzat el testimoni API', + 'api_token_update_notification' => 'S’ha actualitzat el testimoni API', + 'api_token_delete' => 'ha suprimit el testimoni API', + 'api_token_delete_notification' => 'S’ha suprimit el testimoni API', // Roles 'role_create' => 'ha creat el rol', - 'role_create_notification' => 'Rol creat correctament', + 'role_create_notification' => 'S’ha creat el rol', 'role_update' => 'ha actualitzat el rol', - 'role_update_notification' => 'Rol actualitzat correctament', - 'role_delete' => 'ha suprimit un rol', - 'role_delete_notification' => 'Rol suprimit correctament', + 'role_update_notification' => 'S’ha actualitzat el rol', + 'role_delete' => 'ha suprimit el rol', + 'role_delete_notification' => 'S’ha suprimit el rol', // Recycle Bin - 'recycle_bin_empty' => 'ha buidat la paperera de reciclatge', - 'recycle_bin_restore' => 'ha restaurat de la paperera de reciclatge', - 'recycle_bin_destroy' => 'ha suprimit de la paperera de reciclatge', + 'recycle_bin_empty' => 'ha buidat la paperera', + 'recycle_bin_restore' => 'ha restaurat de la paperera', + 'recycle_bin_destroy' => 'ha eliminat de la paperera', // Comments 'commented_on' => 'ha comentat a', - 'comment_create' => 'ha afegit el comentari', - 'comment_update' => 'ha actualitzat el comentari', + 'comment_create' => 'ha afegit un comentari', + 'comment_update' => 'ha actualitzat un comentari', 'comment_delete' => 'ha suprimit un comentari', // Other diff --git a/lang/ca/auth.php b/lang/ca/auth.php index c23fa43d474..ba6ede42f0c 100644 --- a/lang/ca/auth.php +++ b/lang/ca/auth.php @@ -6,112 +6,112 @@ */ return [ - 'failed' => 'Les credencials no coincideixen amb les que hi ha emmagatzemades.', - 'throttle' => 'Massa intents d’inici de sessió. Torneu-ho a provar d’aquí a :seconds segons.', + 'failed' => 'Aquestes credencials no existeixen al nostre registre.', + 'throttle' => 'Massa intents d’inici de sessió. Torneu-ho a provar d’aquí :seconds segons.', // Login & Register - 'sign_up' => 'Registra-m’hi', - 'log_in' => 'Inicia la sessió', - 'log_in_with' => 'Inicia la sessió amb :socialDriver', - 'sign_up_with' => 'Registra-m’hi amb :socialDriver', + 'sign_up' => 'Registreu-vos', + 'log_in' => 'Inicia sessió', + 'log_in_with' => 'Inicia sessió amb :socialDriver', + 'sign_up_with' => 'Registreu-vos amb :socialDriver', 'logout' => 'Tanca la sessió', 'name' => 'Nom', 'username' => 'Nom d’usuari', - 'email' => 'Adreça electrònica', + 'email' => 'Correu electrònic', 'password' => 'Contrasenya', 'password_confirm' => 'Confirmeu la contrasenya', - 'password_hint' => 'Cal que tingui un mínim de 8 caràcters', + 'password_hint' => 'Ha de tenir almenys 8 caràcters', 'forgot_password' => 'Heu oblidat la contrasenya?', 'remember_me' => 'Recorda’m', - 'ldap_email_hint' => 'Introduïu una adreça electrònica per a aquest compte.', - 'create_account' => 'Crea el compte', + 'ldap_email_hint' => 'Introduïu un correu electrònic per al compte.', + 'create_account' => 'Creeu un compte', 'already_have_account' => 'Ja teniu un compte?', 'dont_have_account' => 'No teniu cap compte?', - 'social_login' => 'Inici de sessió amb xarxes socials', - 'social_registration' => 'Registre amb xarxes socials', - 'social_registration_text' => 'Registreu-vos i inicieu la sessió fent servir un altre servei.', + 'social_login' => 'Inici de sessió amb una xarxa social', + 'social_registration' => 'Registre amb una xarxa social', + 'social_registration_text' => 'Registreu-vos i inicieu sessió amb un altre servei.', - 'register_thanks' => 'Gràcies per registrar-vos!', - 'register_confirm' => 'Reviseu el vostre correu electrònic i feu clic al botó de confirmació per a accedir a :appName.', - 'registrations_disabled' => 'Actualment, els registres estan desactivats', - 'registration_email_domain_invalid' => 'Aquest domini de correu electrònic no té accés a aquesta aplicació', - 'register_success' => 'Gràcies per registrar-vos! Ja us hi heu registrat i heu iniciat la sessió.', + 'register_thanks' => 'Gràcies per registrar-vos.', + 'register_confirm' => 'Comproveu el correu electrònic i cliqueu el botó de confirmació per a accedir a :appName.', + 'registrations_disabled' => 'Els registres estan desactivats', + 'registration_email_domain_invalid' => 'Aquest domini de correu electrònic no té accés a l’aplicació.', + 'register_success' => 'Gràcies per registrar-vos Us heu registrat i heu iniciat sessió.', // Login auto-initiation - 'auto_init_starting' => 'S’està provant d’iniciar la sessió', - 'auto_init_starting_desc' => 'Estem contactant amb el vostre sistema d’autenticació per a començar el procés d’inici de sessió. Si no hi ha cap progrés d’aquí a 5 segons, proveu de fer clic a l’enllaç de sota.', - 'auto_init_start_link' => 'Continua amb l’autenticació', + 'auto_init_starting' => 'S’està provant d’iniciar sessió', + 'auto_init_starting_desc' => 'Estem contactant amb el vostre sistema d’autenticació per a començar el procés d’inici sessió. Si d’aquí 5 segons no hi ha hagut cap progrés proveu de clicar l’enllaç.', + 'auto_init_start_link' => 'Continua amb la l’autenticació', // Password Reset - 'reset_password' => 'Restableix la contrasenya', - 'reset_password_send_instructions' => 'Introduïu la vostra adreça electrònica a continuació i us enviarem un correu electrònic amb un enllaç per a restablir la contrasenya.', - 'reset_password_send_button' => 'Envia l’enllaç de restabliment', - 'reset_password_sent' => 'S’enviarà un enllaç per a restablir la contrasenya a :email, si es troba aquesta adreça al sistema.', - 'reset_password_success' => 'La contrasenya s’ha restablert correctament.', - 'email_reset_subject' => 'Restabliu la contrasenya a :appName', - 'email_reset_text' => 'Rebeu aquest correu electrònic perquè s’ha fet una petició de restabliment de contrasenya per al vostre compte.', - 'email_reset_not_requested' => 'Si no heu demanat restablir la contrasenya, no cal que emprengueu cap acció.', + 'reset_password' => 'Reinicialitza la contrasenya', + 'reset_password_send_instructions' => 'Introduïu la vostra adreça electrònica i us enviarem un correu electrònic amb un enllaç de reinicialització de la contrasenya.', + 'reset_password_send_button' => 'Envia’m un enllaç de reinicialització', + 'reset_password_sent' => 'Si l’adreça electrònica :email existeix al sistema, us hi enviarem un enllaç de reinicialització de contrasenya.', + 'reset_password_success' => 'S’ha reinicialitzat la contrasenya.', + 'email_reset_subject' => 'Reinicialització de la contrasenya de :appName', + 'email_reset_text' => 'Heu rebut aquest correu electrònic perquè hem rebut una sol·licitud de reinicialització de contrasenya per al vostre compte.', + 'email_reset_not_requested' => 'Si no heu sol·licitat la reinicialització de la contrasenya, no cal que feu res més.', // Email Confirmation - 'email_confirm_subject' => 'Confirmeu la vostra adreça electrònica a :appName', - 'email_confirm_greeting' => 'Gràcies per unir-vos a :appName!', - 'email_confirm_text' => 'Confirmeu la vostra adreça electrònica fent clic al botó a continuació:', - 'email_confirm_action' => 'Confirma el correu', - 'email_confirm_send_error' => 'Cal confirmar l’adreça electrònica, però el sistema no ha pogut enviar el correu electrònic. Poseu-vos en contacte amb l’administrador perquè s’asseguri que el correu electrònic està ben configurat.', - 'email_confirm_success' => 'Heu confirmat el vostre correu electrònic! Ara hauríeu de poder iniciar la sessió fent servir aquesta adreça electrònica.', - 'email_confirm_resent' => 'S’ha tornat a enviar el correu electrònic de confirmació. Reviseu la vostra safata d’entrada.', - 'email_confirm_thanks' => 'Gràcies per la confirmació!', - 'email_confirm_thanks_desc' => 'Espereu uns instants mentre gestionem la confirmació. Si no se us redirigeix d’aquí a 3 segons, premeu l’enllaç «Continua» de sota per a continuar.', + 'email_confirm_subject' => 'Confirmeu l’adreça electrònica de :appName', + 'email_confirm_greeting' => 'Gràcies per registrar-vos a :appName!', + 'email_confirm_text' => 'Cliqueu el botó per a confirmar l’adreça electrònica:', + 'email_confirm_action' => 'Confirmeu d’adreça electrònica', + 'email_confirm_send_error' => 'S’ha de confirmar de l’adreça electrònica però no s’ha pogut enviar el correu de confirmació. Contacteu l’administrador per a assegurar-vos que el correu està ben configurat.', + 'email_confirm_success' => 'S’ha confirmat el correu electrònic. Ara hauríeu de poder iniciar sessió amb aquesta adreça electrònica.', + 'email_confirm_resent' => 'S’ha enviat el correu de confirmació. Comproveu la safata d’entrada.', + 'email_confirm_thanks' => 'Gràcies per la confirmació.', + 'email_confirm_thanks_desc' => 'Espereu-vos un moment mentre es gestiona la confirmació. Si d’aquí 3 segons no se us ha redirigit, cliqueu l’enllaç «Continua» per a continuar.', - 'email_not_confirmed' => 'Adreça electrònica no confirmada', - 'email_not_confirmed_text' => 'La vostra adreça electrònica encara no està confirmada.', - 'email_not_confirmed_click_link' => 'Feu clic a l’enllaç del correu electrònic que us vam enviar poc després que us registréssiu.', - 'email_not_confirmed_resend' => 'Si no el trobeu, podeu tornar a enviar el correu electrònic de confirmació enviant el formulari a continuació.', + 'email_not_confirmed' => 'No s’ha confirmat l’adreça de correu electrònic', + 'email_not_confirmed_text' => 'Encara no heu confirmat l’adreça electrònica.', + 'email_not_confirmed_click_link' => 'Cliqueu l’enllaç que hi ha al correu electrònic que se us va enviar en registrar-vos.', + 'email_not_confirmed_resend' => 'Si no trobeu el correu electrònic ompliu el formulari a continuació i us n’enviarem un altre.', 'email_not_confirmed_resend_button' => 'Torna a enviar el correu de confirmació', // User Invite - 'user_invite_email_subject' => 'Us han convidat a unir-vos a :appName!', - 'user_invite_email_greeting' => 'S’ha creat un compte en el vostre nom a :appName.', - 'user_invite_email_text' => 'Feu clic al botó a continuació per a definir una contrasenya per al compte i obtenir-hi accés:', - 'user_invite_email_action' => 'Defineix una contrasenya per al compte', + 'user_invite_email_subject' => 'Us han convidat a utilitzar :appName!', + 'user_invite_email_greeting' => 'Us han creat un compte a :appName.', + 'user_invite_email_text' => 'Cliqueu el botó per a configurar una contrasenya pel compte i poder-hi accedir:', + 'user_invite_email_action' => 'Configura la contrasenya del compte', 'user_invite_page_welcome' => 'Us donem la benvinguda a :appName!', - 'user_invite_page_text' => 'Per a enllestir el vostre compte i obtenir-hi accés, cal que definiu una contrasenya, que es farà servir per a iniciar la sessió a :appName en futures visites.', + 'user_invite_page_text' => 'Per a poder accedir al compte heu de configurar una contrasenya que s’utilitzarà per a iniciar sessió a :appName d’ara endavant.', 'user_invite_page_confirm_button' => 'Confirma la contrasenya', - 'user_invite_success_login' => 'S’ha definit la contrasenya. Ara hauríeu de poder iniciar la sessió fent servir la contrasenya que heu definit per a accedir a :appName!', + 'user_invite_success_login' => 'S’ha configurat la contrasenya. Ara hauríeu de poder iniciar sessió a :appName amb la contrasenya configurada.', // Multi-factor Authentication - 'mfa_setup' => 'Configura l’autenticació de múltiple factor', - 'mfa_setup_desc' => 'Configureu l’autenticació de múltiple factor com a capa extra de seguretat en el vostre compte d’usuari.', - 'mfa_setup_configured' => 'Ja està configurada', + 'mfa_setup' => 'Configuració de l’autenticació multifactorial', + 'mfa_setup_desc' => 'Configureu l’autenticació multifactorial per a afegir una capa de seguretat extra al vostre compte d’usuari.', + 'mfa_setup_configured' => 'Ja està configurat', 'mfa_setup_reconfigure' => 'Torna-la a configurar', - 'mfa_setup_remove_confirmation' => 'Segur que voleu suprimir aquest mètode d’autenticació de múltiple factor?', + 'mfa_setup_remove_confirmation' => 'Esteu segur que voleu eliminar aquest mètode d’autenticació multifactorial?', 'mfa_setup_action' => 'Configura', - 'mfa_backup_codes_usage_limit_warning' => 'Teniu menys de 5 codis de seguretat restants. Genereu-ne i emmagatzemeu-ne un nou conjunt abans que se us acabin perquè no perdeu l’accés al vostre compte.', + 'mfa_backup_codes_usage_limit_warning' => 'Us queden menys de 5 codis de suport. Genereu-ne de nous i deseu-los abans de quedar-vos-en sense per a evitar perdre l’accés al compte.', 'mfa_option_totp_title' => 'Aplicació mòbil', - 'mfa_option_totp_desc' => 'Per a fer servir l’autenticació de múltiple factor us caldrà una aplicació mòbil que suporti TOTP, com ara Google Authenticador, Authy o Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Codis de seguretat', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_totp_desc' => 'Per a utilitzar l’autenticació multifactorial heu de tenir una aplicació que sigui compatible amb TOPT (contrasenyes d’un sol ús basades en el temps) com ara Google Authenticator, Authy, o Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Codis de suport', + 'mfa_option_backup_codes_desc' => 'Genereu un joc de codis de suport d’un sol ús que haureu d’introduir per a verificar la vostra identitat. Assegureu-vos de desar-los en un lloc segur.', 'mfa_gen_confirm_and_enable' => 'Confirma i activa', - 'mfa_gen_backup_codes_title' => 'Configuració de codis de seguretat', - 'mfa_gen_backup_codes_desc' => 'Deseu la següent llista de codis en un lloc segur. Quan accediu al sistema, podeu fer servir un dels codis com a segon mètode d’autenticació.', + 'mfa_gen_backup_codes_title' => 'Configuració dels codis de suport', + 'mfa_gen_backup_codes_desc' => 'Deseu els codis en un lloc segur. Podreu utilitzar un dels codis com a un altre mètode d’autenticació per a iniciar sessió.', 'mfa_gen_backup_codes_download' => 'Baixa els codis', - 'mfa_gen_backup_codes_usage_warning' => 'Cada codi es pot fer servir només una vegada', + 'mfa_gen_backup_codes_usage_warning' => 'Només podeu utilitzar cada codi un sol cop.', 'mfa_gen_totp_title' => 'Configuració de l’aplicació mòbil', - 'mfa_gen_totp_desc' => 'Per a fer servir l’autenticació de múltiple factor us caldrà una aplicació mòbil que suporti TOTP, com ara Google Authenticador, Authy o Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Per a començar, escanegeu el codi QR següent fent servir la vostre aplicació d’autenticació preferida.', - 'mfa_gen_totp_verify_setup' => 'Verifica la configuració', - 'mfa_gen_totp_verify_setup_desc' => 'Verifiqueu que tot funciona introduint un codi, generat amb l’aplicació d’autenticació, a la capsa de text següent:', - 'mfa_gen_totp_provide_code_here' => 'Proporcioneu aquí el codi generat per l’aplicació', + 'mfa_gen_totp_desc' => 'Per a utilitzar l’autenticació multifactorial heu de tenir una aplicació que sigui compatible amb TOPT (contrasenyes d’un sol ús basades en el temps) com ara Google Authenticator, Authy, o Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Escanegeu el codi QR amb l’aplicació d’autenticació que preferiu per a començar.', + 'mfa_gen_totp_verify_setup' => 'Verificació de la configuració', + 'mfa_gen_totp_verify_setup_desc' => 'Introduïu un dels codis generats per l’aplicació d’autenticació per a verificar que tot funciona:', + 'mfa_gen_totp_provide_code_here' => 'Codi', 'mfa_verify_access' => 'Verifica l’accés', - 'mfa_verify_access_desc' => 'El vostre compte d’usuari requereix que confirmeu la vostra identitat amb un nivell addicional de verificació abans que pugueu obtenir-hi accés. Verifiqueu-la fent servir un dels vostres mètodes configurats per a continuar.', + 'mfa_verify_access_desc' => 'Heu de verificar la vostra identitat amb un nivell de verificació addicional per a iniciar sessió. Verifiqueu-la amb un dels mètodes que heu configurat per a continuar.', 'mfa_verify_no_methods' => 'No hi ha cap mètode configurat', - 'mfa_verify_no_methods_desc' => 'No s’ha trobat cap mètode d’autenticació de múltiple factor al vostre compte. Cal que configureu almenys un mètode abans d’obtenir-hi accés.', - 'mfa_verify_use_totp' => 'Verifica fent servir una aplicació mòbil', - 'mfa_verify_use_backup_codes' => 'Verifica fent servir un codi de seguretat', - 'mfa_verify_backup_code' => 'Codi de seguretat', - 'mfa_verify_backup_code_desc' => 'Introduïu un dels vostres codis de seguretat restants a continuació:', - 'mfa_verify_backup_code_enter_here' => 'Introduïu aquí el codi de seguretat', - 'mfa_verify_totp_desc' => 'Introduïu el codi generat amb la vostra aplicació mòbil a continuació:', - 'mfa_setup_login_notification' => 'S’ha configurat el mètode d’autenticació de múltiple factor. Torneu a iniciar la sessió fent servir el mètode que heu configurat.', + 'mfa_verify_no_methods_desc' => 'No hi ha cap mètode d’autenticació multifactorial configurat al vostre compte. Heu de configurar com a mínim un mètode per a iniciar sessió.', + 'mfa_verify_use_totp' => 'Verificació amb una aplicació mòbil', + 'mfa_verify_use_backup_codes' => 'Verificació amb un codi de suport', + 'mfa_verify_backup_code' => 'Codi de suport', + 'mfa_verify_backup_code_desc' => 'Introduïu un dels codis de suport que us quedin:', + 'mfa_verify_backup_code_enter_here' => 'Codi:', + 'mfa_verify_totp_desc' => 'Introduïu el codi generat per l’aplicació mòbil:', + 'mfa_setup_login_notification' => 'S’ha configurat el mètode multifactorial. Torneu a iniciar sessió amb el mètode configurat.', ]; diff --git a/lang/ca/common.php b/lang/ca/common.php index f6a825b69fe..34203e600f9 100644 --- a/lang/ca/common.php +++ b/lang/ca/common.php @@ -7,8 +7,8 @@ // Buttons 'cancel' => 'Cancel·la', 'close' => 'Tanca', - 'confirm' => 'D’acord', - 'back' => 'Enrere', + 'confirm' => 'Confirma', + 'back' => 'Torna', 'save' => 'Desa', 'continue' => 'Continua', 'select' => 'Selecciona', @@ -18,14 +18,14 @@ // Form Labels 'name' => 'Nom', 'description' => 'Descripció', - 'role' => 'Rol', - 'cover_image' => 'Imatge de portada', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'role' => 'Funció', + 'cover_image' => 'Imatge de coberta', + 'cover_image_description' => 'La imatge ha de ser d’uns 440×250px, però quan calgui se n’ajustarà la mida o s’escapçarà per a adaptar-la a la interfície d’usuari i les mides amb què es mostrarà canviaran.', // Actions 'actions' => 'Accions', - 'view' => 'Visualitza', - 'view_all' => 'Visualitza-ho tot', + 'view' => 'Mostra', + 'view_all' => 'Mostra-ho tot', 'new' => 'Nou', 'create' => 'Crea', 'update' => 'Actualitza', @@ -44,10 +44,10 @@ 'configure' => 'Configura', 'manage' => 'Gestiona', 'fullscreen' => 'Pantalla completa', - 'favourite' => 'Afegeix a preferits', - 'unfavourite' => 'Suprimeix de preferits', - 'next' => 'Següent', - 'previous' => 'Anterior', + 'favourite' => 'Afegeix als favorits', + 'unfavourite' => 'Suprimeix dels favorits', + 'next' => 'Endavant', + 'previous' => 'Endarrere', 'filter_active' => 'Filtre actiu:', 'filter_clear' => 'Esborra el filtre', 'download' => 'Baixa', @@ -56,26 +56,26 @@ // Sort Options 'sort_options' => 'Opcions d’ordenació', - 'sort_direction_toggle' => 'Commuta la direcció de l’ordenació', - 'sort_ascending' => 'Ordre ascendent', - 'sort_descending' => 'Ordre descendent', + 'sort_direction_toggle' => 'Inverteix l’ordre', + 'sort_ascending' => 'Ordenació ascendent', + 'sort_descending' => 'Ordenació descendent', 'sort_name' => 'Nom', - 'sort_default' => 'Per defecte', + 'sort_default' => 'Predeterminat', 'sort_created_at' => 'Data de creació', 'sort_updated_at' => 'Data d’actualització', // Misc 'deleted_user' => 'Usuari suprimit', - 'no_activity' => 'No hi ha activitat', - 'no_items' => 'No hi ha cap element', - 'back_to_top' => 'Torna a dalt', - 'skip_to_main_content' => 'Vés al contingut', + 'no_activity' => 'No hi ha cap activitat per mostrar', + 'no_items' => 'No hi ha cap element disponible', + 'back_to_top' => 'Amunt', + 'skip_to_main_content' => 'Ves al contingut principal', 'toggle_details' => 'Commuta els detalls', 'toggle_thumbnails' => 'Commuta les miniatures', 'details' => 'Detalls', 'grid_view' => 'Visualització en graella', 'list_view' => 'Visualització en llista', - 'default' => 'Per defecte', + 'default' => 'Predeterminat', 'breadcrumb' => 'Ruta de navegació', 'status' => 'Estat', 'status_active' => 'Actiu', @@ -84,8 +84,8 @@ 'none' => 'Cap', // Header - 'homepage' => 'Pàgina principal', - 'header_menu_expand' => 'Expandeix el menú de la capçalera', + 'homepage' => 'Pàgina d’inici', + 'header_menu_expand' => 'Desplega el menú de la capçalera', 'profile_menu' => 'Menú del perfil', 'view_profile' => 'Mostra el perfil', 'edit_profile' => 'Edita el perfil', @@ -100,7 +100,7 @@ 'tab_content_label' => 'Pestanya: Mostra el contingut principal', // Email Content - 'email_action_help' => 'Si teniu problemes per a fer clic al botó «:actionText», copieu i enganxeu l’URL següent al vostre navegador web:', + 'email_action_help' => 'Si el botó «:actionText» no funciona, copieu i enganxeu l’URL següent al vostre navegador:', 'email_rights' => 'Tots els drets reservats', // Footer Link Options diff --git a/lang/ca/components.php b/lang/ca/components.php index 9c34850213d..c1494ec53a9 100644 --- a/lang/ca/components.php +++ b/lang/ca/components.php @@ -9,37 +9,37 @@ 'image_list' => 'Llista d’imatges', 'image_details' => 'Detalls de la imatge', 'image_upload' => 'Puja una imatge', - 'image_intro' => 'Aquí podeu seleccionar i gestionar imatges que s’hagin pujat anteriorment al sistema.', - 'image_intro_upload' => 'Pugeu una imatge nova arrossegant un fitxer d’imatge a aquesta finestra o fent servir el botó de «Puja una imatge».', + 'image_intro' => 'Seleccioneu i gestioneu les imatges que s’han pujat al sistema amb anterioritat.', + 'image_intro_upload' => 'Pugeu una imatge arrossegant-la i deixant-la anar en aquesta finestra o amb el botó «Puja una imatge».', 'image_all' => 'Totes', 'image_all_title' => 'Mostra totes les imatges', - 'image_book_title' => 'Mostra les imatges pujades a aquest llibre', - 'image_page_title' => 'Mostra les imatges pujades a aquesta pàgina', - 'image_search_hint' => 'Cerca per nom d’imatge', - 'image_uploaded' => 'Pujada :uploadedDate', + 'image_book_title' => 'Mostra les imatges pujades en aquest llibre', + 'image_page_title' => 'Mostra les imatges pujades en aquesta pàgina', + 'image_search_hint' => 'Cerca pel nom de la imatge', + 'image_uploaded' => 'Pujada el :uploadedDate', 'image_uploaded_by' => 'Pujada per :userName', 'image_uploaded_to' => 'Pujada a :pageLink', - 'image_updated' => 'Actualitzada :updateDate', + 'image_updated' => 'Actualitzada el :updateDate', 'image_load_more' => 'Carrega’n més', 'image_image_name' => 'Nom de la imatge', - 'image_delete_used' => 'Aquesta imatge s’utilitza a les pàgines següents.', - 'image_delete_confirm_text' => 'Segur que voleu suprimir aquesta imatge?', - 'image_select_image' => 'Selecciona una imatge', - 'image_dropzone' => 'Arrossegueu imatges o feu clic aquí per a pujar-les', - 'image_dropzone_drop' => 'Arrossegueu imatges aquí per a pujar-les', + 'image_delete_used' => 'Aquesta imatge s’utilitza en les pàgines següents.', + 'image_delete_confirm_text' => 'Esteu segur que voleu suprimir aquesta imatge?', + 'image_select_image' => 'Selecció d’imatges', + 'image_dropzone' => 'Deixeu-hi anar les imatges o cliqueu aquí per a pujar-ne', + 'image_dropzone_drop' => 'Deixeu-hi anar les imatges per a pujar-les', 'images_deleted' => 'Imatges suprimides', 'image_preview' => 'Previsualització de la imatge', - 'image_upload_success' => 'Imatge pujada correctament', - 'image_update_success' => 'Detalls de la imatge actualitzats correctament', - 'image_delete_success' => 'Imatge suprimida correctament', + 'image_upload_success' => 'S’ha pujat la imatge', + 'image_update_success' => 'S’han actualitzat els detalls de la imatge', + 'image_delete_success' => 'S’ha suprimit la imatge', 'image_replace' => 'Substitueix la imatge', - 'image_replace_success' => 'Fitxer de la imatge actualitzat correctament', + 'image_replace_success' => 'S’ha substituït la imatge', 'image_rebuild_thumbs' => 'Regenera les variacions de mida', - 'image_rebuild_thumbs_success' => 'Variacions de mida de la imatge regenerades correctament!', + 'image_rebuild_thumbs_success' => 'S’han regenerat les variacions de mida', // Code Editor 'code_editor' => 'Edita el codi', - 'code_language' => 'Llenguatge del codi', + 'code_language' => 'Llenguatge de codificació', 'code_content' => 'Contingut del codi', 'code_session_history' => 'Historial de la sessió', 'code_save' => 'Desa el codi', diff --git a/lang/ca/editor.php b/lang/ca/editor.php index abfa771a80f..7a09986a2f2 100644 --- a/lang/ca/editor.php +++ b/lang/ca/editor.php @@ -18,27 +18,27 @@ 'left' => 'Esquerra', 'center' => 'Centre', 'right' => 'Dreta', - 'top' => 'Superior', - 'middle' => 'Centre', - 'bottom' => 'Inferior', + 'top' => 'Dalt', + 'middle' => 'Mig', + 'bottom' => 'Baix', 'width' => 'Amplada', - 'height' => 'Alçada', + 'height' => 'Altura', 'More' => 'Més', - 'select' => 'Selecciona...', + 'select' => 'Selecciona…', // Toolbar 'formats' => 'Formats', - 'header_large' => 'Capçalera grossa', - 'header_medium' => 'Capçalera mitjana', - 'header_small' => 'Capçalera petita', - 'header_tiny' => 'Capçalera minúscula', + 'header_large' => 'Títol gros', + 'header_medium' => 'Títol mitjà', + 'header_small' => 'Títol petit', + 'header_tiny' => 'Títol minúscul', 'paragraph' => 'Paràgraf', - 'blockquote' => 'Citació', - 'inline_code' => 'Codi en línia', - 'callouts' => 'Llegendes', + 'blockquote' => 'Bloc de cita', + 'inline_code' => 'Línia de codi', + 'callouts' => 'Crides', 'callout_information' => 'Informació', 'callout_success' => 'Èxit', - 'callout_warning' => 'Advertència', + 'callout_warning' => 'Advertiment', 'callout_danger' => 'Perill', 'bold' => 'Negreta', 'italic' => 'Cursiva', @@ -48,30 +48,30 @@ 'subscript' => 'Subíndex', 'text_color' => 'Color del text', 'custom_color' => 'Color personalitzat', - 'remove_color' => 'Suprimeix el color', + 'remove_color' => 'Elimina el color', 'background_color' => 'Color de fons', 'align_left' => 'Alinea a l’esquerra', 'align_center' => 'Alinea al centre', 'align_right' => 'Alinea a la dreta', 'align_justify' => 'Justifica', - 'list_bullet' => 'Llista amb pics', + 'list_bullet' => 'Llista de pics', 'list_numbered' => 'Llista numerada', - 'list_task' => 'Llista de tasques', - 'indent_increase' => 'Augmenta el sagnat', - 'indent_decrease' => 'Redueix el sagnat', + 'list_task' => 'Llista de caselles', + 'indent_increase' => 'Augmenta la sagnia', + 'indent_decrease' => 'Redueix la sagnia', 'table' => 'Taula', 'insert_image' => 'Insereix una imatge', - 'insert_image_title' => 'Insereix/edita una imatge', - 'insert_link' => 'Insereix/edita un enllaç', - 'insert_link_title' => 'Insereix/edita un enllaç', + 'insert_image_title' => 'Insereix o edita una imatge', + 'insert_link' => 'Insereix un enllaç', + 'insert_link_title' => 'Insereix o edita un enllaç', 'insert_horizontal_line' => 'Insereix una línia horitzontal', 'insert_code_block' => 'Insereix un bloc de codi', - 'edit_code_block' => 'Edita un bloc de codi', - 'insert_drawing' => 'Insereix/edita un dibuix', - 'drawing_manager' => 'Gestor de dibuixos', - 'insert_media' => 'Insereix/edita un mitjà', - 'insert_media_title' => 'Insereix/edita un mitjà', - 'clear_formatting' => 'Neteja el format', + 'edit_code_block' => 'Edita el bloc de codi', + 'insert_drawing' => 'Insereix o edita un diagrama', + 'drawing_manager' => 'Gestor de diagrames', + 'insert_media' => 'Insereix o edita un contingut multimèdia', + 'insert_media_title' => 'Insereix o edita un contingut multimèdia', + 'clear_formatting' => 'Lleva el format', 'source_code' => 'Codi font', 'source_code_title' => 'Codi font', 'fullscreen' => 'Pantalla completa', @@ -81,9 +81,9 @@ 'table_properties' => 'Propietats de la taula', 'table_properties_title' => 'Propietats de la taula', 'delete_table' => 'Suprimeix la taula', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => 'Lleva el format de la taula', + 'resize_to_contents' => 'Ajusta al contingut', + 'row_header' => 'Capçalera de la fila', 'insert_row_before' => 'Insereix una fila abans', 'insert_row_after' => 'Insereix una fila després', 'delete_row' => 'Suprimeix la fila', @@ -97,10 +97,10 @@ 'cell_properties_title' => 'Propietats de la cel·la', 'cell_type' => 'Tipus de cel·la', 'cell_type_cell' => 'Cel·la', - 'cell_scope' => 'Àmbit', + 'cell_scope' => 'Àmbit de la cel·la', 'cell_type_header' => 'Cel·la de capçalera', - 'merge_cells' => 'Fusiona les cel·les', - 'split_cell' => 'Divideix les cel·les', + 'merge_cells' => 'Combina les cel·les', + 'split_cell' => 'Divideix la cel·la', 'table_row_group' => 'Grup de files', 'table_column_group' => 'Grup de columnes', 'horizontal_align' => 'Alineació horitzontal', @@ -117,61 +117,61 @@ 'row_type' => 'Tipus de fila', 'row_type_header' => 'Capçalera', 'row_type_body' => 'Cos', - 'row_type_footer' => 'Peu de pàgina', + 'row_type_footer' => 'Peu', 'alignment' => 'Alineació', 'cut_column' => 'Retalla la columna', 'copy_column' => 'Copia la columna', 'paste_column_before' => 'Enganxa la columna abans', 'paste_column_after' => 'Enganxa la columna després', - 'cell_padding' => 'Separació de la cel·la', - 'cell_spacing' => 'Espaiat de la cel·la', - 'caption' => 'Títol', - 'show_caption' => 'Mostra el títol', + 'cell_padding' => 'Separació de cel·la', + 'cell_spacing' => 'Espaiat de cel·la', + 'caption' => 'Llegenda', + 'show_caption' => 'Mostra la llegenda', 'constrain' => 'Mantén les proporcions', 'cell_border_solid' => 'Sòlida', - 'cell_border_dotted' => 'Puntejada', - 'cell_border_dashed' => 'Discontínua', + 'cell_border_dotted' => 'De punts', + 'cell_border_dashed' => 'De guions', 'cell_border_double' => 'Doble', - 'cell_border_groove' => 'Bisellada', - 'cell_border_ridge' => 'Emmarcada', - 'cell_border_inset' => 'Enfonsada', - 'cell_border_outset' => 'Ressaltada', - 'cell_border_none' => 'Cap', - 'cell_border_hidden' => 'Oculta', + 'cell_border_groove' => 'Vora enfonsada', + 'cell_border_ridge' => 'Vora ressaltada', + 'cell_border_inset' => 'Fons enfonsat', + 'cell_border_outset' => 'Fons ressaltat', + 'cell_border_none' => 'Sense vora', + 'cell_border_hidden' => 'Amagada', // Images, links, details/summary & embed - 'source' => 'Origen', + 'source' => 'Font', 'alt_desc' => 'Descripció alternativa', - 'embed' => 'Incrustació', - 'paste_embed' => 'Enganxeu el codi d’incrustació a continuació:', + 'embed' => 'Incrusta', + 'paste_embed' => 'Enganxa el codi per a incrustar a sota:', 'url' => 'URL', - 'text_to_display' => 'Text a mostrar', + 'text_to_display' => 'Text per a mostrar', 'title' => 'Títol', 'open_link' => 'Obre l’enllaç', - 'open_link_in' => 'Obre l’enllaç a...', - 'open_link_current' => 'Finestra actual', - 'open_link_new' => 'Finestra nova', - 'remove_link' => 'Suprimeix l’enllaç', - 'insert_collapsible' => 'Insereix un bloc contraïble', - 'collapsible_unwrap' => 'Deixa de contraure', + 'open_link_in' => 'Obre l’enllaç…', + 'open_link_current' => 'A la finestra actual', + 'open_link_new' => 'En una finestra nova', + 'remove_link' => 'Lleva l’enllaç', + 'insert_collapsible' => 'Insereix un bloc desplegable', + 'collapsible_unwrap' => 'Lleva el bloc desplegable', 'edit_label' => 'Edita l’etiqueta', - 'toggle_open_closed' => 'Obre/tanca', - 'collapsible_edit' => 'Edita el bloc contraïble', + 'toggle_open_closed' => 'Obre o tanca', + 'collapsible_edit' => 'Edita el bloc desplegable', 'toggle_label' => 'Commuta l’etiqueta', // About view - 'about' => 'Quant a l’editor', - 'about_title' => 'Quant a l’editor WYSIWYG', - 'editor_license' => 'Llicència i copyright de l’editor', - 'editor_tiny_license' => 'Aquest editor s’ha construït amb :tinyLink, que es proporciona sota la llicència MIT.', - 'editor_tiny_license_link' => 'Podeu trobar els detalls de la llicència i el copyright del TinyMCE aquí.', + 'about' => 'Quant a l’Editor', + 'about_title' => 'Quant a l’Editor WYSIWYG', + 'editor_license' => 'Llicència i copyright de l’Editor', + 'editor_tiny_license' => 'Aquest editor s’ha creat amb :tinyLink que es proporciona amb la llicència MIT.', + 'editor_tiny_license_link' => 'Detalls de la llicència i el copyright de TinyMCE.', 'save_continue' => 'Desa la pàgina i continua', - 'callouts_cycle' => '(Continueu prement per a commutar els diferents tipus)', - 'link_selector' => 'Enllaç al contingut', - 'shortcuts' => 'Dreceres', + 'callouts_cycle' => '(Continueu prement per a canviar entre tipus)', + 'link_selector' => 'Enllaç a contingut', + 'shortcuts' => 'Dreceres de teclat', 'shortcut' => 'Drecera', - 'shortcuts_intro' => 'Hi ha les següents dreceres disponibles a l’editor:', - 'windows_linux' => '(Windows/Linux)', + 'shortcuts_intro' => 'A l’editor, hi ha disponibles les dreceres de teclat següents:', + 'windows_linux' => '(Windows o Linux)', 'mac' => '(Mac)', 'description' => 'Descripció', ]; diff --git a/lang/ca/entities.php b/lang/ca/entities.php index ba8f574edd1..b6e104fa4f7 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -6,34 +6,34 @@ return [ // Shared - 'recently_created' => 'Creat fa poc', - 'recently_created_pages' => 'Pàgines creades fa poc', - 'recently_updated_pages' => 'Pàgines actualitzades fa poc', - 'recently_created_chapters' => 'Capítols creats fa poc', - 'recently_created_books' => 'Llibres creats fa poc', - 'recently_created_shelves' => 'Prestatges creats fa poc', - 'recently_update' => 'Actualitzat fa poc', - 'recently_viewed' => 'Vist fa poc', + 'recently_created' => 'Elements creats recentment', + 'recently_created_pages' => 'Pàgines creades recentment', + 'recently_updated_pages' => 'Pàgines actualitzades recentment', + 'recently_created_chapters' => 'Capítols creats recentment', + 'recently_created_books' => 'Llibres creats recentment', + 'recently_created_shelves' => 'Prestatges creats recentment', + 'recently_update' => 'Elements actualitzats recentment', + 'recently_viewed' => 'Elements vistos recentment', 'recent_activity' => 'Activitat recent', - 'create_now' => 'Crea’n ara', + 'create_now' => 'Crea un element', 'revisions' => 'Revisions', 'meta_revision' => 'Revisió núm. :revisionCount', - 'meta_created' => 'Creat :timeLength', - 'meta_created_name' => 'Creat :timeLength per :user', - 'meta_updated' => 'Actualitzat :timeLength', - 'meta_updated_name' => 'Actualitzat :timeLength per :user', + 'meta_created' => 'S’ha creat :timeLength', + 'meta_created_name' => ':user l’ha creat :timeLength', + 'meta_updated' => 'S’ha actualitzat :timeLength', + 'meta_updated_name' => ':user l’ha actualitzat :timeLength', 'meta_owned_name' => 'Propietat de :user', - 'meta_reference_count' => 'Hi fa referència :count element|Hi fan referència :count elements', - 'entity_select' => 'Selecciona una entitat', + 'meta_reference_count' => 'S’hi fa referència en :count element|S’hi fa referència en :count elements', + 'entity_select' => 'Selecció de l’entitat', 'entity_select_lack_permission' => 'No teniu els permisos necessaris per a seleccionar aquest element', 'images' => 'Imatges', - 'my_recent_drafts' => 'Els vostres esborranys recents', - 'my_recently_viewed' => 'Les vostres visualitzacions recents', - 'my_most_viewed_favourites' => 'Els vostres preferits més vistos', - 'my_favourites' => 'Els vostres preferits', + 'my_recent_drafts' => 'Els meus esborranys recents', + 'my_recently_viewed' => 'Els meus vistos recentment', + 'my_most_viewed_favourites' => 'Els meus favorits més vistos', + 'my_favourites' => 'Els meus favorits', 'no_pages_viewed' => 'No heu vist cap pàgina', - 'no_pages_recently_created' => 'No s’ha creat cap pàgina fa poc', - 'no_pages_recently_updated' => 'No s’ha actualitzat cap pàgina fa poc', + 'no_pages_recently_created' => 'No s’ha creat cap pàgina recentment', + 'no_pages_recently_updated' => 'No s’ha actualitzat cap pàgina recentment', 'export' => 'Exporta', 'export_html' => 'Fitxer web independent', 'export_pdf' => 'Fitxer PDF', @@ -45,40 +45,40 @@ // Permissions and restrictions 'permissions' => 'Permisos', - 'permissions_desc' => 'Definiu aquí permisos que tindran preferència sobre les permisos per defecte proporcionats pels rols d’usuari.', - 'permissions_book_cascade' => 'Els permisos definits en llibres s’hereten automàticament en pàgines i capítols inferiors, llevat que tinguin permisos propis definits.', - 'permissions_chapter_cascade' => 'Els permisos definits en capítols s’hereten automàticament en pàgines inferiors, llevat que tinguin permisos propis definits.', + 'permissions_desc' => 'Configureu aquí els permisos per a sobreescriure els permisos per defecte proporcionats pels rols d’usuari.', + 'permissions_book_cascade' => 'Els permisos dels llibres s’aplicaran també als capítols i les pàgines que continguin llevat que tinguin els seus propis permisos.', + 'permissions_chapter_cascade' => 'Els permisos dels capítols s’aplicaran també a les pàgines que continguin llevat que tinguin els seus propis permisos.', 'permissions_save' => 'Desa els permisos', 'permissions_owner' => 'Propietari', - 'permissions_role_everyone_else' => 'Tota la resta', - 'permissions_role_everyone_else_desc' => 'Definiu permisos per a tots els rols no definits específicament.', - 'permissions_role_override' => 'Força els permisos per al rol', - 'permissions_inherit_defaults' => 'Hereta els permisos per defecte', + 'permissions_role_everyone_else' => 'La resta dels usuaris', + 'permissions_role_everyone_else_desc' => 'Configureu permisos per a tots els rols que no s’hagin sobreescrit específicament.', + 'permissions_role_override' => 'Sobreescriu els permisos per al rol', + 'permissions_inherit_defaults' => 'Hereta la configuració per defecte', // Search 'search_results' => 'Resultats de la cerca', - 'search_total_results_found' => 'S’ha trobat :count resultat|S’han trobat :count resultats en total', + 'search_total_results_found' => 'S’ha trobat :count coincidència|S’han trobat :count coincidències', 'search_clear' => 'Esborra la cerca', - 'search_no_pages' => 'La cerca no coincideix amb cap pàgina', + 'search_no_pages' => 'No s’ha trobat cap pàgina que coincideixi amb la cerca', 'search_for_term' => 'Cerca :term', 'search_more' => 'Més resultats', 'search_advanced' => 'Cerca avançada', - 'search_terms' => 'Termes de la cerca', + 'search_terms' => 'Termes de cerca', 'search_content_type' => 'Tipus de contingut', 'search_exact_matches' => 'Coincidències exactes', 'search_tags' => 'Cerca d’etiquetes', 'search_options' => 'Opcions', - 'search_viewed_by_me' => 'Visualitzat per mi', - 'search_not_viewed_by_me' => 'No visualitzat per mi', - 'search_permissions_set' => 'Amb permisos definits', - 'search_created_by_me' => 'Creat per mi', - 'search_updated_by_me' => 'Actualitzat per mi', - 'search_owned_by_me' => 'En sóc propietari', - 'search_date_options' => 'Opcions de dates', - 'search_updated_before' => 'Actualitzat abans de', - 'search_updated_after' => 'Actualitzat després de', - 'search_created_before' => 'Creat abans de', - 'search_created_after' => 'Creat després de', + 'search_viewed_by_me' => 'Vistos per mi', + 'search_not_viewed_by_me' => 'No vistos per mi', + 'search_permissions_set' => 'Amb permisos', + 'search_created_by_me' => 'Creats per mi', + 'search_updated_by_me' => 'Actualitzats per mi', + 'search_owned_by_me' => 'En soc propietari', + 'search_date_options' => 'Opcions de data', + 'search_updated_before' => 'Actualitzats abans de', + 'search_updated_after' => 'Actualitzats després de', + 'search_created_before' => 'Creats abans de', + 'search_created_after' => 'Creats després de', 'search_set_date' => 'Defineix una data', 'search_update' => 'Actualitza la cerca', @@ -86,87 +86,87 @@ 'shelf' => 'Prestatge', 'shelves' => 'Prestatges', 'x_shelves' => ':count prestatge|:count prestatges', - 'shelves_empty' => 'No hi ha cap prestatge creat', - 'shelves_create' => 'Crea un prestatge nou', + 'shelves_empty' => 'No hi ha cap prestatge', + 'shelves_create' => 'Crea un prestatge', 'shelves_popular' => 'Prestatges populars', 'shelves_new' => 'Prestatges nous', 'shelves_new_action' => 'Prestatge nou', 'shelves_popular_empty' => 'Aquí apareixeran els prestatges més populars.', - 'shelves_new_empty' => 'Aquí apareixeran els prestatges creats fa poc.', + 'shelves_new_empty' => 'Aquí apareixeran els prestatges més recents.', 'shelves_save' => 'Desa el prestatge', 'shelves_books' => 'Llibres en aquest prestatge', - 'shelves_add_books' => 'Afegeix llibres a aquest prestatge', - 'shelves_drag_books' => 'Arrossegueu llibres a sota per a afegir-los a aquest prestatge', - 'shelves_empty_contents' => 'Aquest prestatge no té cap llibre assignat', - 'shelves_edit_and_assign' => 'Editeu el prestatge per a assignar-hi llibres', - 'shelves_edit_named' => 'Edita el prestatge :name', + 'shelves_add_books' => 'Afegiu llibres en aquest prestatge', + 'shelves_drag_books' => 'Arrossegueu i deixeu anar llibres a sota per a afegir-los en aquest prestatge', + 'shelves_empty_contents' => 'Aquest prestatge no té cap llibre', + 'shelves_edit_and_assign' => 'Editeu el prestatge per a afegir-hi llibres', + 'shelves_edit_named' => 'Edita el prestatge «:name»', 'shelves_edit' => 'Edita el prestatge', 'shelves_delete' => 'Suprimeix el prestatge', - 'shelves_delete_named' => 'Suprimeix el prestatge :name', - 'shelves_delete_explain' => "Se suprimirà el prestatge amb el nom «:name». Els llibres que contingui no se suprimiran.", - 'shelves_delete_confirmation' => 'Segur que voleu suprimir aquest prestatge?', + 'shelves_delete_named' => 'Suprimeix del prestatge «:name»', + 'shelves_delete_explain' => "Se suprimirà el prestatge «:name». No se suprimiran els llibres que contingui.", + 'shelves_delete_confirmation' => 'Esteu segur que voleu suprimir aquest prestatge?', 'shelves_permissions' => 'Permisos del prestatge', 'shelves_permissions_updated' => 'S’han actualitzat els permisos del prestatge', - 'shelves_permissions_active' => 'Permisos del prestatge actius', - 'shelves_permissions_cascade_warning' => 'Els permisos dels prestatges no s’hereten automàticament en els llibres que contenen. És així perquè un llibre pot estar en múltiples prestatges. Es poden copiar, però, els permisos als llibres continguts fent servir l’opció a continuació.', - 'shelves_permissions_create' => 'Els permisos de crear prestatges només es fan servir per a copiar permisos a llibres continguts fent servir l’acció següent. No controlen la capacitat de crear llibres.', + 'shelves_permissions_active' => 'El prestatge té permisos', + 'shelves_permissions_cascade_warning' => 'Els permisos dels prestatges no s’aplicaran als llibres que contingui. Això és perquè els llibres poden estar en més d’un prestatge a la vegada. Això no obstant, els permisos es poden copiar als llibres que contingui amb l’opció de sota.', + 'shelves_permissions_create' => 'Els permisos per a crear prestatges només s’utilitzen per a copiar permisos als llibres que contenen amb l’acció de sota. No controlen els permisos per a crear llibres.', 'shelves_copy_permissions_to_books' => 'Copia els permisos als llibres', 'shelves_copy_permissions' => 'Copia els permisos', - 'shelves_copy_permissions_explain' => 'S’aplicarà la configuració de permisos actual a tots els llibres continguts. Abans d’activar-la, assegureu-vos que hàgiu desat qualsevol canvi en els permisos d’aquest prestatge.', + 'shelves_copy_permissions_explain' => 'S’aplicaran els permisos del prestatge a tots els llibres que contingui. Abans de continuar assegureu-vos que heu desat els canvis que hàgiu fet als permisos del prestatge.', 'shelves_copy_permission_success' => 'S’han copiat els permisos del prestatge a :count llibres', // Books 'book' => 'Llibre', 'books' => 'Llibres', 'x_books' => ':count llibre|:count llibres', - 'books_empty' => 'No hi ha cap llibre creat', + 'books_empty' => 'No hi ha cap llibre', 'books_popular' => 'Llibres populars', 'books_recent' => 'Llibres recents', 'books_new' => 'Llibres nous', 'books_new_action' => 'Llibre nou', 'books_popular_empty' => 'Aquí apareixeran els llibres més populars.', - 'books_new_empty' => 'Aquí apareixeran els llibres creats fa poc.', - 'books_create' => 'Crea un llibre nou', + 'books_new_empty' => 'Aquí apareixeran els llibres més recents.', + 'books_create' => 'Crea un llibre', 'books_delete' => 'Suprimeix el llibre', - 'books_delete_named' => 'Suprimeix el llibre :bookName', - 'books_delete_explain' => 'Se suprimirà el llibre amb el nom «:bookName». Se’n suprimiran totes les pàgines i tots els capítols.', - 'books_delete_confirmation' => 'Segur que voleu suprimir aquest llibre?', + 'books_delete_named' => 'Suprimeix el llibre «:bookName»', + 'books_delete_explain' => 'Se suprimirà el llibre «:bookName». Se suprimiran totes les pàgines i capítols.', + 'books_delete_confirmation' => 'Esteu segur que voleu suprimir aquest llibre?', 'books_edit' => 'Edita el llibre', - 'books_edit_named' => 'Edita el llibre :bookName', - 'books_form_book_name' => 'Nom del llibre', + 'books_edit_named' => 'Edita el llibre «:bookName»', + 'books_form_book_name' => 'Títol del llibre', 'books_save' => 'Desa el llibre', 'books_permissions' => 'Permisos del llibre', - 'books_permissions_updated' => 'S’han actualitzat els permisos del llibre', - 'books_empty_contents' => 'No hi ha cap pàgina ni cap capítol creat en aquest llibre.', - 'books_empty_create_page' => 'Crea una pàgina nova', - 'books_empty_sort_current_book' => 'Ordena el llibre actual', + 'books_permissions_updated' => 'S’han actualitzat els permisos', + 'books_empty_contents' => 'No hi ha cap capítol ni cap pàgina en aquest llibre.', + 'books_empty_create_page' => 'Crea una pàgina', + 'books_empty_sort_current_book' => 'Ordena el llibre', 'books_empty_add_chapter' => 'Afegeix un capítol', - 'books_permissions_active' => 'S’han activat els permisos del llibre', + 'books_permissions_active' => 'El llibre té permisos', 'books_search_this' => 'Cerca en aquest llibre', - 'books_navigation' => 'Navegació pel llibre', - 'books_sort' => 'Ordena el contingut del llibre', - 'books_sort_desc' => 'Moveu capítols i pàgines dins d’un llibre per a reorganitzar-ne el contingut. Podeu afegir altres llibres, la qual cosa permet moure fàcilment capítols i pàgines entre llibres.', - 'books_sort_named' => 'Ordena el llibre :bookName', - 'books_sort_name' => 'Ordena per nom', - 'books_sort_created' => 'Ordena per data de creació', - 'books_sort_updated' => 'Ordena per data d’actualització', - 'books_sort_chapters_first' => 'Els capítols al principi', - 'books_sort_chapters_last' => 'Els capítols al final', + 'books_navigation' => 'Navegació del llibre', + 'books_sort' => 'Ordena el contingut d’un llibre', + 'books_sort_desc' => 'Moveu els capítols i les pàgines d’un llibre per a reorganitzar-ne el contingut. Podeu afegir altres llibres perquè sigui més fàcil moure capítols i pàgines d’un llibre a un altre.', + 'books_sort_named' => 'Ordena el llibre «:bookName»', + 'books_sort_name' => 'Ordena pel nom', + 'books_sort_created' => 'Ordena per la data de creació', + 'books_sort_updated' => 'Ordena per la data d’actualització', + 'books_sort_chapters_first' => 'Capítols al principi', + 'books_sort_chapters_last' => 'Capítols al final', 'books_sort_show_other' => 'Mostra altres llibres', 'books_sort_save' => 'Desa l’ordre nou', - 'books_sort_show_other_desc' => 'Afegiu aquí altres llibres per a incloure’ls a l’operació d’ordenació i permetre la reorganització entre llibres de manera fàcil.', - 'books_sort_move_up' => 'Mou amunt', - 'books_sort_move_down' => 'Mou avall', - 'books_sort_move_prev_book' => 'Mou al llibre anterior', - 'books_sort_move_next_book' => 'Mou al llibre següent', - 'books_sort_move_prev_chapter' => 'Mou al capítol anterior', - 'books_sort_move_next_chapter' => 'Mou al capítol següent', - 'books_sort_move_book_start' => 'Mou a l’inici del llibre', - 'books_sort_move_book_end' => 'Mou al final del llibre', - 'books_sort_move_before_chapter' => 'Mou al capítol anterior', - 'books_sort_move_after_chapter' => 'Mou al capítol següent', + 'books_sort_show_other_desc' => 'Afegiu llibres per a incloure’ls en l’ordenació i permetre una reorganització entre llibres més senzilla.', + 'books_sort_move_up' => 'Mou-ho cap amunt', + 'books_sort_move_down' => 'Mou-ho cap avall', + 'books_sort_move_prev_book' => 'Mou-ho al llibre anterior', + 'books_sort_move_next_book' => 'Mou-ho al llibre següent', + 'books_sort_move_prev_chapter' => 'Mou-ho al capítol anterior', + 'books_sort_move_next_chapter' => 'Mou-ho al capítol següent', + 'books_sort_move_book_start' => 'Mou-ho al principi del llibre', + 'books_sort_move_book_end' => 'Mou-ho al final del llibre', + 'books_sort_move_before_chapter' => 'Mou-ho a abans del capítol', + 'books_sort_move_after_chapter' => 'Mou-ho a després del capítol', 'books_copy' => 'Copia el llibre', - 'books_copy_success' => 'Llibre copiat correctament', + 'books_copy_success' => 'S’ha copiat el llibre', // Chapters 'chapter' => 'Capítol', @@ -174,21 +174,21 @@ 'x_chapters' => ':count capítol|:count capítols', 'chapters_popular' => 'Capítols populars', 'chapters_new' => 'Capítol nou', - 'chapters_create' => 'Crea un capítol nou', - 'chapters_delete' => 'Suprimeix el capítol', - 'chapters_delete_named' => 'Suprimeix el capítol :chapterName', - 'chapters_delete_explain' => 'Se suprimirà el capítol amb el nom «:chapterName». Totes les pàgines que contingui també se suprimiran.', - 'chapters_delete_confirm' => 'Segur que voleu suprimir aquest capítol?', + 'chapters_create' => 'Crea un capítol', + 'chapters_delete' => 'Suprimeix un capítol', + 'chapters_delete_named' => 'Suprimeix el capítol «:chapterName»', + 'chapters_delete_explain' => 'Se suprimirà el capítol «:chapterName». Se suprimiran totes les pàgines que hi ha al capítol.', + 'chapters_delete_confirm' => 'Esteu segur que voleu suprimir aquest capítol?', 'chapters_edit' => 'Edita el capítol', - 'chapters_edit_named' => 'Edita el capítol :chapterName', + 'chapters_edit_named' => 'Edita el capítol «:chapterName»', 'chapters_save' => 'Desa el capítol', 'chapters_move' => 'Mou el capítol', - 'chapters_move_named' => 'Mou el capítol :chapterName', + 'chapters_move_named' => 'Mou el capítol «:chapterName»', 'chapters_copy' => 'Copia el capítol', - 'chapters_copy_success' => 'Capítol copiat correctament', + 'chapters_copy_success' => 'S’ha copiat el capítol', 'chapters_permissions' => 'Permisos del capítol', - 'chapters_empty' => 'De moment, aquest capítol no conté cap pàgina.', - 'chapters_permissions_active' => 'S’han activat els permisos del capítol', + 'chapters_empty' => 'No hi ha cap pàgina en aquest capítol.', + 'chapters_permissions_active' => 'El capítol té permisos', 'chapters_permissions_success' => 'S’han actualitzat els permisos del capítol', 'chapters_search_this' => 'Cerca en aquest capítol', 'chapter_sort_book' => 'Ordena el llibre', @@ -198,105 +198,105 @@ 'pages' => 'Pàgines', 'x_pages' => ':count pàgina|:count pàgines', 'pages_popular' => 'Pàgines populars', - 'pages_new' => 'Pàgina nova', - 'pages_attachments' => 'Adjuncions', - 'pages_navigation' => 'Navegació per la pàgina', + 'pages_new' => 'Crea una pàgina', + 'pages_attachments' => 'Fitxers adjunts', + 'pages_navigation' => 'Navegació de la pàgina', 'pages_delete' => 'Suprimeix la pàgina', - 'pages_delete_named' => 'Suprimeix la pàgina :pageName', - 'pages_delete_draft_named' => 'Suprimeix l’esborrany de pàgina :pageName', + 'pages_delete_named' => 'Suprimeix la pàgina «:pageName»', + 'pages_delete_draft_named' => 'Suprimeix l’esborrany de pàgina «:pageName»', 'pages_delete_draft' => 'Suprimeix l’esborrany de pàgina', 'pages_delete_success' => 'S’ha suprimit la pàgina', 'pages_delete_draft_success' => 'S’ha suprimit l’esborrany de pàgina', 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', - 'pages_delete_confirm' => 'Segur que voleu suprimir aquesta pàgina?', - 'pages_delete_draft_confirm' => 'Segur que voleu suprimir aquest esborrany de pàgina?', - 'pages_editing_named' => 'Esteu editant :pageName', + 'pages_delete_confirm' => 'Esteu segur que voleu suprimir aquesta pàgina?', + 'pages_delete_draft_confirm' => 'Esteu segur que voleu suprimir aquest esborrany de pàgina?', + 'pages_editing_named' => 'Edició de la pàgina «:pageName»', 'pages_edit_draft_options' => 'Opcions d’esborrany', 'pages_edit_save_draft' => 'Desa l’esborrany', 'pages_edit_draft' => 'Edita l’esborrany de pàgina', - 'pages_editing_draft' => 'Esteu editant l’esborrany', - 'pages_editing_page' => 'Esteu editant la pàgina', - 'pages_edit_draft_save_at' => 'Esborrany desat ', + 'pages_editing_draft' => 'Estàs editant l’esborrany', + 'pages_editing_page' => 'Edita la pàgina', + 'pages_edit_draft_save_at' => 'S’ha desat l’esborrany a les ', 'pages_edit_delete_draft' => 'Suprimeix l’esborrany', - 'pages_edit_delete_draft_confirm' => 'Segur que voleu suprimir els canvis a l’esborrany de pàgina? Es perdran tots els vostres canvis d’ençà de la darrera vegada que heu desat i s’actualitzarà l’editor amb l’estat de la darrera pàgina desada sense ser un esborrany.', + 'pages_edit_delete_draft_confirm' => 'Esteu segur que voleu suprimir els canvis de l’esborrany de pàgina? Es perdran tots els canvis que hàgiu fet després de la darrera vegada que vau desar la pàgina i l’editor s’actualitzarà amb l’estat de la darrera vegada que es va desar la pàgina que no era esborrany.', 'pages_edit_discard_draft' => 'Descarta l’esborrany', - 'pages_edit_switch_to_markdown' => 'Canvia a l’editor Markdown', + 'pages_edit_switch_to_markdown' => 'Canvia a l’editor de Markdown', 'pages_edit_switch_to_markdown_clean' => '(Contingut net)', 'pages_edit_switch_to_markdown_stable' => '(Contingut estable)', 'pages_edit_switch_to_wysiwyg' => 'Canvia a l’editor WYSIWYG', - 'pages_edit_set_changelog' => 'Defineix el registre de canvis', - 'pages_edit_enter_changelog_desc' => 'Introduïu una breu descripció dels canvis que heu fet', - 'pages_edit_enter_changelog' => 'Introduïu un registre de canvis', - 'pages_editor_switch_title' => 'Canvia d’editor', - 'pages_editor_switch_are_you_sure' => 'Segur que voleu canviar l’editor d’aquesta pàgina?', - 'pages_editor_switch_consider_following' => 'Considereu el següent en canviar d’editor:', - 'pages_editor_switch_consideration_a' => 'Quan hàgiu desat, l’opció del nou editor serà la que utilitzaran tots els futurs editors, incloent-hi els que no poden canviar de tipus d’editor amb el seu usuari.', - 'pages_editor_switch_consideration_b' => 'En algunes circumstàncies, això pot comportar una pèrdua de detalls i de sintaxi.', - 'pages_editor_switch_consideration_c' => 'Els canvis al registre de canvis o a les etiquetes fets d’ençà de la darrera vegada que s’ha desat no es mantindran en aquest canvi.', + 'pages_edit_set_changelog' => 'Registre de canvis', + 'pages_edit_enter_changelog_desc' => 'Introduïu una descripció breu dels canvis que heu fet', + 'pages_edit_enter_changelog' => 'Registra un canvi', + 'pages_editor_switch_title' => 'Canvia l’editor', + 'pages_editor_switch_are_you_sure' => 'Esteu segur que voleu canviar l’editor d’aquesta pàgina?', + 'pages_editor_switch_consider_following' => 'Quan canvieu l’editor tingueu en compte que:', + 'pages_editor_switch_consideration_a' => 'Un cop s’hagi desat, l’editor nou serà el que hagin d’utilitzar tots els editor futurs, incloent-hi aquells que no puguin canviar el tipus d’editor.', + 'pages_editor_switch_consideration_b' => 'És possible que hi hagi pèrdues en el detall i la sintaxi.', + 'pages_editor_switch_consideration_c' => 'Els canvis en les etiquetes i el registre de canvis que s’hagin fet després de la darrera vegada que es va desar no es conservaran.', 'pages_save' => 'Desa la pàgina', 'pages_title' => 'Títol de la pàgina', 'pages_name' => 'Nom de la pàgina', 'pages_md_editor' => 'Editor', - 'pages_md_preview' => 'Previsualització', + 'pages_md_preview' => 'Visualització prèvia', 'pages_md_insert_image' => 'Insereix una imatge', - 'pages_md_insert_link' => 'Insereix un enllaç a una entitat', - 'pages_md_insert_drawing' => 'Insereix un diagrama', - 'pages_md_show_preview' => 'Mostra la previsualització', - 'pages_md_sync_scroll' => 'Sincronitza el desplaçament de la previsualització', - 'pages_drawing_unsaved' => 'S’ha trobat un diagrama no desat', - 'pages_drawing_unsaved_confirm' => 'S’han trobat dades d’un diagrama d’un intent de desat fallit anterior. Voleu restaurar-les i continuar editant aquest diagrama no desat?', - 'pages_not_in_chapter' => 'La pàgina no pertany a cap capítol', + 'pages_md_insert_link' => 'Insereix un enllaç d’entitat', + 'pages_md_insert_drawing' => 'Insereix un dibuix', + 'pages_md_show_preview' => 'Mostra la visualització prèvia', + 'pages_md_sync_scroll' => 'Sincronitza el desplaçament de la visualització prèvia', + 'pages_drawing_unsaved' => 'S’ha trobat un dibuix sense desar', + 'pages_drawing_unsaved_confirm' => 'S’han trobat dades d’un dibuix d’un intent anterior de desar un dibuix. Voleu restaurar aquest dibuix no desat per a reprendre’n l’edició?', + 'pages_not_in_chapter' => 'La pàgina no és un capítol', 'pages_move' => 'Mou la pàgina', 'pages_copy' => 'Copia la pàgina', - 'pages_copy_desination' => 'Destinació de la còpia', - 'pages_copy_success' => 'Pàgina copiada correctament', + 'pages_copy_desination' => 'Copia la destinació', + 'pages_copy_success' => 'S’ha copiat la pàgina', 'pages_permissions' => 'Permisos de la pàgina', 'pages_permissions_success' => 'S’han actualitzat els permisos de la pàgina', 'pages_revision' => 'Revisió', 'pages_revisions' => 'Revisions de la pàgina', - 'pages_revisions_desc' => 'A continuació hi ha les revisions anteriors de la pàgina. Podeu mirar-les, comparar-les i restaurar-ne versions antigues si us ho permeten els permisos. És possible que l’historial complet de la pàgina no s’hi vegi reflectit perquè, depenent de la configuració del sistema, es poden haver esborrat automàticament revisions antigues.', - 'pages_revisions_named' => 'Revisions de la pàgina :pageName', - 'pages_revision_named' => 'Revisió de la pàgina :pageName', - 'pages_revision_restored_from' => 'Restaurada de núm. :id; :summary', - 'pages_revisions_created_by' => 'Creada per', + 'pages_revisions_desc' => 'A continuació hi ha la llista de totes les revisions d’aquesta pàgina. Si hi teniu permís, podeu mirar, comparar i restaurar versions antigues de la pàgina. És possible que no hi aparegui tot l’historial de la pàgina ja que, segons la configuració del sistema, és possible que s’hagin suprimit les revisions antigues automàticament.', + 'pages_revisions_named' => 'Revisions de la pàgina «:pageName»', + 'pages_revision_named' => 'Revisió de la pàgina «:pageName»', + 'pages_revision_restored_from' => 'S’ha restaurat de núm. :id: :summary', + 'pages_revisions_created_by' => 'Revisor:', 'pages_revisions_date' => 'Data de la revisió', - 'pages_revisions_number' => 'Núm. ', - 'pages_revisions_sort_number' => 'Número de revisió', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'Número de la revisió', 'pages_revisions_numbered' => 'Revisió núm. :id', 'pages_revisions_numbered_changes' => 'Canvis de la revisió núm. :id', 'pages_revisions_editor' => 'Tipus d’editor', 'pages_revisions_changelog' => 'Registre de canvis', 'pages_revisions_changes' => 'Canvis', 'pages_revisions_current' => 'Versió actual', - 'pages_revisions_preview' => 'Previsualitza', + 'pages_revisions_preview' => 'Visualització prèvia', 'pages_revisions_restore' => 'Restaura', 'pages_revisions_none' => 'Aquesta pàgina no té cap revisió', 'pages_copy_link' => 'Copia l’enllaç', - 'pages_edit_content_link' => 'Vés a la secció a l’editor', - 'pages_pointer_enter_mode' => 'Activa el mode de selecció de secció', - 'pages_pointer_label' => 'Opcions de la secció de la pàgina', - 'pages_pointer_permalink' => 'Enllaç permanent de la secció de la pàgina', - 'pages_pointer_include_tag' => 'Etiqueta d’inclusió de la secció de la pàgina', - 'pages_pointer_toggle_link' => 'Mode d’enllaç permanent, premeu per a mostrar l’etiqueta d‘inclusió', - 'pages_pointer_toggle_include' => 'Mode d’etiqueta d’inclusió, premeu per a mostrar l’enllaç permanent', - 'pages_permissions_active' => 'S’han activat els permisos de la pàgina', + 'pages_edit_content_link' => 'Ves a la secció a l’editor', + 'pages_pointer_enter_mode' => 'Entra al mode de selecció de secció', + 'pages_pointer_label' => 'Opcions de la secció de pàgina', + 'pages_pointer_permalink' => 'Enllaç permanent de la secció de pàgina', + 'pages_pointer_include_tag' => 'Etiqueta inclou de la secció de pàgina', + 'pages_pointer_toggle_link' => 'Enllaç permanent. Cliqueu per a mostrar l’etiqueta d’inclusió', + 'pages_pointer_toggle_include' => 'Etiqueta d’inclusió. Cliqueu per a mostrar l’enllaç permanent', + 'pages_permissions_active' => 'La pàgina té permisos', 'pages_initial_revision' => 'Publicació inicial', - 'pages_references_update_revision' => 'Actualització automàtica dels enllaços interns del sistema', + 'pages_references_update_revision' => 'Actualització automàtica dels enllaços interns', 'pages_initial_name' => 'Pàgina nova', - 'pages_editing_draft_notification' => 'Esteu editant un esborrany que es va desar per darrer cop :timeDiff.', - 'pages_draft_edited_notification' => 'Aquesta pàgina s’ha actualitzat d’ençà d’aleshores. Us recomanem que descarteu aquest esborrany.', - 'pages_draft_page_changed_since_creation' => 'Aquesta pàgina s’ha actualitzat d‘ençà que es va crear l’esborrany. Us recomanem que descarteu aquest esborrany i que aneu amb compte de no sobreescriure els canvis de la pàgina.', + 'pages_editing_draft_notification' => 'Esteu editant un esborrany que es va desar :timeDiff.', + 'pages_draft_edited_notification' => 'Des de llavors, s’ha actualitzat la pàgina. Us recomanem que descarteu aquest esborrany.', + 'pages_draft_page_changed_since_creation' => 'Des que es va crear l’esborrany, la pàgina s’ha actualitzat. Us recomanem que descarteu aquest esborrany o que aneu amb compte de no sobreescriure cap canvi.', 'pages_draft_edit_active' => [ 'start_a' => ':count usuaris han començat a editar aquesta pàgina', 'start_b' => ':userName ha començat a editar aquesta pàgina', - 'time_a' => 'd’ençà que la pàgina es va actualitzar per darrer cop', + 'time_a' => 'des que es va actualitzar aquesta pàgina', 'time_b' => 'en els darrers :minCount minuts', - 'message' => ':start :time. Aneu amb compte de no trepitjar-vos les actualitzacions entre vosaltres!', + 'message' => ':start :time. Aneu amb compte de no sobreescriure-us els canvis.', ], - 'pages_draft_discarded' => 'S’ha descartat l’esborrany! S’ha actualitzat l’editor amb el contingut actual de la pàgina', - 'pages_draft_deleted' => 'S’ha suprimit l’esborrany! S’ha actualitzat l’editor amb el contingut actual de la pàgina', - 'pages_specific' => 'Una pàgina específica', - 'pages_is_template' => 'Plantilla de pàgina', + 'pages_draft_discarded' => 'S’ha descartat l’esborrany. L’editor s’ha actualitzat amb el contingut actual de la pàgina', + 'pages_draft_deleted' => 'S’ha suprimit l’esborrany! L’editor s’ha actualitzat amb el contingut actual de la pàgina', + 'pages_specific' => 'Pàgina específica', + 'pages_is_template' => 'Plantilla de la pàgina', // Editor Sidebar 'toggle_sidebar' => 'Commuta la barra lateral', @@ -306,57 +306,57 @@ 'shelf_tags' => 'Etiquetes del prestatge', 'tag' => 'Etiqueta', 'tags' => 'Etiquetes', - 'tags_index_desc' => 'Es poden aplicar etiquetes al contingut dins del sistema per a aplicar un mode de categorització flexible. Les etiquetes poden tenir una clau i un valor, però el valor és opcional. Quan s’han aplican, el contingut es pot cercar fent servir el nom o el valor de l’etiqueta.', + 'tags_index_desc' => 'Es poden posar etiquetes al contingut per a aconseguir una categorització flexible. Les etiquetes han de tenir una clau i poden o no tenir un valor. Un cop s’hi han posat, es pot cercar el contingut utilitzant el nom o el valor de les etiquetes.', 'tag_name' => 'Nom de l’etiqueta', 'tag_value' => 'Valor de l’etiqueta (opcional)', - 'tags_explain' => "Afegiu etiquetes per a categoritzar millor el contingut. \n Podeu assignar un valor a cada etiqueta per a una organització més detallada.", - 'tags_add' => 'Afegeix una altra etiqueta', - 'tags_remove' => 'Elimina aquesta etiqueta', - 'tags_usages' => 'Usos totals de l’etiqueta', - 'tags_assigned_pages' => 'Assignada a pàgines', - 'tags_assigned_chapters' => 'Assignada a capítols', - 'tags_assigned_books' => 'Assignada a llibres', - 'tags_assigned_shelves' => 'Assignada a prestatges', + 'tags_explain' => "Poseu alguna etiqueta per a categoritzar millor el contingut. \n Podeu assignar un valor a l’etiqueta per a aconseguir una organització més detallada.", + 'tags_add' => 'Posa-hi una altra etiqueta', + 'tags_remove' => 'Lleva aquesta etiqueta', + 'tags_usages' => 'Ús total de les etiquetes', + 'tags_assigned_pages' => 'Assignades a pàgines', + 'tags_assigned_chapters' => 'Assignades a capítols', + 'tags_assigned_books' => 'Assignades a llibres', + 'tags_assigned_shelves' => 'Assignades a prestatges', 'tags_x_unique_values' => ':count valors únics', 'tags_all_values' => 'Tots els valors', 'tags_view_tags' => 'Mostra les etiquetes', 'tags_view_existing_tags' => 'Mostra les etiquetes existents', - 'tags_list_empty_hint' => 'Es poden assignar etiquetes mitjançant la barra lateral de l’editor de la pàgina o quan s’editen els detalls d’un llibre, capítol o prestatge.', - 'attachments' => 'Adjuncions', - 'attachments_explain' => 'Pugeu fitxers o adjunteu enllaços per a mostrar-los a la pàgina. Són visibles a la barra lateral de la pàgina.', - 'attachments_explain_instant_save' => 'Els canvis fets aquí es desen instantàniament.', + 'tags_list_empty_hint' => 'Podeu posar etiquetes a la barra lateral de l’editor o quan canvieu la informació d’un capítol o d’un prestatge.', + 'attachments' => 'Fitxers adjunts', + 'attachments_explain' => 'Pugeu fitxers o afegiu enllaços per a mostrar a la pàgina. Els podreu veure a la barra lateral de la pàgina.', + 'attachments_explain_instant_save' => 'Els canvis dels fitxers adjunts es desen a l’instant.', 'attachments_upload' => 'Puja un fitxer', - 'attachments_link' => 'Adjunta un enllaç', - 'attachments_upload_drop' => 'De manera alternativa, podeu arrossegar i deixar anar un fitxer aquí per a pujar-lo com a adjunció.', - 'attachments_set_link' => 'Defineix l’enllaç', - 'attachments_delete' => 'Segur que voleu suprimir aquesta adjunció?', - 'attachments_dropzone' => 'Deixeu anar fitxers aquí per a pujar-los', + 'attachments_link' => 'Afegeix un enllaç', + 'attachments_upload_drop' => 'També podeu arrossegar i deixar anar un fitxer aquí per a adjuntar-lo.', + 'attachments_set_link' => 'Configura l’enllaç', + 'attachments_delete' => 'Esteu segur que voleu suprimir aquest fitxer adjunt?', + 'attachments_dropzone' => 'Deixeu-hi anar fitxers per a pujar-los', 'attachments_no_files' => 'No s’ha pujat cap fitxer', - 'attachments_explain_link' => 'Podeu adjuntar un enllaç si preferiu no pujar un fitxer. Pot ser un enllaç a una altra pàgina o un enllaç a un fitxer al núvol.', + 'attachments_explain_link' => 'Si us estimeu més no pujar un fitxer, podeu afegir un enllaç. Pot ser un enllaç en una altra pàgina o a un fitxer al núvol.', 'attachments_link_name' => 'Nom de l’enllaç', - 'attachment_link' => 'Enllaç de l’adjunció', + 'attachment_link' => 'Enllaç del fitxer adjunt', 'attachments_link_url' => 'Enllaç al fitxer', - 'attachments_link_url_hint' => 'URL del lloc o fitxer', + 'attachments_link_url_hint' => 'URL del lloc o del fitxer.', 'attach' => 'Adjunta', - 'attachments_insert_link' => 'Afegeix un enllaç de l’adjunció a la pàgina', + 'attachments_insert_link' => 'Afegeix un enllaç de fitxer adjunt a la pàgina', 'attachments_edit_file' => 'Edita el fitxer', 'attachments_edit_file_name' => 'Nom del fitxer', - 'attachments_edit_drop_upload' => 'Arrossegueu fitxers o feu clic aquí per a pujar-los i sobreescriure’ls', - 'attachments_order_updated' => 'S’ha actualitzat l’ordre de les adjuncions', - 'attachments_updated_success' => 'S’han actualitzat els detalls de les adjuncions', - 'attachments_deleted' => 'S’ha suprimit l’adjunció', - 'attachments_file_uploaded' => 'Fitxer pujat correctament', - 'attachments_file_updated' => 'Fitxer actualitzat correctament', - 'attachments_link_attached' => 'Enllaç adjuntat a la pàgina correctament', + 'attachments_edit_drop_upload' => 'Deixeu-hi anar fitxers o cliqueu aquí per a pujar-ne i sobreescriure’ls.', + 'attachments_order_updated' => 'S’ha actualitzat l’ordre del fitxer adjunt', + 'attachments_updated_success' => 'S’han actualitzat els detalls dels fitxer adjunt', + 'attachments_deleted' => 'S’ha suprimit el fitxer adjunt', + 'attachments_file_uploaded' => 'S’ha pujat el fitxer', + 'attachments_file_updated' => 'S’ha actualitzat el fitxer', + 'attachments_link_attached' => 'S’ha adjuntat l’enllaç a la pàgina', 'templates' => 'Plantilles', 'templates_set_as_template' => 'La pàgina és una plantilla', - 'templates_explain_set_as_template' => 'Podeu definir aquesta pàgina com a plantilla perquè el seu contingut es pugui fer servir en crear altres pàgines. Els altres usuaris podran fer servir la plantilla si tenen permís per a veure aquesta pàgina.', + 'templates_explain_set_as_template' => 'Podeu configurar aquesta pàgina com a plantilla perquè s’utilitzi en la creació d’altres pàgines. Els altres usuaris podran utilitzar aquesta plantilla si tenen permisos de visualització d’aquesta pàgina.', 'templates_replace_content' => 'Substitueix el contingut de la pàgina', - 'templates_append_content' => 'Afegeix al final del contingut de la pàgina', - 'templates_prepend_content' => 'Afegeix al principi del contingut de la pàgina', + 'templates_append_content' => 'Afegeix-ho després del contingut la pàgina', + 'templates_prepend_content' => 'Afegeix-ho abans del contingut la pàgina', // Profile View - 'profile_user_for_x' => 'Usuari fa :time', + 'profile_user_for_x' => 'Usuari des de fa :time', 'profile_created_content' => 'Contingut creat', 'profile_not_created_pages' => ':userName no ha creat cap pàgina', 'profile_not_created_chapters' => ':userName no ha creat cap capítol', @@ -367,71 +367,71 @@ 'comment' => 'Comentari', 'comments' => 'Comentaris', 'comment_add' => 'Afegeix un comentari', - 'comment_placeholder' => 'Deixeu un comentari aquí', - 'comment_count' => '{0} Sense comentaris|{1} 1 comentari|[2,*] :count comentaris', + 'comment_placeholder' => 'Deixa un comentari aquí', + 'comment_count' => '{0} No hi ha cap comentari|{1} Hi ha 1 comentari|[2,*] Hi ha :count comentaris', 'comment_save' => 'Desa el comentari', - 'comment_new' => 'Comentari nou', + 'comment_new' => 'Crea un comentari', 'comment_created' => 'ha comentat :createDiff', - 'comment_updated' => 'Actualitzat :updateDiff per :username', + 'comment_updated' => ':username l’ha actualitzat :updateDiff', 'comment_updated_indicator' => 'Actualitzat', - 'comment_deleted_success' => 'Comentari suprimit', - 'comment_created_success' => 'Comentari afegit', - 'comment_updated_success' => 'Comentari actualitzat', - 'comment_delete_confirm' => 'Segur que voleu suprimir aquest comentari?', + 'comment_deleted_success' => 'S’ha suprimit el comentari', + 'comment_created_success' => 'S’ha afegit un comentari', + 'comment_updated_success' => 'S’ha actualitzat un comentari', + 'comment_delete_confirm' => 'Esteu segur que voleu suprimir aquest comentari?', 'comment_in_reply_to' => 'En resposta a :commentId', - 'comment_editor_explain' => 'Aquí hi ha els comentaris que s’han deixat en aquesta pàgina. Els comentaris es poden afegir i gestionar en veure una pàgina desada.', + 'comment_editor_explain' => 'Vet aquí els comentaris que s’han fet en aquesta pàgina. Els comentaris es poden fer i gestionar quan es visualitza la pàgina desada.', // Revision - 'revision_delete_confirm' => 'Segur que voleu suprimir aquesta revisió?', - 'revision_restore_confirm' => 'Segur que voleu restaurar aquesta revisió? Se substituirà el contingut de la pàgina actual.', + 'revision_delete_confirm' => 'Esteu segur que voleu suprimir aquesta revisió?', + 'revision_restore_confirm' => 'Esteu segur que voleu restaurar aquesta revisió? Se substituirà el contingut actual de la pàgina.', 'revision_cannot_delete_latest' => 'No es pot suprimir la darrera revisió.', // Copy view - 'copy_consider' => 'Tingueu en compte el següent quan copieu contingut.', + 'copy_consider' => 'Quan copieu contingut, tingueu en compte el següent:', 'copy_consider_permissions' => 'No es copiarà la configuració personalitzada de permisos.', - 'copy_consider_owner' => 'Esdevindreu el nou propietari de qualsevol contingut copiat.', - 'copy_consider_images' => 'Els fitxers d’imatge de les pàgines no es duplicaran i les imatges originals mantindran la relació amb la pàgina a la qual es van pujar originalment.', - 'copy_consider_attachments' => 'No es copiaran les adjuncions de la pàgina.', - 'copy_consider_access' => 'Un canvi d’ubicació, propietari o permisos pot provocar que aquest contingut esdevingui accessible a públic que abans no n’hi tenia.', + 'copy_consider_owner' => 'Sereu el propietari de tot el contingut copiat.', + 'copy_consider_images' => 'Els fitxers d’imatge de la pàgina no es duplicaran i les imatges originals conservaran la relació amb la pàgina a què es van pujar originalment.', + 'copy_consider_attachments' => 'No es copiaran els fitxers adjunts de la pàgina.', + 'copy_consider_access' => 'És possible que, en canviar la ubicació, el propietari o els permisos, el contingut esdevingui accessible a usuaris que no hi tenien accés.', // Conversions 'convert_to_shelf' => 'Converteix en prestatge', - 'convert_to_shelf_contents_desc' => 'Podeu convertir aquest llibre en un prestatge nou amb el mateix contingut. Els capítols continguts en aquest llibre es convertiran en nous lliures. Si aquest llibre conté pàgines que no pertanyin a cap capítol, aquest llibre canviarà de nom i les contindrà, i aquest llibre esdevindrà part del nou prestatge.', - 'convert_to_shelf_permissions_desc' => 'Qualsevol permís definit en aquest llibre es copiarà al nou prestatge i a tots els nous llibres fills que no tinguin permisos explícits. Tingueu en compte que els permisos dels prestatges no s’hereten automàticament al contingut que continguin de la mateixa manera que passa amb els llibres.', + 'convert_to_shelf_contents_desc' => 'Pots convertir aquest llibre en un prestatge nou amb els mateixos continguts. Els capítols d’aquest llibre es convertiran en llibres. Si aquest llibre conté cap pàgina que no sigui en un capítol, es canviarà el nom del llibre que contindrà aquestes pàgines i el llibre formarà part del prestatge nou.', + 'convert_to_shelf_permissions_desc' => 'Es copiaran els permisos d’aquest llibre al prestatge nou i a tots els llibres que contingui que no tinguin els seus propis permisos. Tingueu en compte que els permisos dels prestatges no es propaguen als seus continguts com sí que ho fan els dels llibres.', 'convert_book' => 'Converteix el llibre', - 'convert_book_confirm' => 'Segur que voleu convertir aquest llibre?', - 'convert_undo_warning' => 'No es podrà desfer de manera fàcil.', + 'convert_book_confirm' => 'Esteu segur que voleu convertir aquest llibre?', + 'convert_undo_warning' => 'Això no es pot desfer fàcilment.', 'convert_to_book' => 'Converteix en llibre', - 'convert_to_book_desc' => 'Podeu convertir aquest capítol en un llibre nou amb el mateix contingut. Qualsevol permís definit en aquest capítol es copiarà al nou llibre, però qualsevol permís heretat del llibre pare no es copiarà, la qual cosa podria implicar canvis en el control d’accés.', + 'convert_to_book_desc' => 'Podeu convertir aquest capítol en un llibre nou amb els mateixos continguts. Es copiaran els permisos d’aquest capítol al llibre nou, però no es copiaran els permisos heretats del llibre a què pertany cosa que podria canviar-ne el control d’accés.', 'convert_chapter' => 'Converteix el capítol', - 'convert_chapter_confirm' => 'Segur que voleu convertir aquest capítol?', + 'convert_chapter_confirm' => 'Esteu segur que voleu convertir aquest capítol?', // References 'references' => 'Referències', - 'references_none' => 'Ni hi ha cap referència detectada a aquest element.', - 'references_to_desc' => 'A continuació es llisten tots els continguts coneguts del sistema que enllacen a aquest element.', + 'references_none' => 'No hi ha cap referència cap a aquest element.', + 'references_to_desc' => 'A la llista que hi ha a continuació, hi trobareu tot el contingut que enllaça cap aquest element.', // Watch Options 'watch' => 'Segueix', - 'watch_title_default' => 'Preferències per defecte', - 'watch_desc_default' => 'Restableix el seguiment a només les preferències de notificació per defecte.', + 'watch_title_default' => 'Preferències predeterminades', + 'watch_desc_default' => 'Reverteix el seguiment a les preferències de notificació per defecte.', 'watch_title_ignore' => 'Ignora', - 'watch_desc_ignore' => 'Ignora totes les notificacions, incloent-hi les preferències a nivell d’usuari.', + 'watch_desc_ignore' => 'Ignora totes les notificacions, incloent-hi les de les preferències de nivell usuari.', 'watch_title_new' => 'Pàgines noves', - 'watch_desc_new' => 'Notifica’m quan es creï qualsevol pàgina nova dins d’aquest element.', - 'watch_title_updates' => 'Totes les actualitzacions de pàgines', - 'watch_desc_updates' => 'Notifica’m totes les pàgines noves i qualsevol canvi de pàgina.', - 'watch_desc_updates_page' => 'Notifica’m tots els canvis de pàgina.', - 'watch_title_comments' => 'Totes les actualitzacions de pàgines i comentaris', - 'watch_desc_comments' => 'Notifica’m totes les pàgines noves, qualsevol canvi de pàgina i comentaris nous.', - 'watch_desc_comments_page' => 'Notifica’m qualsevol canvi de pàgina i comentaris nous.', - 'watch_change_default' => 'Canvieu les preferències de notificació per deecte', - 'watch_detail_ignore' => 'S’ignoren les notificacions', - 'watch_detail_new' => 'Se segueixen pàgines noves', - 'watch_detail_updates' => 'Se segueixen pàgines noves i actualitzacions', - 'watch_detail_comments' => 'Se segueixen pàgines noves, actualitzacions i comentaris', - 'watch_detail_parent_book' => 'Se segueix mitjançant el llibre pare', - 'watch_detail_parent_book_ignore' => 'S’ignora mitjançant el llibre pare', - 'watch_detail_parent_chapter' => 'Se segueix mitjançant el capítol pare', - 'watch_detail_parent_chapter_ignore' => 'S’ignora mitjançant el capítol pare', + 'watch_desc_new' => 'Notifica’m la creació d’una pàgina nova dins d’aquest element.', + 'watch_title_updates' => 'Actualitzacions de la pàgina', + 'watch_desc_updates' => 'Notifica’m totes les pàgines noves i totes les actualitzacions de les pàgines.', + 'watch_desc_updates_page' => 'Notifica’m totes les actualitzacions de les pàgines.', + 'watch_title_comments' => 'Actualitzacions i comentaris de la pàgina', + 'watch_desc_comments' => 'Notifica’m totes les pàgines noves, totes les actualitzacions de les pàgines i tots els comentaris nous.', + 'watch_desc_comments_page' => 'Notifica’m tots les actualitzacions de pàgines i tots els comentaris nous.', + 'watch_change_default' => 'Canvia les preferències de notificació per defecte', + 'watch_detail_ignore' => 'S’estan ignorant les notificacions', + 'watch_detail_new' => 'S’està fent el seguiment de pàgines noves', + 'watch_detail_updates' => 'S’està fent el seguiment de pàgines noves i les actualitzacions de les pàgines', + 'watch_detail_comments' => 'S’està fent el seguiment de pàgines noves i les actualitzacions de les pàgines i els comentaris', + 'watch_detail_parent_book' => 'S’està fent el seguiment a través del llibre de què és part', + 'watch_detail_parent_book_ignore' => 'S’està ignorant a través del llibre de què és part', + 'watch_detail_parent_chapter' => 'S’està fent el seguiment a través del capítol de què és part', + 'watch_detail_parent_chapter_ignore' => 'S’està ignorant a través del capítol de què és part', ]; diff --git a/lang/ca/errors.php b/lang/ca/errors.php index 5655276961f..e30ef3d9725 100644 --- a/lang/ca/errors.php +++ b/lang/ca/errors.php @@ -6,115 +6,115 @@ // Permissions 'permission' => 'No teniu permís per a accedir a la pàgina sol·licitada.', - 'permissionJson' => 'No teniu permís per a executar l’acció sol·licitada.', + 'permissionJson' => 'No teniu permís per a fer l’acció sol·licitada.', // Auth - 'error_user_exists_different_creds' => 'Ja hi ha un usuari amb l’adreça electrònica :email però amb credencials diferents.', + 'error_user_exists_different_creds' => 'Ja existeix un usuari amb el correu electrònic :email però amb unes credencials diferents.', 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', - 'email_already_confirmed' => 'L’adreça electrònica ja està confirmada. Proveu d’iniciar la sessió.', + 'email_already_confirmed' => 'Ja s’ha confirmat el correu electrònic. Proveu d’iniciar sessió.', 'email_confirmation_invalid' => 'Aquest testimoni de confirmació no és vàlid o ja s’ha utilitzat. Proveu de tornar-vos a registrar.', - 'email_confirmation_expired' => 'El testimoni de confirmació ha caducat. S’ha enviat un nou correu electrònic de confirmació.', - 'email_confirmation_awaiting' => 'Cal confirmar l’adreça electrònica del compte que utilitzeu', - 'ldap_fail_anonymous' => 'L’accés a l’LDAP ha fallat fent servir un lligam anònim', - 'ldap_fail_authed' => 'L’accés a l’LDAP ha fallat fent servir els detalls de DN i contrasenya proporcionats', - 'ldap_extension_not_installed' => 'L’extensió de l’LDAP del PHP no està instal·lada', - 'ldap_cannot_connect' => 'No s’ha pogut connectar amb el servidor de l’LDAP, la connexió inicial ha fallat', - 'saml_already_logged_in' => 'Ja heu iniciat la sessió', - 'saml_no_email_address' => 'No s’ha pogut trobar cap adreça electrònica per a aquest usuari en les dades proporcionades pel sistema d’autenticació extern', - 'saml_invalid_response_id' => 'La petició del sistema d’autenticació extern no és reconeguda per un procés iniciat per aquesta aplicació. Aquest problema podria ser causat per navegar endarrere després d’iniciar la sessió.', - 'saml_fail_authed' => 'L’inici de sessió fent servir :system ha fallat, el sistema no ha proporcionat una autorització satisfactòria', - 'oidc_already_logged_in' => 'Ja teniu una sessió iniciada', - 'oidc_no_email_address' => 'No s’ha pogut trobar cap adreça electrònica per a aquest usuari en les dades proporcionades pel sistema d’autenticació extern', - 'oidc_fail_authed' => 'L’inici de sessió fent servir :system ha fallat, el sistema no ha proporcionat una autorització satisfactòria', - 'social_no_action_defined' => 'No hi ha cap acció definida', - 'social_login_bad_response' => "S’ha rebut un error mentre s’iniciava la sessió amb :socialAccount: \n:error", - 'social_account_in_use' => 'Aquest compte de :socialAccount ja està en ús, proveu d’iniciar la sessió mitjançant l’opció de :socialAccount.', - 'social_account_email_in_use' => 'L’adreça electrònica :email ja està en ús. Si ja teniu un compte, podeu connectar-hi el vostre compte de :socialAccount a la configuració del vostre perfil.', + 'email_confirmation_expired' => 'Aquest testimoni de confirmació ha caducat. S’ha enviat un altre correu electrònic de confirmació.', + 'email_confirmation_awaiting' => 'Cal confirmar l’adreça electrònica del compte que utilitzeu.', + 'ldap_fail_anonymous' => 'L’accés LDAP anònim ha fallat', + 'ldap_fail_authed' => 'L’accés LDAP amb el nom distintiu i la contrasenya proporcionades', + 'ldap_extension_not_installed' => 'L’extensió PHP de l’LDAP no està instal·lada', + 'ldap_cannot_connect' => 'No s’ha pogut connectar amb el servidor LDAP perquè la connexió inicial ha fallat', + 'saml_already_logged_in' => 'Ja heu iniciat sessió', + 'saml_no_email_address' => 'No s’ha pogut trobar una adreça electrònica per a aquest usuari a les dades proporcionades pel sistema d’autenticació extern', + 'saml_invalid_response_id' => 'Un procés iniciat per aquesta aplicació no reconeix la sol·licitud del sistema d’autenticació extern. Haver navegat enrere després d’iniciar sessió en podria ser la causa.', + 'saml_fail_authed' => 'No s’ha pogut iniciar sessió amb :system perquè el sistema no ha proporcionat una autorització satisfactòria', + 'oidc_already_logged_in' => 'Ja heu iniciat sessió', + 'oidc_no_email_address' => 'No s’ha pogut trobar una adreça electrònica per a aquest usuari a les dades proporcionades pel sistema d’autenticació extern', + 'oidc_fail_authed' => 'No s’ha pogut iniciar sessió amb :system perquè el sistema no ha proporcionat una autorització satisfactòria', + 'social_no_action_defined' => 'No s’ha definit cap acció', + 'social_login_bad_response' => "S’ha produït un error en l’inici de sessió amb :socialAccount: \n:error", + 'social_account_in_use' => 'Aquest compte de :socialAccount ja s’està utilitzant. Proveu d’iniciar sessió amb :socialAccount.', + 'social_account_email_in_use' => 'L’adreça electrònica :email ja s’està utilitzant. Si ja teniu uns compte podeu connectar-hi el vostre compte de :socialAccount des de la configuració del vostre perfil.', 'social_account_existing' => 'Aquest compte de :socialAccount ja està associat al vostre perfil.', - 'social_account_already_used_existing' => 'Aquest compte de :socialAccount ja el fa servir un altre usuari.', + 'social_account_already_used_existing' => 'Aquest compte de :socialAccount ja està associat a un altre usuari.', 'social_account_not_used' => 'Aquest compte de :socialAccount no està associat a cap usuari. Associeu-lo a la configuració del vostre perfil. ', - 'social_account_register_instructions' => 'Si encara no teniu cap compte, podeu registrar-vos fent servir l’opció de :socialAccount.', + 'social_account_register_instructions' => 'Si encara no teniu un compte, podeu registrar-vos amb l’opció :socialAccount.', 'social_driver_not_found' => 'No s’ha trobat el controlador social', - 'social_driver_not_configured' => 'La configuració social de :socialAccount no és correcta.', - 'invite_token_expired' => 'Aquest enllaç d’invitació ha caducat. Podeu provar de restablir la contrasenya del vostre compte.', + 'social_driver_not_configured' => 'La configuració de :socialAccount no és correcta.', + 'invite_token_expired' => 'Aquest enllaç d’invitació ha caducat. Proveu de reinicialitzar la contrasenya.', 'login_user_not_found' => 'A user for this action could not be found.', // System - 'path_not_writable' => 'No s’ha pogut pujar al camí del fitxer :filePath. Assegureu-vos que el servidor hi té permisos d’escriptura.', - 'cannot_get_image_from_url' => 'No s’ha pogut obtenir la imatge de :url', - 'cannot_create_thumbs' => 'El servidor no pot crear miniatures. Reviseu que tingueu instal·lada l’extensió GD del PHP.', - 'server_upload_limit' => 'El servidor no permet pujades d’aquesta mida. Proveu-ho amb una mida de fitxer més petita.', - 'server_post_limit' => 'El servidor no pot rebre la quantitat de dades que heu proporcionat. Torneu-ho a provar amb menys dades o un fitxer més petit.', - 'uploaded' => 'El servidor no permet pujades d’aquesta mida. Proveu-ho amb una mida de fitxer més petita.', + 'path_not_writable' => 'No s’ha pogut pujar a :filePath. Assegureu-vos que teniu permisos d’escriptura al servidor.', + 'cannot_get_image_from_url' => 'No s’ha pogut obtenir la imatge des de :url', + 'cannot_create_thumbs' => 'El servidor no pot crear miniatures. Assegureu-vos que s’ha instal·lat l’extensió de GD PHP.', + 'server_upload_limit' => 'El servidor no permet pujades d’aquesta mida. Proveu-ho amb una mida més petita.', + 'server_post_limit' => 'El servidor no pot rebre la quantitat de dades proporcionades. Proveu-ho amb menys dades o amb un fitxer més petit.', + 'uploaded' => 'El servidor no permet pujades d’aquesta mida. Proveu-ho amb un fitxer més petit.', // Drawing & Images - 'image_upload_error' => 'S’ha produït un error en pujar la imatge', - 'image_upload_type_error' => 'El tipus d’imatge que heu pujat no és vàlid', - 'image_upload_replace_type' => 'Les substitucions de fitxers d’imatge han de ser el mateix tipus', - 'image_upload_memory_limit' => 'No s’ha pogut gestionar la pujada de la imatge i/o crear-ne miniatures a causa dels límits dels recursos del sistema.', - 'image_thumbnail_memory_limit' => 'No s’ha pogut crear les variacions de mida de la imatge a causa dels límits dels recursos del sistema.', + 'image_upload_error' => 'S’ha produït un error en pujar la imatge.', + 'image_upload_type_error' => 'El tipus d’imatge no és vàlid.', + 'image_upload_replace_type' => 'Els fitxers d’imatge s’han de substituir per un del mateix tipus', + 'image_upload_memory_limit' => 'No s’ha pogut pujar la imatge o crear-ne les miniatures a causa dels límits dels recursos del sistema.', + 'image_thumbnail_memory_limit' => 'No s’han pogut crear mides d’imatge diferents a causa dels límits dels recursos del sistema.', 'image_gallery_thumbnail_memory_limit' => 'No s’han pogut crear les miniatures de la galeria a causa dels límits dels recursos del sistema.', - 'drawing_data_not_found' => 'No s’han pogut carregar les dades de dibuix. És possible que el fitxer de dibuix ja no existeixi o que no tingueu permisos per a accedir-hi.', + 'drawing_data_not_found' => 'No s’han pogut pujar les dades del dibuix. És possible que el fitxer del dibuix ja no existeixi o que no tingueu permís per a accedir-hi.', // Attachments - 'attachment_not_found' => 'No s’ha trobat l’adjunció', - 'attachment_upload_error' => 'S’ha produït un error en pujar el fitxer de l’adjunció', + 'attachment_not_found' => 'No s’ha trobat el fitxer adjunt', + 'attachment_upload_error' => 'S’ha produït un error en pujar el fitxer adjunt', // Pages - 'page_draft_autosave_fail' => 'No s’ha pogut desar l’esborrany. Assegureu-vos que teniu connexió a Internet abans de desar la pàgina', - 'page_draft_delete_fail' => 'No s’ha pogut suprimir l’esborrany de la pàgina i obtenir el contingut desat actual de la pàgina', - 'page_custom_home_deletion' => 'No es pot suprimir una pàgina mentre estigui definida com a pàgina d’inici', + 'page_draft_autosave_fail' => 'No s’ha pogut desar l’esborrany. Assegureu-vos que esteu connectat a internet per a desar la pàgina.', + 'page_draft_delete_fail' => 'No s’ha pogut suprimir l’esborrany de la pàgina i obtenir el contingut de la pàgina desada actual.', + 'page_custom_home_deletion' => 'No es pot suprimir una pàgina que està definida com a pàgina d’inici.', // Entities - 'entity_not_found' => 'No s’ha trobat l’entitat', - 'bookshelf_not_found' => 'No s’ha trobat el prestatge', - 'book_not_found' => 'No s’ha trobat el llibre', - 'page_not_found' => 'No s’ha trobat la pàgina', - 'chapter_not_found' => 'No s’ha trobat el capítol', - 'selected_book_not_found' => 'No s’ha trobat el llibre seleccionat', - 'selected_book_chapter_not_found' => 'No s’ha trobat el llibre o el capítol seleccionat', - 'guests_cannot_save_drafts' => 'Els convidats no poden desar esborranys', + 'entity_not_found' => 'No s’ha trobat l’entitat.', + 'bookshelf_not_found' => 'No s’ha trobat el prestatge.', + 'book_not_found' => 'No s’ha trobat el llibre.', + 'page_not_found' => 'No s’ha trobat la pàgina.', + 'chapter_not_found' => 'No s’ha trobat el capítol.', + 'selected_book_not_found' => 'No s’ha trobat el llibre seleccionat.', + 'selected_book_chapter_not_found' => 'No s’ha trobat el llibre o el capítol seleccionat.', + 'guests_cannot_save_drafts' => 'Els convidats no poden desar esborranys.', // Users - 'users_cannot_delete_only_admin' => 'No podeu suprimir l’únic administrador', - 'users_cannot_delete_guest' => 'No podeu suprimir l’usuari convidat', + 'users_cannot_delete_only_admin' => 'No podeu suprimir l’administrador únic.', + 'users_cannot_delete_guest' => 'No podeu suprimir l’usuari convidat.', // Roles - 'role_cannot_be_edited' => 'Aquest rol no es pot editar', - 'role_system_cannot_be_deleted' => 'Aquest rol és un rol del sistema i no es pot suprimir', - 'role_registration_default_cannot_delete' => 'No es pot suprimir aquest rol mentre estigui definit com a rol per defecte dels registres', - 'role_cannot_remove_only_admin' => 'Aquest usuari és l’únic usuari assignat al rol d’administrador. Assigneu el rol d’administrador a un altre usuari abans de provar de suprimir aquest.', + 'role_cannot_be_edited' => 'No es pot editar aquest rol.', + 'role_system_cannot_be_deleted' => 'No es pot suprimir aquest rol perquè és un rol del sistema.', + 'role_registration_default_cannot_delete' => 'No es pot suprimir aquest rol mentre estigui configurat com a rol de registre per defecte.', + 'role_cannot_remove_only_admin' => 'Aquest és l’únic usuari assignat al rol d’administrador. Assigneu el rol d’administrador a un altre usuari abans de provar de suprimir-lo.', // Comments 'comment_list' => 'S’ha produït un error en obtenir els comentaris.', - 'cannot_add_comment_to_draft' => 'No podeu afegir comentaris a un esborrany.', + 'cannot_add_comment_to_draft' => 'No es poden afegir comentaris en un esborrany.', 'comment_add' => 'S’ha produït un error en afegir o actualitzar el comentari.', 'comment_delete' => 'S’ha produït un error en suprimir el comentari.', - 'empty_comment' => 'No podeu afegir un comentari buit.', + 'empty_comment' => 'No es pot afegir un comentari buit.', // Error pages '404_page_not_found' => 'No s’ha trobat la pàgina', - 'sorry_page_not_found' => 'No hem pogut trobar la pàgina que cerqueu.', - 'sorry_page_not_found_permission_warning' => 'Si esperàveu que existís, és possible que no tingueu permisos per a veure-la.', + 'sorry_page_not_found' => 'No s’ha trobat la pàgina que heu cercat.', + 'sorry_page_not_found_permission_warning' => 'Si la pàgina existeix, és possible que no tingueu permís per a accedir-hi.', 'image_not_found' => 'No s’ha trobat la imatge', - 'image_not_found_subtitle' => 'No ha estat possible trobar el fitxer de la imatge que cerqueu.', - 'image_not_found_details' => 'Si esperàveu que existís, és possible que s’hagi suprimit.', - 'return_home' => 'Torna a l’inici', - 'error_occurred' => 'S’ha produït un error', - 'app_down' => ':appName està fora de servei en aquests moments', - 'back_soon' => 'Tornarà a estar disponible aviat.', + 'image_not_found_subtitle' => 'No s’ha trobat la imatge que heu cercat.', + 'image_not_found_details' => 'És possible que s’hagi suprimit.', + 'return_home' => 'Torna a la pàgina d’inici', + 'error_occurred' => 'S’ha produït un error.', + 'app_down' => ':appName està fora de servei.', + 'back_soon' => 'Aviat ho arreglarem.', // API errors - 'api_no_authorization_found' => 'No s’ha trobat cap testimoni d’autorització a la petició', - 'api_bad_authorization_format' => 'S’ha trobat un testimoni d’autorització a la petició, però el format sembla erroni', - 'api_user_token_not_found' => 'No s’ha trobat cap testimoni de l’API per al testimoni d’autorització proporcionat', - 'api_incorrect_token_secret' => 'El secret proporcionat per al testimoni de l’API proporcionat és incorrecte', - 'api_user_no_api_permission' => 'El propietari del testimoni de l’API utilitzat no té permís per a fer crides a l’API', - 'api_user_token_expired' => 'El testimoni d’autorització utilitzat ha caducat', + 'api_no_authorization_found' => 'No s’ha trobat cap testimoni d’autorització en aquesta sol·licitud.', + 'api_bad_authorization_format' => 'S’ha trobat un testimoni d’autorització en aquesta sol·licitud però no tenia el format correcte.', + 'api_user_token_not_found' => 'No s’ha trobat cap testimoni d’API per al testimoni d’autorització proporcionat.', + 'api_incorrect_token_secret' => 'El secret proporcionat per al testimoni d’API utilitzat no és correcte.', + 'api_user_no_api_permission' => 'El propietari del testimoni API utilitzat no té permís per a fer crides a l’API.', + 'api_user_token_expired' => 'El testimoni d’autorització utilitzat ha caducat.', // Settings & Maintenance - 'maintenance_test_email_failure' => 'S’ha produït un error en enviar un correu electrònic de prova:', + 'maintenance_test_email_failure' => 'S’ha produït un error en enviar el correu electrònic de prova:', // HTTP errors - 'http_ssr_url_no_match' => 'L’URL no coincideix amb els amfitrions SSR permesos segons la configuració', + 'http_ssr_url_no_match' => 'L’URL no coincideix amb els amfitrions SSR configurats permesos.', ]; diff --git a/lang/ca/notifications.php b/lang/ca/notifications.php index 8f86b85ad52..fdea33436de 100644 --- a/lang/ca/notifications.php +++ b/lang/ca/notifications.php @@ -4,24 +4,24 @@ */ return [ - 'new_comment_subject' => 'Comentari nou a la pàgina :pageName', - 'new_comment_intro' => 'Un usuari ha comentat en una pàgina de :appName:', - 'new_page_subject' => 'Pàgina nova: :pageName', - 'new_page_intro' => 'S’ha creat una pàgina nova a :appName:', - 'updated_page_subject' => 'Pàgina actualitzada: :pageName', - 'updated_page_intro' => 'S’ha actualitzat una pàgina a :appName:', - 'updated_page_debounce' => 'Per a evitar les notificacions massives, no us enviarem notificacions si hi ha més edicions en aquesta pàgina fetes pel mateix editor.', + 'new_comment_subject' => 'S’ha fet un comentari a la pàgina :pageName.', + 'new_comment_intro' => 'S’ha fet un comentari nou en una pàgina a :appName.', + 'new_page_subject' => 'S’ha creat la pàgina :pageName', + 'new_page_intro' => 'S’ha creat una pàgina nova a :appName.', + 'updated_page_subject' => 'S’ha actualitzat la pàgina :pageName', + 'updated_page_intro' => 'S’ha actualitzat una pàgina a :appName.', + 'updated_page_debounce' => 'Per a evitar que s’acumulin les notificacions, durant un temps no se us notificarà cap canvi fet en aquesta pàgina pel mateix usuari.', 'detail_page_name' => 'Nom de la pàgina:', 'detail_page_path' => 'Camí de la pàgina:', 'detail_commenter' => 'Autor del comentari:', 'detail_comment' => 'Comentari:', - 'detail_created_by' => 'Creat per:', - 'detail_updated_by' => 'Actualitzat per:', + 'detail_created_by' => 'Creada per:', + 'detail_updated_by' => 'Actualitzada per:', 'action_view_comment' => 'Mostra el comentari', 'action_view_page' => 'Mostra la pàgina', - 'footer_reason' => 'Rebeu aquesta notificació perquè :link cobreixen aquest tipus d’activitat en aquest element.', - 'footer_reason_link' => 'les vostres preferències de notificacions', + 'footer_reason' => 'Heu rebut aquesta notificació perquè :link inclouen aquest tipus d’activitat per a aquest element.', + 'footer_reason_link' => 'les vostres preferències de notificació', ]; diff --git a/lang/ca/passwords.php b/lang/ca/passwords.php index 60789c8a248..aa727ad4b61 100644 --- a/lang/ca/passwords.php +++ b/lang/ca/passwords.php @@ -6,10 +6,10 @@ */ return [ - 'password' => 'Les contrasenyes han de tenir almenys vuit caràcters i coincidir amb la confirmació.', - 'user' => "No s’ha trobat cap usuari amb aquesta adreça electrònica.", + 'password' => 'Les contrasenyes han de tenir almenys 8 caràcters i han de coincidir amb la confirmació de contrasenya.', + 'user' => "No es pot trobar un usuari amb aquesta adreça electrònica.", 'token' => 'El testimoni de restabliment de la contrasenya no és vàlid per a aquesta adreça electrònica.', 'sent' => 'Us hem enviat un enllaç per a restablir la contrasenya!', - 'reset' => 'S’ha restablert la contrasenya!', + 'reset' => 'S’ha reinicialitzat la contrasenya.', ]; diff --git a/lang/ca/preferences.php b/lang/ca/preferences.php index 9d9ed8aa10d..25206726ecd 100644 --- a/lang/ca/preferences.php +++ b/lang/ca/preferences.php @@ -7,45 +7,45 @@ return [ 'my_account' => 'El meu compte', - 'shortcuts' => 'Dreceres', - 'shortcuts_interface' => 'Preferències de les dreceres de la interfície d’usuari', - 'shortcuts_toggle_desc' => 'Aquí podeu activar o desactivar les dreceres de teclat de la interfície del sistema, que s’utilitzen per a la navegació i les accions.', - 'shortcuts_customize_desc' => 'Podeu personalitzar cadascuna de les dreceres següents. Premeu la combinació de tecles desitjada després de seleccionar la casella d’una drecera.', - 'shortcuts_toggle_label' => 'Dreceres de teclat activades', + 'shortcuts' => 'Dreceres de teclat', + 'shortcuts_interface' => 'Preferències de dreceres de teclat de la interfície d’usuari', + 'shortcuts_toggle_desc' => 'Configureu les dreceres de teclat de la interfície d’usuari del sistema utilitzades per a la navegació i les accions.', + 'shortcuts_customize_desc' => 'Podeu personalitzar cadascuna de les dreceres a continuació. seleccionant el camp d’entrada de cada drecera i prement la combinació de tecles que vulgueu.', + 'shortcuts_toggle_label' => 'Activa les dreceres de teclat', 'shortcuts_section_navigation' => 'Navegació', 'shortcuts_section_actions' => 'Accions habituals', 'shortcuts_save' => 'Desa les dreceres', - 'shortcuts_overlay_desc' => 'Nota: Quan s’activen les dreceres, hi ha una interfície d’ajuda disponible en prémer «?» que destaca les dreceres disponibles per a accions visibles actualment a la pantalla.', - 'shortcuts_update_success' => 'S’han actualitzat les preferències de les dreceres!', - 'shortcuts_overview_desc' => 'Gestioneu les dreceres de teclat que podeu utilitzar per a navegar per la interfície d’usuari del sistema.', + 'shortcuts_overlay_desc' => 'Nota: Quan les dreces estan activades hi haurà disponible una ajuda que es pot obtenir prement «?» que ressaltarà les dreceres disponibles a la pantalla que s’estigui visualitzant.', + 'shortcuts_update_success' => 'S’han actualitzat les preferències de drecera.', + 'shortcuts_overview_desc' => 'Gestioneu les dreceres que s’utilitzen per a navegar per la interfície d’usuari.', - 'notifications' => 'Preferències de notificació', - 'notifications_desc' => 'Controleu les notificacions per correu electrònic que rebeu quan es fan certes activitats dins del sistema.', - 'notifications_opt_own_page_changes' => 'Notifica’m quan hi hagi canvis a pàgines de les quals sóc propietari', - 'notifications_opt_own_page_comments' => 'Notifica’m quan hi hagi comentaris a pàgines de les quals sóc propietari', - 'notifications_opt_comment_replies' => 'Notifica’m quan hi hagi respostes als meus comentaris', + 'notifications' => 'Preferències de les notificacions', + 'notifications_desc' => 'Gestioneu les notificacions de correu electrònic que rebreu quan es facin certes activitats.', + 'notifications_opt_own_page_changes' => 'Notifica’m els canvis a les meves pàgines.', + 'notifications_opt_own_page_comments' => 'Notifica’m la creació de comentaris a les meves pàgines.', + 'notifications_opt_comment_replies' => 'Notifica’m les respostes als meus comentaris.', 'notifications_save' => 'Desa les preferències', - 'notifications_update_success' => 'S’han actualitzat les preferències de notificacions!', + 'notifications_update_success' => 'S’han actualitzat les preferències de notificació', 'notifications_watched' => 'Elements seguits i ignorats', - 'notifications_watched_desc' => 'A continuació hi ha els elements que tenen aplicades preferències de seguiment personalitzades. Per a actualitzar-ne les preferències, consulteu l’element i seleccioneu les opcions de seguiment a la barra lateral.', + 'notifications_watched_desc' => 'A continuació hi ha els elements que tenen preferències de seguiment personalitzades. Per a actualitzar-les, accediu a l’element i configureu-les a la barra lateral.', 'auth' => 'Accés i seguretat', 'auth_change_password' => 'Canvia la contrasenya', - 'auth_change_password_desc' => 'Canvieu la contrasenya que feu servir per a iniciar la sessió a l’aplicació. Cal que tingui un mínim de 8 caràcters.', - 'auth_change_password_success' => 'S’ha actualitzat la contrasenya!', + 'auth_change_password_desc' => 'Canvieu la contrasenya que s’utilitzarà per a iniciar sessió a l’aplicació. Ha de tenir com a mínim 8 caràcters.', + 'auth_change_password_success' => 'S’ha actualitzat la contrasenya.', - 'profile' => 'Detalls del perfil', - 'profile_desc' => 'Gestioneu els detalls del vostre compte, que us representa davant d’altres usuaris, i també els detalls que s’utilitzen per a la comunicació i la personalització del sistema.', - 'profile_view_public' => 'Mostra el perfil públic', - 'profile_name_desc' => 'Configureu el vostre nom públic, que és visible als altres usuaris del sistema a través de les activitats que realitzeu i del contingut del qual sou propietari.', - 'profile_email_desc' => 'Aquest correu s’utilitza per a notificacions i, depenent de l’autenticació activa del sistema, per a l’accés al sistema.', - 'profile_email_no_permission' => 'Malauradament, no teniu permisos per a canviar l’adreça electrònica. Si voleu canviar-la, caldrà que demaneu a un administrador que us faci el canvi.', - 'profile_avatar_desc' => 'Seleccioneu una imatge que us representarà davant d’altres usuaris del sistema. Idealment, aquesta imatge hauria de ser un quadrat de 256 px d’alçada i d’amplada.', - 'profile_admin_options' => 'Opcions d’administració', - 'profile_admin_options_desc' => 'Podeu trobar opcions de nivell d’administració addicionals del vostre compte, com ara les que permeten gestionar les assignacions de rols, a l’àrea de l’aplicació «Configuració > Usuaris».', + 'profile' => 'Informació del perfil', + 'profile_desc' => 'Gestioneu la informació del vostre compte que representa com us veuran els altres usuaris a més de la informació que s’utilitza per a la comunicació i la personalització del sistema.', + 'profile_view_public' => 'Mostra’m el perfil públic', + 'profile_name_desc' => 'Configureu el nom públic que veuran els altres usuaris a les activitats que feu i al vostre contingut.', + 'profile_email_desc' => 'Aquesta serà l’adreça electrònica on s’enviaran les notificacions i que, segons l’autenticació del sistema activada, s’utilitzarà per a iniciar sessió.', + 'profile_email_no_permission' => 'No teniu permís per a canviar la vostra adreça electrònica. Si voleu canviar-la, demaneu-ho a un administrador.', + 'profile_avatar_desc' => 'Seleccioneu una imatge que us representi davant dels altres usuaris. És millor que sigui una imatge quadrada de 256px de costat.', + 'profile_admin_options' => 'Preferències d’administrador', + 'profile_admin_options_desc' => 'Podeu trobar preferències addicionals d’administrador pel vostre compte, com ara les que gestionen els rols d’usuari, a «Preferències › Usuaris».', 'delete_account' => 'Suprimeix el compte', 'delete_my_account' => 'Suprimeix el meu compte', - 'delete_my_account_desc' => 'Se suprimirà completament el vostre compte d’usuari del sistema. No podreu recuperar aquest compte ni revertir aquesta acció. El contingut que hàgiu creat, com ara les pàgines creades o les imatges pujades, es mantindrà.', - 'delete_my_account_warning' => 'Segur que voleu suprimir el vostre compte?', + 'delete_my_account_desc' => 'Se suprimirà completament del sistema el vostre compte d’usuari. No podreu recuperar el compte ni revertir-ne la supressió. Es conservarà el contingut que hàgiu creat, com ara les pàgines o les imatges que hàgiu pujat.', + 'delete_my_account_warning' => 'Esteu segur que voleu suprimir el vostre compte?', ]; diff --git a/lang/ca/settings.php b/lang/ca/settings.php index 5fe9d3bffe7..6bb1e8b6989 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -8,119 +8,119 @@ // Common Messages 'settings' => 'Configuració', - 'settings_save' => 'Desa la configuració', - 'system_version' => 'Versió del sistema', + 'settings_save' => 'Configuració de desat', + 'system_version' => 'Versió de sistema', 'categories' => 'Categories', // App Settings 'app_customization' => 'Personalització', - 'app_features_security' => 'Funcionalitats i seguretat', + 'app_features_security' => 'Funcions i seguretat', 'app_name' => 'Nom de l’aplicació', - 'app_name_desc' => 'Aquest nom es mostra a la capçalera i en tots els correus electrònics enviats pel sistema.', + 'app_name_desc' => 'El nom es mostra a la capçalera i als correus electrònics enviats pel sistema.', 'app_name_header' => 'Mostra el nom a la capçalera', 'app_public_access' => 'Accés públic', - 'app_public_access_desc' => 'Si activeu aquesta opció, es permetrà que els visitants que no hagin iniciat la sessió accedeixin al contingut de la vostra instància del BookStack.', - 'app_public_access_desc_guest' => 'Podeu controlar l’accés dels visitants públics amb l’usuari «Convidat».', + 'app_public_access_desc' => 'Si activeu aquesta opció les visitants podran accedir a la vostra instància del BookStack sense iniciar sessió.', + 'app_public_access_desc_guest' => 'L’accés per als visitants públics es pot gestionar amb l’usuari «Convidat».', 'app_public_access_toggle' => 'Permet l’accés públic', - 'app_public_viewing' => 'Voleu permetre la visualització pública?', - 'app_secure_images' => 'Pujades d’imatges amb més seguretat', - 'app_secure_images_toggle' => 'Activa les pujades d’imatges amb més seguretat', - 'app_secure_images_desc' => 'Per motius de rendiment, totes les imatges són públiques. Aquesta opció afegeix una cadena aleatòria i difícil d’endevinar al davant dels URL d’imatges. Assegureu-vos que els índexs de directoris no estiguin activats per a evitar-hi l’accés de manera fàcil.', + 'app_public_viewing' => 'Esteu segur que voleu permetre l’accés públic?', + 'app_secure_images' => 'Pujada d’imatges amb més seguretat', + 'app_secure_images_toggle' => 'Habilita la pujada d’imatges amb més seguretat', + 'app_secure_images_desc' => 'Per raons de rendiment, totes les imatges són públiques. Aquesta opció afegeix una cadena aleatòria que és difícil d’endevinar al davant de l’URL de les imatges. Assegureu-vos que els índex de carpetes estan desactivats perquè no s’hi pugui accedir fàcilment.', 'app_default_editor' => 'Editor de pàgines per defecte', - 'app_default_editor_desc' => 'Seleccioneu quin editor es farà servir per defecte en editar pàgines noves. Es pot canviar a nivell de pàgina si els permisos ho permeten.', + 'app_default_editor_desc' => 'Seleccioneu quin editor s’utilitzarà per defecte quan s’editin pàgines noves. Això es pot sobreescriure a nivell de pàgina si els permisos ho permeten.', 'app_custom_html' => 'Contingut personalitzat a la capçalera HTML', - 'app_custom_html_desc' => 'Aquí podeu afegir contingut que s’inserirà a la part final de la secció de cada pàgina. És útil per a sobreescriure estils o afegir-hi codi d’analítiques.', - 'app_custom_html_disabled_notice' => 'El contingut personalitzat a la capçalera HTML es desactiva en aquesta pàgina de la configuració per a assegurar que qualsevol canvi que trenqui el web es pugui desfer.', - 'app_logo' => 'Logo de l’aplicació', - 'app_logo_desc' => 'Es fa servir a la barra de la capçalera de l’aplicació, a banda d’altres zones. Aquesta imatge ha de fer 86 px d’alçada. Les imatges massa grosses es reduiran.', + 'app_custom_html_desc' => 'El contingut que s’afegeixi aquí s’inserirà al final de la secció de cada pàgina. Això permet sobreescriure els estils o afegir codi d’analítiques web.', + 'app_custom_html_disabled_notice' => 'El contingut personalitzat a la capçalera HTML està desactivat en aquesta pàgina perquè es puguin revertir els canvis que trenquin el lloc web.', + 'app_logo' => 'Logotip de l’aplicació', + 'app_logo_desc' => 'El logotip s’utilitzarà a la barra de la capçalera de l’aplicació, entre d’altres llocs. La imatge ha de tenir 86px d’alçada. Les imatges més grans es reduiran.', 'app_icon' => 'Icona de l’aplicació', - 'app_icon_desc' => 'Aquesta icona es fa servir a la pestanya del navegador i a les icones de les dreceres. Hauria de ser una imatge PNG quadrada de 256 px.', + 'app_icon_desc' => 'La icona s’utilitzarà a les pestanyes del navegador i a les icones de les adreces. La imatge ha de ser un PNG quadrat de 256px de costat.', 'app_homepage' => 'Pàgina d’inici de l’aplicació', - 'app_homepage_desc' => 'Seleccioneu la visualització que es mostrarà a la pàgina d’inici en lloc de la visualització per defecte. Els permisos de pàgines s’ignoraran per a les pàgines seleccionades.', - 'app_homepage_select' => 'Selecciona una pàgina', - 'app_footer_links' => 'Enllaços al peu de pàgina', - 'app_footer_links_desc' => 'Afegiu enllaços que es mostraran al peu de pàgina del lloc. Es mostraran a la part inferior de la majoria de pàgines, incloent-hi les que no requereixen iniciar la sessió. Podeu utilitzar l’etiqueta «trans::» per a fer servir traduccions definides pel sistema. Per exemple, si feu servir «trans::common.privacy_policy», es mostrarà el text traduït «Política de privadesa», i amb «trans::common.terms_of_service» es mostrarà el text traduït «Condicions del servei».', - 'app_footer_links_label' => 'Etiqueta de l’enllaç', + 'app_homepage_desc' => 'Seleccioneu una vista per a mostrar a la pàgina d’inici en comptes de la vista per defecte. Els permisos de pàgina s’ignoren a les pàgines seleccionades.', + 'app_homepage_select' => 'Seleccioneu una pàgina', + 'app_footer_links' => 'Enllaços del peu de pàgina', + 'app_footer_links_desc' => 'Afegiu enllaços per a mostrar al peu de pàgina. Els enllaços es mostraran al final de la majoria de les pàgines, incloent-hi les pàgines per a les que no es requereix iniciar sessió. Podeu utilitzar una etiqueta «trans::» per a utilitzar traduccions definides pel sistema. Per exemple: Si utilitzeu «trans::common.privacy_policy» es mostrarà la traducció de «Privacy Policy» i si utilitzeu «trans::common.terms_of_service» es mostrarà la traducció de «Terms of Service».', + 'app_footer_links_label' => 'Text de l’enllaç', 'app_footer_links_url' => 'URL de l’enllaç', - 'app_footer_links_add' => 'Afegeix un enllaç al peu de pàgina', + 'app_footer_links_add' => 'Afegeix l’enllaç en el peu de pàgina', 'app_disable_comments' => 'Desactiva els comentaris', 'app_disable_comments_toggle' => 'Desactiva els comentaris', - 'app_disable_comments_desc' => 'Desactiva els comentaris a totes les pàgines de l’aplicació.
    Els comentaris existents no es mostraran.', + 'app_disable_comments_desc' => 'Desactivarà els comentaris a totes les pàgines de l’aplicació.
    Els comentaris existents no es mostraran.', // Color settings 'color_scheme' => 'Esquema de colors de l’aplicació', - 'color_scheme_desc' => 'Definiu els colors que s’utilitzaran a la interfície d’usuari de l’aplicació. Els colors es poden configurar de manera separada per als modes fosc i clar perquè encaixin millor amb el tema i n’assegurin la llegibilitat.', - 'ui_colors_desc' => 'Definiu el color primari de l’aplicació i el color per defecte dels enllaços. El color primari es fa servir sobretot per a la capçalera, els botons i la decoració de la interfície. El color per defecte dels enllaços s’utilitza per als enllaços de text i les accions, tant al contingut escrit com a la interfície de l’aplicació.', - 'app_color' => 'Color primari', + 'color_scheme_desc' => 'Configureu els colors que s’utilitzaran a la interfície d’usuari de l’aplicació. Podeu configurar els colors pel mode clar o el mode fosc per separat perquè escaigui millor al tema i assegurar la llegibilitat.', + 'ui_colors_desc' => 'Configureu el color principal de l’aplicació i el color per defecte dels enllaços. El color principal s’utilitza al bàner de la capçalera, els botons i les decoracions de la interfície. El color per defecte dels enllaços s’utilitza a les accions i els enllaços de text, tant al contingut escrit com a la interfície de l’aplicació.', + 'app_color' => 'Color principal', 'link_color' => 'Color per defecte dels enllaços', - 'content_colors_desc' => 'Definiu els colors per a tots els elements de la jerarquia d’organització de pàgines. És recomanable triar colors amb una brillantor similar a la dels colors per defecte per a millorar la llegibilitat.', + 'content_colors_desc' => 'Configureu els colors per a tots els elements en la jerarquia de l’organització de la pàgina. És recomanable que trieu uns colors amb una brillantor similar a la dels colors per defecte per assegurar la llegibilitat.', 'bookshelf_color' => 'Color dels prestatges', 'book_color' => 'Color dels llibres', 'chapter_color' => 'Color dels capítols', 'page_color' => 'Color de les pàgines', - 'page_draft_color' => 'Color dels esborranys de pàgines', + 'page_draft_color' => 'Color de les pàgines d’esborrany', // Registration Settings 'reg_settings' => 'Registre', 'reg_enable' => 'Activa el registre d’usuaris', 'reg_enable_toggle' => 'Activa el registre d’usuaris', - 'reg_enable_desc' => 'Si els registres estan activats, els usuaris podran registrar-se ells mateixos com a usuaris de l’aplicació. Un cop registrats, se’ls assigna un únic rol d’usuari per defecte.', - 'reg_default_role' => 'Rol d’usuari per defecte en registrar-se', - 'reg_enable_external_warning' => 'L’opció anterior s’ignora quan hi ha activada l’autenticació SAML o LDAP externa. Els comptes d’usuari de membres inexistents es crearan automàticament si l’autenticació contra el sistema extern és satisfactòria.', - 'reg_email_confirmation' => 'Confirmació de correu electrònic', - 'reg_email_confirmation_toggle' => 'Requereix la confirmació per correu electrònic', - 'reg_confirm_email_desc' => 'Si s’utilitza la restricció de dominis, serà obligatòria la confirmació per correu electrònic, i s’ignorarà aquesta opció.', + 'reg_enable_desc' => 'Quan el registre està activat, els usuaris es podran registrar com a usuari de l’aplicació. Un cop registrat, se’ls assignarà un rol d’usuari per defecte únic.', + 'reg_default_role' => 'Rol d’usuari per defecte després del registre.', + 'reg_enable_external_warning' => 'S’ignorarà l’opció de sobre quan l’autenticació LDAP or SAML estigui activada. Es crearan automàticament comptes d’usuari per als membres que encara no ho siguin si no és possible l’autenticació amb els sistema d’autenticació extern.', + 'reg_email_confirmation' => 'Correu electrònic de confirmació', + 'reg_email_confirmation_toggle' => 'Requereix un correu electrònic de confirmació', + 'reg_confirm_email_desc' => 'Si s’utilitza la restricció de dominis es requerirà un correu electrònic de confirmació i s’ignorarà aquesta opció.', 'reg_confirm_restrict_domain' => 'Restricció de dominis', - 'reg_confirm_restrict_domain_desc' => 'Introduïu una llista separada per comes de dominis de correu electrònic als quals voleu restringir els registres. S’enviarà un correu electrònic als usuaris perquè confirmin la seva adreça abans de permetre’ls interactuar amb l’aplicació.
    Tingueu en compte que els usuaris podran canviar les seves adreces electròniques després de registrar-se correctament.', + 'reg_confirm_restrict_domain_desc' => 'Introduïu una llista separada per comes dels dominis a què voleu restringir el registre. S’enviarà un correu electrònic als usuaris perquè confirmin la seva adreça electrònica abans que puguin interactuar amb l’aplicació.
    Tingueu en compte que els usuaris podran canviar l’adreça electrònica un cop s’hagin registrat.', 'reg_confirm_restrict_domain_placeholder' => 'No hi ha cap restricció', // Maintenance settings 'maint' => 'Manteniment', - 'maint_image_cleanup' => 'Neteja les imatges', - 'maint_image_cleanup_desc' => 'Escaneja el contingut de les pàgines i les revisions per a comprovar quines imatges i diagrames estan en ús actualment i quines imatges són redundants. Assegureu-vos de crear una còpia de seguretat completa de la base de dades i de les imatges abans d’executar això.', - 'maint_delete_images_only_in_revisions' => 'Suprimeix també les imatges que només existeixin en revisions antigues de pàgines', + 'maint_image_cleanup' => 'Neteja d’imatges', + 'maint_image_cleanup_desc' => 'Escanegeu les pàgines i les revisions per a comprovar quines imatges o dibuixos s’utilitzen i quins no. Assegureu-vos de crear una còpia de seguretat completa de la base de dades i de les imatges abans d’executar-la.', + 'maint_delete_images_only_in_revisions' => 'Suprimiu també les imatges que només existeixen en revisions de pàgina antigues.', 'maint_image_cleanup_run' => 'Executa la neteja', - 'maint_image_cleanup_warning' => 'S’han trobat :count imatges potencialment no utilitzades. Segur que voleu suprimir aquestes imatges?', - 'maint_image_cleanup_success' => 'S’han trobat i suprimit :count imatges potencialment no utilitzades!', - 'maint_image_cleanup_nothing_found' => 'No s’ha trobat cap imatge no utilitzada, així que no s’ha suprimit res!', - 'maint_send_test_email' => 'Envia un correu electrònic de prova', - 'maint_send_test_email_desc' => 'Envia un correu electrònic de prova a l’adreça electrònica que hàgiu especificat al perfil.', + 'maint_image_cleanup_warning' => 'Imatges que potencialment no s’utilitzen: :count. Esteu segur que voleu suprimir aquestes imatges?', + 'maint_image_cleanup_success' => 'Imatges que potencialment no s’utilitzen que s’han suprimit: :count.', + 'maint_image_cleanup_nothing_found' => 'No s’ha trobat cap imatge que no s’utilitzi. No s’ha suprimit res.', + 'maint_send_test_email' => 'Envia un correu electrònic de prova.', + 'maint_send_test_email_desc' => 'S’enviarà un correu electrònic de prova a l’adreça electrònica que figura al vostre perfil.', 'maint_send_test_email_run' => 'Envia el correu electrònic de prova', - 'maint_send_test_email_success' => 'S’ha enviat el correu electrònic a :address', + 'maint_send_test_email_success' => 'S’ha enviat un correu electrònic a :address', 'maint_send_test_email_mail_subject' => 'Correu electrònic de prova', - 'maint_send_test_email_mail_greeting' => 'El lliurament de correus electrònics sembla que funciona!', - 'maint_send_test_email_mail_text' => 'Enhorabona! Com que heu rebut aquesta notificació per correu electrònic, la vostra configuració del correu electrònic sembla que està ben configurada.', - 'maint_recycle_bin_desc' => 'Els prestatges, llibres, capítols i pàgines suprimits s’envien a la paperera de reciclatge perquè es puguin restaurar o suprimir de manera permanent. Pot ser que els elements més antics de la paperera de reciclatge se suprimeixin automàticament després d’un temps, depenent de la configuració del sistema.', - 'maint_recycle_bin_open' => 'Obre la paperera de reciclatge', + 'maint_send_test_email_mail_greeting' => 'Sembla que l’enviament de correus electrònics funciona.', + 'maint_send_test_email_mail_text' => 'Enhorabona! Que hagis rebut aquesta notificació de correu electrònic vol dir que la vostra configuració de correu electrònic és correcta.', + 'maint_recycle_bin_desc' => 'Els prestatges, els llibres, els capítols i les pàgines suprimides s’envien a la paperera perquè es puguin restaurar o suprimir permanentment. És possible que els elements que fa més temps que són a la paperera s’eliminin automàticament al cap d’un temps segons la configuració del sistema.', + 'maint_recycle_bin_open' => 'Obre la paperera', 'maint_regen_references' => 'Regenera les referències', - 'maint_regen_references_desc' => 'Aquesta acció reconstruirà l’índex de referències entre elements de la base de dades. Normalment es gestiona automàticament, però aquesta acció pot ser útil per a indexar contingut antic o contingut afegit mitjançant mètodes no oficials.', - 'maint_regen_references_success' => 'L’índex de referències s’ha regenerat!', - 'maint_timeout_command_note' => 'Nota: Aquesta acció pot trigar estona a executar-se, la qual cosa pot provocar errors de temps d’expera excedits en alguns entorns web. Com a alternativa, podeu executar aquesta acció en una ordre al terminal.', + 'maint_regen_references_desc' => 'Aquesta acció reconstruirà l’índex de referències creuades entre elements a la base de dades. Normalment es fa automàticament però aquesta acció és útil per a indexar contingut antic o contingut afegit a través de mètodes no oficials.', + 'maint_regen_references_success' => 'S’ha regenerat l’índex de referències.', + 'maint_timeout_command_note' => 'Nota: És possible que aquesta acció trigui a executar-se cosa que pot provocar que s’excedeixi el temps d’espera en alguns entorns web. Com a alternativa, podeu executar aquesta acció amb una ordre del terminal.', // Recycle Bin - 'recycle_bin' => 'Paperera de reciclatge', - 'recycle_bin_desc' => 'Aquí podeu restaurar els elements que hàgiu suprimit o triar suprimir-los del sistema de manera permanent. Aquesta llista no té cap filtre, al contrari que altres llistes d’activitat similars en què es tenen en compte els filtres de permisos.', + 'recycle_bin' => 'Paperera', + 'recycle_bin_desc' => 'Aquí podeu restaurar els elements que s’han suprimit o suprimir-los permanentment del sistema. Aquesta llista no està filtrada, a diferència de llistes d’activitat similars on s’apliquen filtres de permisos.', 'recycle_bin_deleted_item' => 'Element suprimit', 'recycle_bin_deleted_parent' => 'Pare', 'recycle_bin_deleted_by' => 'Suprimit per', - 'recycle_bin_deleted_at' => 'Moment de la supressió', - 'recycle_bin_permanently_delete' => 'Suprimeix permanentment', + 'recycle_bin_deleted_at' => 'Hora de supressió', + 'recycle_bin_permanently_delete' => 'Suprimit permanentment', 'recycle_bin_restore' => 'Restaura', - 'recycle_bin_contents_empty' => 'La paperera de reciclatge és buida', - 'recycle_bin_empty' => 'Buida la paperera de reciclatge', - 'recycle_bin_empty_confirm' => 'Se suprimiran de manera permanent tots els elements de la paperera de reciclatge, incloent-hi el contingut dins de cada element. Segur que voleu buidar la paperera de reciclatge?', + 'recycle_bin_contents_empty' => 'La paperera és buida', + 'recycle_bin_empty' => 'Buida la paperera', + 'recycle_bin_empty_confirm' => 'Se suprimiran permanentment tots els elements que hi ha a la paperera incloent-hi el contingut que hi hagi a cada element. Esteu segur que voleu buidar la paperera?', 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', - 'recycle_bin_destroy_list' => 'Elements que es destruiran', - 'recycle_bin_restore_list' => 'Elements que es restauraran', - 'recycle_bin_restore_confirm' => 'Aquesta acció restaurarà l’element suprimit, incloent-hi tots els elements fills, a la seva ubicació original. Si la ubicació original ha estat suprimida, i ara és a la paperera de reciclatge, caldrà que també en restaureu l’element pare.', - 'recycle_bin_restore_deleted_parent' => 'El pare d’aquest element també ha estat suprimit. L’element es mantindrà suprimit fins que el pare també es restauri.', - 'recycle_bin_restore_parent' => 'Restaura’n el pare', - 'recycle_bin_destroy_notification' => 'S’han suprimit :count elements en total de la paperera de reciclatge.', - 'recycle_bin_restore_notification' => 'S’han restaurat :count elements en total de la paperera de reciclatge.', + 'recycle_bin_destroy_list' => 'Elements per destruir', + 'recycle_bin_restore_list' => 'Elements per restaurar', + 'recycle_bin_restore_confirm' => 'Aquesta acció restaurarà l’element suprimit, incloent-hi els elements fills, a la seva ubicació original. Si la ubicació original s’ha suprimit i és a la paperera, l’element pare també s’haurà de restaurar.', + 'recycle_bin_restore_deleted_parent' => 'El pare d’aquest element també s’ha suprimit. L’element continuarà suprimit fins que no se’n restauri també el pare.', + 'recycle_bin_restore_parent' => 'Restaura el pare', + 'recycle_bin_destroy_notification' => 'Elements suprimits de la paperera: :count.', + 'recycle_bin_restore_notification' => 'Elements restaurats de la paperera: :count.', // Audit Log 'audit' => 'Registre d’auditoria', - 'audit_desc' => 'Aquest registre d’auditoria mostra una llista d’activitats registrades al sistema. Aquesta llista no té cap filtre, al contrari que altres llistes d’activitat similars en què es tenen en compte els filtres de permisos.', + 'audit_desc' => 'El registre d\'auditoria mostra una llista de les activitats de què es fa un seguiment. Aquesta llista no està filtrada, a diferència de llistes d’activitat similars on s’apliquen filtres de permisos.', 'audit_event_filter' => 'Filtre d’esdeveniments', 'audit_event_filter_no_filter' => 'Sense filtre', 'audit_deleted_item' => 'Element suprimit', @@ -130,150 +130,150 @@ 'audit_table_related' => 'Element relacionat o detall', 'audit_table_ip' => 'Adreça IP', 'audit_table_date' => 'Data de l’activitat', - 'audit_date_from' => 'Rang de dates a partir de', - 'audit_date_to' => 'Rang de rates fins a', + 'audit_date_from' => 'Des de', + 'audit_date_to' => 'Fins a', // Role Settings 'roles' => 'Rols', 'role_user_roles' => 'Rols d’usuari', - 'roles_index_desc' => 'Els rols s’utilitzen per a agrupar usuaris i proporcionar permisos del sistema a llurs membres. Quan un usuari és membre de múltiples rols, els privilegis que li concedeixin s’acumularan i l’usuari heretarà totes les habilitats.', + 'roles_index_desc' => 'Els rols d’usuari s’utilitzen per a agrupar usuaris i donar-los permisos conjuntament. Un usuari que tingui més d’un rol acumularà els privilegis que s’atorguin a tots els rols i n’heretarà els permisos.', 'roles_x_users_assigned' => ':count usuari assignat|:count usuaris assignats', 'roles_x_permissions_provided' => ':count permís|:count permisos', 'roles_assigned_users' => 'Usuaris assignats', - 'roles_permissions_provided' => 'Permisos proporcionats', - 'role_create' => 'Crea un rol nou', - 'role_delete' => 'Suprimeix el rol', - 'role_delete_confirm' => 'Se suprimirà el rol amb el nom «:roleName».', - 'role_delete_users_assigned' => 'Aquest rol té :userCount usuaris assignats. Si voleu migrar els usuaris d’aquest rol, seleccioneu un rol nou a continuació.', + 'roles_permissions_provided' => 'Permisos atorgats', + 'role_create' => 'Crea un rol', + 'role_delete' => 'Suprimeix un rol', + 'role_delete_confirm' => 'Se suprimirà el rol «:roleName».', + 'role_delete_users_assigned' => 'Usuaris assignats en aquest rol: :userCount. Si voleu migrar aquests usuaris a un altre rol, seleccioneu-ne un dels de sota.', 'role_delete_no_migration' => "No migris els usuaris", - 'role_delete_sure' => 'Segur que voleu suprimir aquest rol?', + 'role_delete_sure' => 'Esteu segur que voleu suprimir aquest rol?', 'role_edit' => 'Edita el rol', 'role_details' => 'Detalls del rol', 'role_name' => 'Nom del rol', - 'role_desc' => 'Descripció curta del rol', - 'role_mfa_enforced' => 'Requereix autenticació de múltiple factor', + 'role_desc' => 'Descripció del rol', + 'role_mfa_enforced' => 'Autenticació multifactorial requerida', 'role_external_auth_id' => 'Identificadors d’autenticació externa', 'role_system' => 'Permisos del sistema', - 'role_manage_users' => 'Gestiona usuaris', - 'role_manage_roles' => 'Gestiona rols i permisos de rols', - 'role_manage_entity_permissions' => 'Gestiona els permisos de tots els llibres, capítols i pàgines', - 'role_manage_own_entity_permissions' => 'Gestiona els permisos dels llibres, capítols i pàgines propis', - 'role_manage_page_templates' => 'Gestiona les plantilles de pàgines', - 'role_access_api' => 'Accedeix a l’API del sistema', - 'role_manage_settings' => 'Gestiona la configuració de l’aplicació', - 'role_export_content' => 'Exporta el contingut', - 'role_editor_change' => 'Canvia l’editor de pàgines', - 'role_notifications' => 'Rep i gestiona les notificacions', + 'role_manage_users' => 'Gestió dels usuaris', + 'role_manage_roles' => 'Gestió dels rols i els seus permisos', + 'role_manage_entity_permissions' => 'Gestió de tots els permisos de llibres, capítols i pàgines', + 'role_manage_own_entity_permissions' => 'Gestió dels permisos als seus llibres, capítols i pàgines', + 'role_manage_page_templates' => 'Gestió de les plantilles de pàgina', + 'role_access_api' => 'Accés a l’API del sistema', + 'role_manage_settings' => 'Gestió de la configuració de l’aplicació', + 'role_export_content' => 'Exportació de contingut', + 'role_editor_change' => 'Canvi de l’editor de pàgina', + 'role_notifications' => 'Recepció i gestió de notificacions', 'role_asset' => 'Permisos de recursos', - 'roles_system_warning' => 'Tingueu en compte que l’accés a qualsevol dels tres permisos de dalt pot permetre que un usuari alteri els seus propis permisos o els privilegis d’altres usuaris del sistema. Assigneu rols amb aquests permisos només a usuaris de confiança.', - 'role_asset_desc' => 'Aquests permisos controlen l’accés per defecte als recursos del sistema. Els permisos de llibres, capítols i pàgines tindran més importància que aquests permisos.', - 'role_asset_admins' => 'Els administradors tenen accés automàticament a tot el contingut, però aquestes opcions poden mostrar o amagar opcions de la interfície d’usuari.', - 'role_asset_image_view_note' => 'Això és relatiu a la visibilitat dins del gestor d’imatges. L’accés real als fitxers d’imatge pujats dependrà de l’opció d’emmagatzematge d’imatges del sistema.', + 'roles_system_warning' => 'Tingueu en compte que l’accés a qualsevol dels tres permisos de dalt permeten que l’usuari canviï els seus privilegis i els privilegis d’altres usuaris. Assigneu rols d’usuari amb aquests permisos només a usuaris de confiança.', + 'role_asset_desc' => 'Aquests permisos controlen l’accés per defecte als recursos del sistema. El permisos dels llibres, capítols i pàgines sobreescriuran aquests permisos.', + 'role_asset_admins' => 'Als administradors se’ls dona accés automàticament a tot el contingut però aquestes opcions mostren o amaguen opcions de la interfície d’usuari.', + 'role_asset_image_view_note' => 'Això té relació amb la visibilitat al gestor d’imatges. L’accés a les imatges pujades dependrà de l’opció d’emmagatzematge d’imatges dels sistema.', 'role_all' => 'Tot', 'role_own' => 'Propi', - 'role_controlled_by_asset' => 'Controlat pel recurs en què es pugen', + 'role_controlled_by_asset' => 'Controlat pel recurs a què estan pujats', 'role_save' => 'Desa el rol', - 'role_users' => 'Usuaris amb aquest rol', - 'role_users_none' => 'Ara mateix no hi ha cap usuari assignat a aquest rol', + 'role_users' => 'Usuaris assignats en aquest rol', + 'role_users_none' => 'No hi ha cap usuari assignat en aquest rol', // Users 'users' => 'Usuaris', - 'users_index_desc' => 'Creeu i gestioneu comptes d’usuari individuals dins del sistema. Els comptes d’usuari s’utilitzen per a iniciar la sessió i atribuir el contingut i les activitats. Els permisos d’accés són principalment basats en rols, però si un usuari és propietari o no d’un contingut, entre altres factors, també pot afectar-ne els permisos i l’accés.', - 'user_profile' => 'Perfil de l’usuari', + 'users_index_desc' => 'Creeu i gestioneu comptes d’usuari individuals. Els comptes d’usuari s’utilitzen per als inicis de sessió i les atribucions de contingut i activitat. Els permisos d’accés es basen fonamentalment en el rol d’usuari però la propietat del contingut, entre d’altres, també afecta els permisos i l’accés.', + 'user_profile' => 'Perfil d’usuari', 'users_add_new' => 'Afegeix un usuari nou', 'users_search' => 'Cerca usuaris', - 'users_latest_activity' => 'Darrera activitat', + 'users_latest_activity' => 'Activitat més recent', 'users_details' => 'Detalls de l’usuari', - 'users_details_desc' => 'Definiu un nom públic i una adreça electrònica per a aquest usuari. L’adreça electrònica es farà servir per a iniciar la sessió a l’aplicació.', - 'users_details_desc_no_email' => 'Definiu un nom públic per a aquest usuari perquè els altres el puguin reconèixer.', - 'users_role' => 'Rols de l’usuari', - 'users_role_desc' => 'Seleccioneu a quins rols s’assignarà l’usuari. Si un usuari s’assigna a múltiples rols, els permisos dels rols s’acumularan i l’usuari rebrà totes les habilitats dels rols assignats.', - 'users_password' => 'Contrasenya de l’usuari', - 'users_password_desc' => 'Definiu una contrasenya que s’utilitzarà per a iniciar la sessió a l’aplicació. Cal que tingui un mínim de 8 caràcters.', - 'users_send_invite_text' => 'Podeu elegir enviar un correu d’invitació a aquest usuari, la qual cosa li permetrà definir la seva contrasenya, o podeu definir-li una contrasenya vós.', - 'users_send_invite_option' => 'Envia un correu d’invitació a l’usuari', + 'users_details_desc' => 'Configureu un nom i una adreça electrònica per a aquest usuari. L’adreça electrònica s’usarà per a iniciar sessió a l’aplicació.', + 'users_details_desc_no_email' => 'Configureu un nom per a aquest usuari perquè se’l pugui reconèixer.', + 'users_role' => 'Rols d’usuari', + 'users_role_desc' => 'Seleccioneu quins rols s’assignaran en aquest usuari. Si a un usuari se li assignen més d’un rol acumularà els privilegis que s’atorguin a tots els rols i n’heretarà les els permisos.', + 'users_password' => 'Contrasenya d’usuari', + 'users_password_desc' => 'Configureu la contrasenya que s’utilitzarà per a iniciar sessió a l’aplicació. Ha de tenir com a mínim 8 caràcters.', + 'users_send_invite_text' => 'Podeu configurar la contrasenya o enviar un correu electrònic que convidi a l’usuari a configurar-la.', + 'users_send_invite_option' => 'Envia el correu electrònic', 'users_external_auth_id' => 'Identificador d’autenticació extern', - 'users_external_auth_id_desc' => 'Quan es fa servir un sistema d’autenticació extern (com ara SAML2, OIDC o LDAP), aquest és l’identificador que enllaça aquest usuari del BookStack amb el compte del sistema d’autenticació. Podeu ignorar aquest camp si feu servir l’autenticació basada en correu electrònic per defecte.', - 'users_password_warning' => 'Ompliu-ho només si voleu canviar la contrasenya d’aquest usuari.', - 'users_system_public' => 'Aquest usuari representa qualsevol usuari convidat que visita la vostra instància. No es pot fer servir per a iniciar la sessió, però s’assigna automàticament.', + 'users_external_auth_id_desc' => 'Quan s’utilitza un sistema d’autenticació extern (com ara SAML2, OIDC or LDAP) l’identificador enllaça l’usuari amb el compte dels sistema d’autenticació. Podeu ignorar aquest camp si utilitzeu l’autenticació amb correu electrònic per defecte.', + 'users_password_warning' => 'Ompliu els camps de sota només si voleu canviar la contrasenya d’aquest usuari.', + 'users_system_public' => 'Aquest usuari representa qualsevol usuari convidat que visiti la instància. No es pot utilitzar per a iniciar sessió sinó que s’assigna automàticament.', 'users_delete' => 'Suprimeix l’usuari', 'users_delete_named' => 'Suprimeix l’usuari :userName', - 'users_delete_warning' => 'Se suprimirà completament del sistema l’usuari amb el nom «:userName».', - 'users_delete_confirm' => 'Segur que voleu suprimir aquest usuari?', - 'users_migrate_ownership' => 'Migra l’autoria', - 'users_migrate_ownership_desc' => 'Seleccioneu un usuari si voleu que un altre usuari esdevingui el propietari de tots els elements que ara són propietat d’aquest usuari.', - 'users_none_selected' => 'No hi ha cap usuari seleccionat', + 'users_delete_warning' => 'Se suprimirà l’usuari «:userName» del sistema.', + 'users_delete_confirm' => 'Esteu segur que voleu suprimir aquest usuari?', + 'users_migrate_ownership' => 'Migració de la propietat', + 'users_migrate_ownership_desc' => 'Si voleu que un altre usuari esdevingui el propietari de tots els elements d’aquest usuari, seleccioneu-ne un.', + 'users_none_selected' => 'No s’ha seleccionat cap usuari', 'users_edit' => 'Edita l’usuari', 'users_edit_profile' => 'Edita el perfil', - 'users_avatar' => 'Avatar de l’usuari', - 'users_avatar_desc' => 'Seleccioneu una imatge que representi aquest usuari. Hauria de ser un quadrat d’aproximadament 256 px.', - 'users_preferred_language' => 'Llengua preferida', - 'users_preferred_language_desc' => 'Aquesta opció canviarà la llengua utilitzada a la interfície d’usuari de l’aplicació. No afectarà el contingut creat pels usuaris.', - 'users_social_accounts' => 'Comptes socials', - 'users_social_accounts_desc' => 'Vegeu l’estat dels comptes socials connectats d’aquest usuari. Els comptes socials es poden fer servir per a accedir al sistema de manera addicional al sistema d’autenticació principal.', - 'users_social_accounts_info' => 'Aquí podeu connectar altres comptes per a un inici de sessió més ràpid i còmode. Si desconnecteu un compte aquí, no en revoqueu l’accés d’autorització donat amb anterioritat. Revoqueu-hi l’accés a la configuració del perfil del compte social que hàgiu connectat.', - 'users_social_connect' => 'Connecta un compte', + 'users_avatar' => 'Avatar', + 'users_avatar_desc' => 'Seleccioneu una imatge per a representar aquest usuari. Ha de ser una imatge quadrada de 256px de costat, aproximadament.', + 'users_preferred_language' => 'Llengua', + 'users_preferred_language_desc' => 'Canvia la llengua en què es mostra la interfície d’usuari de l’aplicació. No afectarà el contingut creat pels usuaris.', + 'users_social_accounts' => 'Comptes a les xarxes socials', + 'users_social_accounts_desc' => 'Mireu l’estat dels comptes socials connectats d’aquest usuari. També es poden utilitzar els comptes socials per a iniciar sessió a banda del sistema d’autenticació principal.', + 'users_social_accounts_info' => 'Connecteu els vostres comptes socials per a iniciar sessió més ràpidament. La desconnexió d’un compte no revoca l’autorització d’accés atorgada prèviament. Revoqueu l’accés des de la configuració de perfil del compte social connectat.', + 'users_social_connect' => 'Connecta el compte', 'users_social_disconnect' => 'Desconnecta el compte', 'users_social_status_connected' => 'Connectat', 'users_social_status_disconnected' => 'Desconnectat', - 'users_social_connected' => 'El compte de :socialAccount s’ha associat correctament al vostre perfil.', - 'users_social_disconnected' => 'El compte de :socialAccount ’ha desassociat correctament del vostre perfil.', - 'users_api_tokens' => 'Testimonis de l’API', - 'users_api_tokens_desc' => 'Creeu i gestioneu els testimonis d’accés que s’utilitzen per a autenticar-vos a l’API REST del BookStack. Els permisos de l’API es gestionen fent servir l’usuari al qual pertany el testimoni.', - 'users_api_tokens_none' => 'No s’ha creat cap testimoni de l’API per a aquest usuari', + 'users_social_connected' => 'S’ha connectat el compte :socialAccount al vostre perfil.', + 'users_social_disconnected' => 'S’ha desconnectat el compte :socialAccount del vostre perfil.', + 'users_api_tokens' => 'Testimonis API', + 'users_api_tokens_desc' => 'Creeu i gestioneu els testimonis d’accés que s’utilitzen per autenticar els usuaris amb la l’API REST del BookStack. Els permisos de l’API es gestionen a través de l’usuari a qui pertany el testimoni.', + 'users_api_tokens_none' => 'No s’ha creat cap testimoni API per a aquest usuari', 'users_api_tokens_create' => 'Crea un testimoni', 'users_api_tokens_expires' => 'Caducitat', 'users_api_tokens_docs' => 'Documentació de l’API', - 'users_mfa' => 'Autenticació de múltiple factor', - 'users_mfa_desc' => 'Configureu l’autenticació de múltiple factor com a capa extra de seguretat en el vostre compte d’usuari.', - 'users_mfa_x_methods' => ':count mètode configurat|:count mètodes configurats', - 'users_mfa_configure' => 'Configura els mètodes', + 'users_mfa' => 'Autenticació multifactorial', + 'users_mfa_desc' => 'Configureu l’autenticació multifactorial per a afegir una capa de seguretat extra al vostre compte d’usuari.', + 'users_mfa_x_methods' => 'Hi ha :count mètode configurat|Hi ha :count mètodes configurats', + 'users_mfa_configure' => 'Configura un mètode', // API Tokens - 'user_api_token_create' => 'Crea un testimoni de l’API', + 'user_api_token_create' => 'Crea un testimoni API', 'user_api_token_name' => 'Nom', - 'user_api_token_name_desc' => 'Poseu un nom llegible al vostre testimoni com a recordatori futur del propòsit al qual el voleu destinar.', + 'user_api_token_name_desc' => 'Anomeneu el testimoni amb un nom entenedor que permeti saber-ne el propòsit.', 'user_api_token_expiry' => 'Data de caducitat', - 'user_api_token_expiry_desc' => 'Definiu una data en què aquest testimoni caducarà. Després d’aquesta data, les peticions fetes amb aquest testimoni deixaran de funcionar. Si deixeu aquest camp en blanc, es definirà una caducitat d’aquí a 100 anys.', - 'user_api_token_create_secret_message' => 'Just després de crear aquest testimoni, es generaran i es mostraran un «Identificador del testimoni» i un «Secret del testimoni». El secret només es mostrarà una única vegada: assegureu-vos de copiar-lo a un lloc segur abans de continuar.', - 'user_api_token' => 'Testimoni de l’API', - 'user_api_token_id' => 'Identificador del testimoni', - 'user_api_token_id_desc' => 'Aquest identificador és generat pel sistema per a aquest testimoni i no és editable i caldrà que el proporcioneu a les peticions a l’API.', - 'user_api_token_secret' => 'Secret del testimoni', - 'user_api_token_secret_desc' => 'Aquest secret és generat pel sistema per a aquest testimoni i caldrà que el proporcioneu a les peticions a l’API. Només es mostrarà aquesta única vegada, assegureu-vos de copiar-lo a un lloc segur.', + 'user_api_token_expiry_desc' => 'Configureu la data de caducitat del testimoni. Un cop passada aquesta data, les sol·licituds fetes amb aquest testimoni no funcionaran. Si deixeu aquest camp en blanc, el testimoni caducarà d’aquí 100 anys.', + 'user_api_token_create_secret_message' => 'Es crearà i es mostrarà un «identificador de testimoni» i un «secret de testimoni» immediatament després de crear aquest testimoni. El secret es mostrarà només un sol cop. Assegureu-vos d’anotar-lo i de desar-lo en un lloc segur abans de continuar.', + 'user_api_token' => 'Testimoni API', + 'user_api_token_id' => 'Identificador de testimoni', + 'user_api_token_id_desc' => 'És un identificador no editable generat pel sistema per a aquest testimoni i que s’haurà de proporcionar en les sol·licituds API.', + 'user_api_token_secret' => 'Secret de testimoni', + 'user_api_token_secret_desc' => 'És un secret generat pel sistema per a aquest testimoni i que s’haurà de proporcionar en les sol·licituds API. Es mostrarà només un sol cop. Assegureu-vos d’anotar-lo i de desar-lo en un lloc segur abans de continuar.', 'user_api_token_created' => 'Testimoni creat :timeAgo', 'user_api_token_updated' => 'Testimoni actualitzat :timeAgo', 'user_api_token_delete' => 'Suprimeix el testimoni', - 'user_api_token_delete_warning' => 'Se suprimirà completament del sistema aquest testimoni de l’API amb el nom «:tokenName».', - 'user_api_token_delete_confirm' => 'Segur que voleu suprimir aquest testimoni de l’API?', + 'user_api_token_delete_warning' => 'Se suprimirà el testimoni API «:tokenName» del sistema.', + 'user_api_token_delete_confirm' => 'Esteu segur que voleu suprimir aquest testimoni API?', // Webhooks 'webhooks' => 'Webhooks', - 'webhooks_index_desc' => 'Els webhooks són una manera d’enviar dades a URL externs quan es produeixen certes accions i esdeveniments al sistema, la qual cosa permet una integració basada en esdeveniments amb plataformes externs, com ara sistemes de missatgeria o de notificacions.', - 'webhooks_x_trigger_events' => ':count esdeveniment disparador|:count elements disparadors', - 'webhooks_create' => 'Crea un webhook nou', - 'webhooks_none_created' => 'Encara no s’ha creat cap webhook.', + 'webhooks_index_desc' => 'Els webhooks permeten enviar dades a URLs externs quan ocorren unes accions o esdeveniments determinats. Això permet la integració basada en esdeveniments amb plataformes externes com ara sistemes de missatgeria o notificació.', + 'webhooks_x_trigger_events' => 'Hi ha :count esdeveniment disparador|Hi ha :count esdeveniments disparadors', + 'webhooks_create' => 'Crea un webhook', + 'webhooks_none_created' => 'No hi ha cap webhook', 'webhooks_edit' => 'Edita el webhook', 'webhooks_save' => 'Desa el webhook', 'webhooks_details' => 'Detalls del webhook', - 'webhooks_details_desc' => 'Proporcioneu un nom amigable i un endpoint POST com a ubicació on s’enviaran les dades del webhook.', - 'webhooks_events' => 'Esdeveniments del webhook', - 'webhooks_events_desc' => 'Seleccioneu tots els esdeveniments que haurien de fer que es cridés aquest webhook.', - 'webhooks_events_warning' => 'Tingueu en compte que aquests esdeveniments es produiran per a tots els esdeveniments seleccionats, fins i tot si s’hi apliquen permisos personalitzats. Assegureu-vos que aquest webhook no exposarà contingut confidencial.', + 'webhooks_details_desc' => 'Anomeneu el webhook amb un nom entenedor i proporcioneu un extrem POST com a ubicació on enviar les dades per al webhook.', + 'webhooks_events' => 'Esdeveniments webhook', + 'webhooks_events_desc' => 'Seleccioneu els esdeveniment que voleu que disparin la crida d’aquest webhook.', + 'webhooks_events_warning' => 'Tingueu en compte que aquestes crides es dispararan a tots els esdeveniments seleccionats, inclús si s’apliquen permisos personalitzats. Assegureu-vos que l’ús d’aquest webhook no exposarà contingut confidencial.', 'webhooks_events_all' => 'Tots els esdeveniments del sistema', 'webhooks_name' => 'Nom del webhook', - 'webhooks_timeout' => 'Temps d’espera de les peticions al webhook (en segons)', - 'webhooks_endpoint' => 'Endpoint del webhook', + 'webhooks_timeout' => 'Temps d’espera del webhook (en segons)', + 'webhooks_endpoint' => 'Extrem del webhook', 'webhooks_active' => 'Webhook actiu', 'webhook_events_table_header' => 'Esdeveniments', 'webhooks_delete' => 'Suprimeix el webhook', - 'webhooks_delete_warning' => 'Se suprimirà completament del sistema el webhook amb el nom «:webhookName».', - 'webhooks_delete_confirm' => 'Segur que voleu suprimir aquest webhook?', - 'webhooks_format_example' => 'Exemple del format del webhook', - 'webhooks_format_example_desc' => 'Les dades del webhook s’envien com una petició POST a l’endpoint configurat amb un JSON que segueix el següent format. Les propietats «related_item» i «url» són opcionals i dependran del tipus d’esdeveniment produït.', + 'webhooks_delete_warning' => 'Se suprimirà el webhook «:webhookName» del sistema.', + 'webhooks_delete_confirm' => 'Esteu segur que voleu suprimir aquest webhook?', + 'webhooks_format_example' => 'Exemple de format de webhook', + 'webhooks_format_example_desc' => 'Les dades d’un webhook s’envien com una sol·licitud POST a l’extrem configurat com a JSON amb el format següent. Les propietats «related_item» i «url» són opcionals i dependran del tipus d’esdeveniment que es dispari.', 'webhooks_status' => 'Estat del webhook', - 'webhooks_last_called' => 'Cridat per darrera vegada:', - 'webhooks_last_errored' => 'Error per darrera vegada:', + 'webhooks_last_called' => 'Darrera crida:', + 'webhooks_last_errored' => 'Darrer error:', 'webhooks_last_error_message' => 'Darrer missatge d’error:', // Licensing diff --git a/lang/ca/validation.php b/lang/ca/validation.php index b73a66c5c6c..dc9b02d5024 100644 --- a/lang/ca/validation.php +++ b/lang/ca/validation.php @@ -12,13 +12,13 @@ 'active_url' => 'El camp :attribute no és un URL vàlid.', 'after' => 'El camp :attribute ha de ser una data posterior a :date.', 'alpha' => 'El camp :attribute només pot contenir lletres.', - 'alpha_dash' => 'El camp :attribute només pot contenir lletres, números, guions i guions baixos.', - 'alpha_num' => 'El camp :attribute només pot contenir lletres i números.', - 'array' => 'El camp :attribute ha de ser un vector.', - 'backup_codes' => 'El codi que heu proporcionat no és vàlid o ja s’ha fet servir.', - 'before' => 'El camp :attribute ha de ser una data anterior a :date.', + 'alpha_dash' => 'El camp :attribute només pot contenir lletres, xifres, guionets i guions baixos.', + 'alpha_num' => 'El camp :attribute només pot contenir lletres, xifres.', + 'array' => 'El camp :attribute ha de ser una matriu.', + 'backup_codes' => 'El codi que heu proporcionat no és vàlid o ja s’ha utilitzat.', + 'before' => 'El camp :attribute ha de ser una data posterior a :date.', 'between' => [ - 'numeric' => 'El camp :attribute ha d’estar entre :min i :max.', + 'numeric' => 'El camp :attribute ha de ser un nombre entre :min i :max.', 'file' => 'El camp :attribute ha de tenir entre :min i :max kilobytes.', 'string' => 'El camp :attribute ha de tenir entre :min i :max caràcters.', 'array' => 'El camp :attribute ha de tenir entre :min i :max elements.', @@ -27,11 +27,11 @@ 'confirmed' => 'La confirmació del camp :attribute no coincideix.', 'date' => 'El camp :attribute no és una data vàlida.', 'date_format' => 'El camp :attribute no coincideix amb el format :format.', - 'different' => 'Els camps :attribute i :other han de ser diferents.', - 'digits' => 'El camp :attribute ha de tenir :digits dígits.', - 'digits_between' => 'El camp :attribute ha de tenir entre :min i :max dígits.', - 'email' => 'El camp :attribute ha de ser una adreça electrònica vàlida.', - 'ends_with' => 'El camp :attribute ha d’acabar amb un dels següents valors: :values', + 'different' => 'El camp :attribute i :other han de ser diferents.', + 'digits' => 'El camp :attribute ha de tenir :digits xifres.', + 'digits_between' => 'El camp :attribute ha de tenir entre :min i :max xifres.', + 'email' => 'El camp :attribute ha de ser un adreça electrònica vàlida.', + 'ends_with' => 'El camp :attribute ha d’acabar amb un dels signes següents: :values', 'file' => 'El camp :attribute ha de ser un fitxer vàlid.', 'filled' => 'El camp :attribute és obligatori.', 'gt' => [ @@ -41,74 +41,74 @@ 'array' => 'El camp :attribute ha de tenir més de :value elements.', ], 'gte' => [ - 'numeric' => 'El camp :attribute ha de ser més gran o igual que :value.', - 'file' => 'El camp :attribute ha de tenir :value kilobytes o més.', - 'string' => 'El camp :attribute ha de tenir :value caràcters o més.', - 'array' => 'El camp :attribute ha de tenir :value elements o més.', + 'numeric' => 'El camp :attribute ha de ser com a mínim :value.', + 'file' => 'El camp :attribute ha de tenir com a mínim :value kilobytes.', + 'string' => 'El camp :attribute ha de tenir com a mínim :value caràcters.', + 'array' => 'El camp :attribute ha de tenir com a mínim :value elements.', ], 'exists' => 'El camp :attribute seleccionat no és vàlid.', 'image' => 'El camp :attribute ha de ser una imatge.', - 'image_extension' => 'El camp :attribute ha de tenir una extensió d’imatge vàlida i suportada.', - 'in' => 'El camp :attribute seleccionat no és vàlid.', - 'integer' => 'El camp :attribute ha de ser un enter.', - 'ip' => 'El camp :attribute ha de ser una adreça IP vàlida.', - 'ipv4' => 'El camp :attribute ha de ser una adreça IPv4 vàlida.', - 'ipv6' => 'El camp :attribute ha de ser una adreça IPv6 vàlida.', + 'image_extension' => 'El camp :attribute ha de tenir una extensió d’imatge compatible.', + 'in' => 'El camp :attribute no és vàlid.', + 'integer' => 'El camp :attribute ha de ser un nombre enter.', + 'ip' => 'El camp :attribute ha de ser un adreça IP vàlida.', + 'ipv4' => 'El camp :attribute ha de ser un adreça IPv4 vàlida.', + 'ipv6' => 'El camp :attribute ha de ser un adreça IPv6 vàlida.', 'json' => 'El camp :attribute ha de ser una cadena JSON vàlida.', 'lt' => [ - 'numeric' => 'El camp :attribute ha de ser menor que :value.', + 'numeric' => 'El camp :attribute ha de ser més petit que :value.', 'file' => 'El camp :attribute ha de tenir menys de :value kilobytes.', 'string' => 'El camp :attribute ha de tenir menys de :value caràcters.', 'array' => 'El camp :attribute ha de tenir menys de :value elements.', ], 'lte' => [ - 'numeric' => 'El camp :attribute ha de ser més petit o igual que :value.', - 'file' => 'El camp :attribute ha de tenir :value kilobytes o menys.', - 'string' => 'El camp :attribute ha de tenir :value caràcters o menys.', - 'array' => 'El camp :attribute ha de tenir :value elements o menys.', + 'numeric' => 'El camp :attribute ha de ser com a màxim :value.', + 'file' => 'El camp :attribute ha de tenir com a màxim :value kilobytes.', + 'string' => 'El camp :attribute ha de tenir com a màxim :value caràcters.', + 'array' => 'El camp :attribute ha de tenir com a màxim :value elements.', ], 'max' => [ - 'numeric' => 'El camp :attribute no pot ser més gran que :max.', - 'file' => 'El camp :attribute no pot tenir més de :max kilobytes.', - 'string' => 'El camp :attribute no pot tenir més de :max caràcters.', - 'array' => 'El camp :attribute no pot tenir més de :max elements.', + 'numeric' => 'El camp :attribute ha de ser com a màxim :max.', + 'file' => 'El camp :attribute ha de tenir com a màxim :max kilobytes.', + 'string' => 'El camp :attribute ha de tenir com a màxim :max caràcters.', + 'array' => 'El camp :attribute ha de tenir com a màxim :max elements.', ], 'mimes' => 'El camp :attribute ha de ser un fitxer del tipus: :values.', 'min' => [ - 'numeric' => 'El camp :attribute no pot ser més petit que :min.', - 'file' => 'El camp :attribute no pot tenir menys de :min kilobytes.', - 'string' => 'El camp :attribute no pot tenir menys de :min caràcters.', - 'array' => 'El camp :attribute no pot tenir menys de :min elements.', + 'numeric' => 'El camp :attribute ha de ser com a mínim :min.', + 'file' => 'El camp :attribute ha de tenir com a mínim :min kilobytes.', + 'string' => 'El camp :attribute ha de tenir com a mínim :min caràcters.', + 'array' => 'El camp :attribute ha de tenir com a mínim :min elements.', ], - 'not_in' => 'El camp :attribute seleccionat no és vàlid.', - 'not_regex' => 'El format del camp :attribute no és vàlid.', - 'numeric' => 'El camp :attribute ha de ser un número.', - 'regex' => 'El format del camp :attribute no és vàlid.', + 'not_in' => 'El camp :attribute no és vàlid.', + 'not_regex' => 'El format :attribute no és vàlid.', + 'numeric' => 'El camp :attribute ha de ser un nombre.', + 'regex' => 'El format :attribute no és vàlid.', 'required' => 'El camp :attribute és obligatori.', 'required_if' => 'El camp :attribute és obligatori quan :other és :value.', - 'required_with' => 'El camp :attribute és obligatori quan hi ha aquest valor: :values.', - 'required_with_all' => 'El camp :attribute és obligatori quan hi ha algun d’aquests valors: :values.', - 'required_without' => 'El camp :attribute és obligatori quan no hi ha aquest valor: :values.', + 'required_with' => 'El camp :attribute és obligatori quan hi ha :values.', + 'required_with_all' => 'El camp :attribute és obligatori quan hi ha tots aquests valors: :values.', + 'required_without' => 'El camp :attribute és obligatori quan no hi ha :values.', 'required_without_all' => 'El camp :attribute és obligatori quan no hi ha cap d’aquests valors: :values.', - 'same' => 'Els camps :attribute i :other han de coincidir.', - 'safe_url' => 'L’enllaç proporcionat podria no ser segur.', + 'same' => 'El camp :attribute i :other han de coincidir.', + 'safe_url' => 'És possible que l’enllaç proporcionat no sigui segur.', 'size' => [ 'numeric' => 'El camp :attribute ha de ser :size.', 'file' => 'El camp :attribute ha de tenir :size kilobytes.', - 'string' => 'El camp :attribute ha de tenir :size caràcters.', - 'array' => 'El camp :attribute ha de contenir :size elements.', + 'string' => 'El camp :attribute ha de tenir :size caràcters', + 'array' => 'El camp :attribute ha de tenir :size elements.', ], - 'string' => 'El camp :attribute ha de ser una cadena.', - 'timezone' => 'El camp :attribute ha de ser una zona vàlida.', - 'totp' => 'El codi que heu proporcionat no és vàlid o ha caducat.', - 'unique' => 'El camp :attribute ja es fa servir.', - 'url' => 'El format del camp :attribute no és vàlid.', - 'uploaded' => 'No s’ha pogut pujar el fitxer. És possible que el servidor no accepti fitxers d’aquesta mida.', + 'string' => 'El camp :attribute ha de ser una cadena de text.', + 'timezone' => 'El camp :attribute ha de ser un fus horari vàlid.', + 'totp' => 'El codi proporcionat no és vàlid o ha caducat.', + 'unique' => 'El camp :attribute ja s’ha utilitzat.', + 'url' => 'El format :attribute no és vàlid.', + 'uploaded' => 'No s’ha pogut pujar el fitxer. És possible que el servidor no admeti fitxers d’aquesta mida.', // Custom validation lines 'custom' => [ 'password-confirm' => [ - 'required_with' => 'Cal la confirmació de la contrasenya', + 'required_with' => 'Heu de confirmar la contrasenya.', ], ], diff --git a/lang/cs/activities.php b/lang/cs/activities.php index 4fcc9dacef2..d0bf9cf4577 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -40,14 +40,14 @@ 'book_sort_notification' => 'Kniha byla úspěšně seřazena', // Bookshelves - 'bookshelf_create' => 'vytvořit knihovnu', - 'bookshelf_create_notification' => 'Knihovna byla úspěšně vytvořena', - 'bookshelf_create_from_book' => 'převést knihu na knihovnu', - 'bookshelf_create_from_book_notification' => 'Kniha byla úspěšně převedena na knihovnu', - 'bookshelf_update' => 'aktualizovat knihovnu', - 'bookshelf_update_notification' => 'Knihovna byla úspěšně aktualizována', + 'bookshelf_create' => 'vytvořil polici', + 'bookshelf_create_notification' => 'Police byla úspěšně vytvořena', + 'bookshelf_create_from_book' => 'převést knihu na polici', + 'bookshelf_create_from_book_notification' => 'Kniha byla úspěšně převedena na polici', + 'bookshelf_update' => 'aktualizovat polici', + 'bookshelf_update_notification' => 'Police byla úspěšně aktualizována', 'bookshelf_delete' => 'odstranit knihovnu', - 'bookshelf_delete_notification' => 'Knihovna byla úspěšně smazána', + 'bookshelf_delete_notification' => 'Police byla úspěšně odstraněna', // Revisions 'revision_restore' => 'obnovil revizi', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index 3d402081063..89ff7f2d59e 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -85,35 +85,35 @@ // Shelves 'shelf' => 'Knihovna', 'shelves' => 'Knihovny', - 'x_shelves' => '{0}:count knihoven|{1}:count knihovna|[2,4]:count knihovny|[5,*]:count knihoven', - 'shelves_empty' => 'Nebyly vytvořeny žádné knihovny', - 'shelves_create' => 'Vytvořit novou knihovnu', - 'shelves_popular' => 'Populární knihovny', - 'shelves_new' => 'Nové knihovny', - 'shelves_new_action' => 'Nová Knihovna', - 'shelves_popular_empty' => 'Nejpopulárnější knihovny se objeví zde.', - 'shelves_new_empty' => 'Zde se zobrazí nejnověji vytvořené knihovny.', - 'shelves_save' => 'Uložit knihovnu', - 'shelves_books' => 'Knihy v této knihovně', - 'shelves_add_books' => 'Přidat knihy do knihovny', - 'shelves_drag_books' => 'Přetáhněte knihy níže a přidejte je do této knihovny', - 'shelves_empty_contents' => 'Tato knihovna neobsahuje žádné knihy', - 'shelves_edit_and_assign' => 'Upravit knihovnu a přiřadit knihy', - 'shelves_edit_named' => 'Upravit knihovnu :name', - 'shelves_edit' => 'Upravit knihovnu', - 'shelves_delete' => 'Odstranit knihovnu', - 'shelves_delete_named' => 'Odstranit knihovnu :name', - 'shelves_delete_explain' => "Chystáte se smazat knihovnu ':name'. Knihy v ní obsažené zůstanou zachovány.", - 'shelves_delete_confirmation' => 'Opravdu chcete odstranit tuto knihovnu?', - 'shelves_permissions' => 'Oprávnění knihovny', - 'shelves_permissions_updated' => 'Oprávnění knihovny byla aktualizována', - 'shelves_permissions_active' => 'Oprávnění knihovny byla aktivována', - 'shelves_permissions_cascade_warning' => 'Oprávnění v Knihovnách nejsou automaticky kaskádována do obsažených knih. To proto, že kniha může existovat ve více Knihovnách. Oprávnění však lze zkopírovat do podřízených knih pomocí níže uvedené možnosti.', + 'x_shelves' => '{0}:count polic|{1}:count police|[2,4]:count police|[5,*]:count polic', + 'shelves_empty' => 'Nebyly vytvořeny žádné police', + 'shelves_create' => 'Vytvořit novou polici', + 'shelves_popular' => 'Populární police', + 'shelves_new' => 'Nové police', + 'shelves_new_action' => 'Nová Police', + 'shelves_popular_empty' => 'Nejpopulárnější police se objeví zde.', + 'shelves_new_empty' => 'Zde se zobrazí nejnovější police.', + 'shelves_save' => 'Uložit polici', + 'shelves_books' => 'Knihy na této polici', + 'shelves_add_books' => 'Přidat knihy do této police', + 'shelves_drag_books' => 'Přetáhněte knihy níže a přidejte je do této police', + 'shelves_empty_contents' => 'Tato police neobsahuje žádné knihy', + 'shelves_edit_and_assign' => 'Upravit polici a přiřadit knihy', + 'shelves_edit_named' => 'Upravit polici :name', + 'shelves_edit' => 'Upravit polici', + 'shelves_delete' => 'Odstranit polici', + 'shelves_delete_named' => 'Odstranit polici :name', + 'shelves_delete_explain' => "Chystáte se smazat polici ':name'. Knihy v ní obsažené zůstanou zachovány.", + 'shelves_delete_confirmation' => 'Opravdu chcete odstranit tuto polici?', + 'shelves_permissions' => 'Oprávnění police', + 'shelves_permissions_updated' => 'Oprávnění police byla aktualizována', + 'shelves_permissions_active' => 'Oprávnění police byla aktivována', + 'shelves_permissions_cascade_warning' => 'Oprávnění v policiích nejsou automaticky kaskádována do obsažených knih. To proto, že kniha může existovat ve více policích. Oprávnění však lze zkopírovat do podřízených knih pomocí níže uvedené možnosti.', 'shelves_permissions_create' => 'Oprávnění k vytváření Shelf se používají pouze ke kopírování oprávnění do dětských knih pomocí níže uvedené akce. Nekontrolují schopnost vytvářet knihy.', 'shelves_copy_permissions_to_books' => 'Kopírovat oprávnění na knihy', 'shelves_copy_permissions' => 'Kopírovat oprávnění', - 'shelves_copy_permissions_explain' => 'Toto použije aktuální nastavení oprávnění knihovny na všechny knihy v ní obsažené. Před aktivací se ujistěte, že byly uloženy všechny změny oprávnění této knihovny.', - 'shelves_copy_permission_success' => 'Oprávnění knihovny byla zkopírována na :count knih', + 'shelves_copy_permissions_explain' => 'Tímto se použije aktuální nastavení oprávnění police na všechny knihy v ní obsažené. Před aktivací se ujistěte, že byly uloženy všechny změny oprávnění této police.', + 'shelves_copy_permission_success' => 'Oprávnění police byla zkopírována na :count knih', // Books 'book' => 'Kniha', @@ -303,7 +303,7 @@ 'page_tags' => 'Štítky stránky', 'chapter_tags' => 'Štítky kapitoly', 'book_tags' => 'Štítky knihy', - 'shelf_tags' => 'Štítky knihovny', + 'shelf_tags' => 'Štítky police', 'tag' => 'Štítek', 'tags' => 'Štítky', 'tags_index_desc' => 'Tagy mohou být použity pro obsah v rámci systému pro pružnou formu kategorizace. Tagy mohou mít klíč i hodnotu, přičemž hodnota je nepovinná. Po aplikaci může být obsah dotazován pomocí názvu a hodnoty štítku.', @@ -316,12 +316,12 @@ 'tags_assigned_pages' => 'Přiřazeno ke stránkám', 'tags_assigned_chapters' => 'Přiřazeno ke kapitolám', 'tags_assigned_books' => 'Přiřazeno ke knihám', - 'tags_assigned_shelves' => 'Přiřazeno ke knihovnám', + 'tags_assigned_shelves' => 'Přiřazeno k policím', 'tags_x_unique_values' => ':count jedinečných hodnot', 'tags_all_values' => 'Všechny hodnoty', 'tags_view_tags' => 'Zobrazit štítky', 'tags_view_existing_tags' => 'Zobrazit existující štítky', - 'tags_list_empty_hint' => 'Štítky mohou být přiřazeny pomocí postranního panelu editoru stránky nebo při úpravách podrobností knihy, kapitoly nebo knihovny.', + 'tags_list_empty_hint' => 'Štítky mohou být přiřazeny pomocí postranního panelu editoru stránky nebo při úpravách podrobností knihy, kapitoly nebo police.', 'attachments' => 'Přílohy', 'attachments_explain' => 'Nahrajte soubory nebo připojte odkazy, které se zobrazí na stránce. Budou k nalezení v postranní liště.', 'attachments_explain_instant_save' => 'Změny zde provedené se okamžitě ukládají.', @@ -395,9 +395,9 @@ 'copy_consider_access' => 'Po změně umístění, vlastníka nebo oprávnění může dojít k tomu, že obsah může být přístupný těm, kteří přístup dříve něměli.', // Conversions - 'convert_to_shelf' => 'Převést na knihovnu', - 'convert_to_shelf_contents_desc' => 'Tuto knihu můžete převést na novou knihovnu se stejným obsahem. Kapitoly obsažené v této knize budou převedeny na nové knihy. Pokud tato kniha obsahuje jakékoli stránky, které nejsou uvedeny v kapitole, Tato kniha bude přejmenována a bude obsahovat tyto stránky a tato kniha se stane součástí nové knihovny.', - 'convert_to_shelf_permissions_desc' => 'Veškerá oprávnění nastavená v této knize budou zkopírována do nové knihovny a do všech nových podřazených knih, které nemají vlastní oprávnění. Všimněte si, že oprávnění na regálech neobsahují automatickou kaskádu na obsah, jak to dělají pro knihy.', + 'convert_to_shelf' => 'Převést na polici', + 'convert_to_shelf_contents_desc' => 'Tuto knihu můžete převést na novou polici se stejným obsahem. Kapitoly obsažené v této knize budou převedeny na nové knihy. Pokud tato kniha obsahuje jakékoli stránky, které nejsou uvedeny v kapitole, bude tato kniha přejmenována a bude obsahovat tyto stránky a stane se součástí nové police.', + 'convert_to_shelf_permissions_desc' => 'Veškerá oprávnění nastavená v této knize budou zkopírována do nové police a do všech nových podřazených knih, které nemají vlastní oprávnění. Všimněte si, že oprávnění na policích neobsahují automatickou kaskádu na obsah, jako je tomu u knih.', 'convert_book' => 'Převést knihu', 'convert_book_confirm' => 'Opravdu chcete převést tuto knihu?', 'convert_undo_warning' => 'To nelze tak snadno vrátit zpět.', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index 0f03f897745..e82be17d3fa 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -67,7 +67,7 @@ // Entities 'entity_not_found' => 'Prvek nenalezen', - 'bookshelf_not_found' => 'Knihovna nenalezena', + 'bookshelf_not_found' => 'Police nenalezena', 'book_not_found' => 'Kniha nenalezena', 'page_not_found' => 'Stránka nenalezena', 'chapter_not_found' => 'Kapitola nenalezena', diff --git a/lang/cs/notifications.php b/lang/cs/notifications.php index 8314a0d8f38..caf75f17951 100644 --- a/lang/cs/notifications.php +++ b/lang/cs/notifications.php @@ -5,23 +5,23 @@ return [ 'new_comment_subject' => 'Nový komentář na stránce: :pageName', - 'new_comment_intro' => 'Uživatel(ka) okomentoval(a) stránku v :appName:', - 'new_page_subject' => 'New page: :pageName', - 'new_page_intro' => 'Nová stránka byla vytvořena v :appName:', + 'new_comment_intro' => 'Uživatel/ka okomentoval/a stránku v :appName:', + 'new_page_subject' => 'Nová stránka: :pageName', + 'new_page_intro' => 'V :appName byla vytvořena nová stránka:', 'updated_page_subject' => 'Aktualizovaná stránka: :pageName', - 'updated_page_intro' => 'Stránka byla aktualizována v :appName:', - 'updated_page_debounce' => 'Po nějakou dobu neobdržíte další oznámení o aktualizaci této stránky stejným editorem, aby se omezil počet stejných zaslaných upozornění.', + 'updated_page_intro' => 'V :appName byla aktualizována stránka:', + 'updated_page_debounce' => 'Po nějakou dobu neobdržíte další oznámení o aktualizaci této stránky stejným editorem, aby se omezil počet stejných zpráv.', 'detail_page_name' => 'Název stránky:', - 'detail_page_path' => 'Umístěný v:', - 'detail_commenter' => 'Komentoval(a):', + 'detail_page_path' => 'Umístění:', + 'detail_commenter' => 'Komentoval/a:', 'detail_comment' => 'Komentář:', - 'detail_created_by' => 'Vytvořil(a):', - 'detail_updated_by' => 'Aktualizoval(a):', + 'detail_created_by' => 'Vytvořil/a:', + 'detail_updated_by' => 'Aktualizoval/a:', 'action_view_comment' => 'Zobrazit komentář', 'action_view_page' => 'Zobrazit stránku', - 'footer_reason' => 'Dle :link Vám bylo pro tento typ aktivity zasláno toto upozornění.', + 'footer_reason' => 'Tato zpráva Vám byla doručena na základě Vašeho :link.', 'footer_reason_link' => 'nastavení upozornění', ]; diff --git a/lang/cs/settings.php b/lang/cs/settings.php index 23820fe873b..a74822658a0 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -54,7 +54,7 @@ 'app_color' => 'Hlavní barva', 'link_color' => 'Výchozí barva odkazu', 'content_colors_desc' => 'Nastaví barvy pro všechny prvky v organizační struktuře stránky. Pro lepší čitelnost doporučujeme zvolit barvy s podobným jasem, jakou mají výchozí barvy.', - 'bookshelf_color' => 'Barva knihovny', + 'bookshelf_color' => 'Barva police', 'book_color' => 'Barva knihy', 'chapter_color' => 'Barva kapitoly', 'page_color' => 'Barva stránky', @@ -90,7 +90,7 @@ 'maint_send_test_email_mail_subject' => 'Testovací e-mail', 'maint_send_test_email_mail_greeting' => 'Zdá se, že posílání e-mailů funguje!', 'maint_send_test_email_mail_text' => 'Gratulujeme! Protože jste dostali tento e-mail, zdá se, že nastavení e-mailů je v pořádku.', - 'maint_recycle_bin_desc' => 'Odstraněné knihovny, knihy, kapitoly a stránky se přesouvají do Koše, aby je bylo možné obnovit nebo trvale smazat. Starší položky v koši mohou být po čase automaticky odstraněny v závislosti na konfiguraci systému.', + 'maint_recycle_bin_desc' => 'Odstraněné police, knihy, kapitoly a stránky se přesouvají do Koše, aby je bylo možné obnovit nebo trvale smazat. Starší položky v koši mohou být po čase automaticky odstraněny v závislosti na konfiguraci systému.', 'maint_recycle_bin_open' => 'Otevřít Koš', 'maint_regen_references' => 'Přegenerovat odkazy', 'maint_regen_references_desc' => 'Tato akce obnoví referenční index křížových položek v rámci databáze. Toto je obvykle zpracováno automaticky, ale tato akce může být užitečná pro indexování starého obsahu nebo obsahu přidaného neoficiálními metodami.', diff --git a/lang/cy/activities.php b/lang/cy/activities.php index b757707d13b..84c4af9f70d 100644 --- a/lang/cy/activities.php +++ b/lang/cy/activities.php @@ -30,8 +30,8 @@ // Books 'book_create' => 'llyfr wedi creu', 'book_create_notification' => 'Llyfr wedi\'i creu\'n llwyddiannus', - 'book_create_from_chapter' => 'converted chapter to book', - 'book_create_from_chapter_notification' => 'Chapter successfully converted to a book', + 'book_create_from_chapter' => 'trosodd bennod i lyfr', + 'book_create_from_chapter_notification' => 'Pennod wedi\'i trosi\'n llwyddiannus i lyfr', 'book_update' => 'llyfr wedi diweddaru', 'book_update_notification' => 'Llyfr wedi\'i diweddaru\'n llwyddiannus', 'book_delete' => 'llyfr wedi\'i dileu', @@ -42,17 +42,17 @@ // Bookshelves 'bookshelf_create' => 'creodd silff', 'bookshelf_create_notification' => 'Silff wedi\'i chreu\'n llwyddiannus', - 'bookshelf_create_from_book' => 'converted book to shelf', - 'bookshelf_create_from_book_notification' => 'Book successfully converted to a shelf', + 'bookshelf_create_from_book' => 'trosodd lyfr i silff', + 'bookshelf_create_from_book_notification' => 'Llyfr wedi\'i trosi\'n llwyddiannus i silff', 'bookshelf_update' => 'diweddarodd silff', 'bookshelf_update_notification' => 'Silff wedi\'i diweddaru\'n llwyddiannus', 'bookshelf_delete' => 'dileodd silff', 'bookshelf_delete_notification' => 'Silff wedi\'i dileu\'n llwyddiannus', // Revisions - 'revision_restore' => 'restored revision', - 'revision_delete' => 'deleted revision', - 'revision_delete_notification' => 'Revision successfully deleted', + 'revision_restore' => 'adferodd y diwygiad', + 'revision_delete' => 'dileuodd y diwygiad', + 'revision_delete_notification' => 'Diwygiad wedi\'i dileu\'n llwyddiannus', // Favourites 'favourite_add_notification' => 'Mae ":name" wedi\'i ychwanegu at eich ffefrynnau', @@ -63,17 +63,17 @@ // Auth 'auth_login' => 'wedi\'u mewngofnodi', - 'auth_register' => 'registered as new user', - 'auth_password_reset_request' => 'requested user password reset', - 'auth_password_reset_update' => 'reset user password', + 'auth_register' => 'wedi\'i cofrestru\'n ddefnyddiwr newydd', + 'auth_password_reset_request' => 'wedi ceisio ailosod gair pass defnyddiwr', + 'auth_password_reset_update' => 'ailosododd air pass defnyddiwr', 'mfa_setup_method' => 'configured MFA method', 'mfa_setup_method_notification' => 'Dull aml-ffactor wedi\'i ffurfweddu\'n llwyddiannus', 'mfa_remove_method' => 'removed MFA method', 'mfa_remove_method_notification' => 'Llwyddwyd i ddileu dull aml-ffactor', // Settings - 'settings_update' => 'updated settings', - 'settings_update_notification' => 'Settings successfully updated', + 'settings_update' => 'diweddarodd osodiadau', + 'settings_update_notification' => 'Gosodiadau wedi\'i diweddaru\'n llwyddiannus', 'maintenance_action_run' => 'ran maintenance action', // Webhooks @@ -93,24 +93,24 @@ 'user_delete_notification' => 'Tynnwyd y defnyddiwr yn llwyddiannus', // API Tokens - 'api_token_create' => 'created API token', + 'api_token_create' => 'creodd docyn API', 'api_token_create_notification' => 'Tocyn API wedi\'i greu\'n llwyddiannus', - 'api_token_update' => 'updated API token', + 'api_token_update' => 'diweddarodd docyn API', 'api_token_update_notification' => 'Tocyn API wedi\'i ddiweddaru\'n llwyddiannus', - 'api_token_delete' => 'deleted API token', + 'api_token_delete' => 'dileodd docyn API', 'api_token_delete_notification' => 'Tocyn API wedi\'i ddileu\'n llwyddiannus', // Roles - 'role_create' => 'created role', - 'role_create_notification' => 'Role successfully created', - 'role_update' => 'updated role', - 'role_update_notification' => 'Role successfully updated', - 'role_delete' => 'deleted role', - 'role_delete_notification' => 'Role successfully deleted', + 'role_create' => 'creodd rôl', + 'role_create_notification' => 'Rôl wedi\'i creu\'n llwyddiannus', + 'role_update' => 'diweddarodd rôl', + 'role_update_notification' => 'Rôl wedi\'i diweddaru\'n llwyddiannus', + 'role_delete' => 'dileodd rôl', + 'role_delete_notification' => 'Rôl wedi\'i dileu\'n llwyddiannus', // Recycle Bin 'recycle_bin_empty' => 'gwagodd fin ailgylchu', - 'recycle_bin_restore' => 'restored from recycle bin', + 'recycle_bin_restore' => 'wedi\'i adfer o\'r bin ailgylchu', 'recycle_bin_destroy' => 'removed from recycle bin', // Comments diff --git a/lang/cy/auth.php b/lang/cy/auth.php index bda2e741d0e..8cdb679c8c7 100644 --- a/lang/cy/auth.php +++ b/lang/cy/auth.php @@ -39,9 +39,9 @@ 'register_success' => 'Diolch am arwyddo! Rydych bellach wedi cofrestru ac wedi mewngofnodi.', // Login auto-initiation - 'auto_init_starting' => 'Attempting Login', + 'auto_init_starting' => 'Wrthi\'n ceisio mewngofnodi', 'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.', - 'auto_init_start_link' => 'Proceed with authentication', + 'auto_init_start_link' => 'Parhau gyda dilysu', // Password Reset 'reset_password' => 'Ailosod cyfrinair', @@ -49,9 +49,9 @@ 'reset_password_send_button' => 'Anfon Dolen Ailosod', 'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.', 'reset_password_success' => 'Your password has been successfully reset.', - 'email_reset_subject' => 'Reset your :appName password', + 'email_reset_subject' => 'Ailosod eich gair pass :appName', 'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.', - 'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.', + 'email_reset_not_requested' => 'Os nad oeddwch chi\'n ceisio ailosod eich gair pass, does dim byd arall i wneud.', // Email Confirmation 'email_confirm_subject' => 'Cadarnhewch eich e-bost chi a :appName', @@ -83,19 +83,19 @@ // Multi-factor Authentication 'mfa_setup' => 'Setup Multi-Factor Authentication', 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'mfa_setup_configured' => 'Already configured', - 'mfa_setup_reconfigure' => 'Reconfigure', + 'mfa_setup_configured' => 'Wedi\'i ail-ffurfweddu\'n barod', + 'mfa_setup_reconfigure' => 'Ail-ffurfweddu', 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', - 'mfa_setup_action' => 'Setup', + 'mfa_setup_action' => 'Gosodiad', 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', 'mfa_option_totp_title' => 'Ap Ffôn Symudol', 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Backup Codes', + 'mfa_option_backup_codes_title' => 'Codau wrth Gefn', 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', - 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', + 'mfa_gen_backup_codes_title' => 'Gosodiad Codau wrth Gefn', 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', - 'mfa_gen_backup_codes_download' => 'Download Codes', + 'mfa_gen_backup_codes_download' => 'Llwytho Codau i Lawr', 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', 'mfa_gen_totp_title' => 'Mobile App Setup', 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', @@ -103,15 +103,15 @@ 'mfa_gen_totp_verify_setup' => 'Verify Setup', 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', - 'mfa_verify_access' => 'Verify Access', + 'mfa_verify_access' => 'Gwirio Mynediad', 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', 'mfa_verify_no_methods' => 'No Methods Configured', 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', - 'mfa_verify_use_totp' => 'Verify using a mobile app', - 'mfa_verify_use_backup_codes' => 'Verify using a backup code', - 'mfa_verify_backup_code' => 'Backup Code', + 'mfa_verify_use_totp' => 'Gwirio gan ap ffôn', + 'mfa_verify_use_backup_codes' => 'Gwirio gan god wrth gefn', + 'mfa_verify_backup_code' => 'Cod wrth Gefn', 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', - 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', + 'mfa_verify_backup_code_enter_here' => 'Cofnodi cod wrth gefn yma', 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', ]; diff --git a/lang/cy/common.php b/lang/cy/common.php index 7297e78db53..ce1e188da68 100644 --- a/lang/cy/common.php +++ b/lang/cy/common.php @@ -12,13 +12,13 @@ 'save' => 'Cadw', 'continue' => 'Parhau', 'select' => 'Dewis', - 'toggle_all' => 'Toggle All', + 'toggle_all' => 'Toglo Popeth', 'more' => 'Mwy', // Form Labels 'name' => 'Enw', 'description' => 'Disgrifiad', - 'role' => 'Role', + 'role' => 'Rôl', 'cover_image' => 'Cover image', 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', @@ -43,9 +43,9 @@ 'add' => 'Ychwanegu', 'configure' => 'Ffurfweddu', 'manage' => 'Rheoli', - 'fullscreen' => 'Fullscreen', - 'favourite' => 'Favourite', - 'unfavourite' => 'Unfavourite', + 'fullscreen' => 'Sgrin Llawn', + 'favourite' => 'Gwneud Ffefryn', + 'unfavourite' => 'Dadwneud Ffefryn', 'next' => 'Nesa', 'previous' => 'Cynt', 'filter_active' => 'Hidl weithredol:', @@ -60,37 +60,37 @@ 'sort_ascending' => 'Trefnu\'n esgynnol', 'sort_descending' => 'Trefnu\'n ddisgynnol', 'sort_name' => 'Enw', - 'sort_default' => 'Default', - 'sort_created_at' => 'Created Date', - 'sort_updated_at' => 'Updated Date', + 'sort_default' => 'Diofyn', + 'sort_created_at' => 'Dyddiad Creu', + 'sort_updated_at' => 'Dyddiad Diweddaru', // Misc - 'deleted_user' => 'Deleted User', - 'no_activity' => 'No activity to show', + 'deleted_user' => 'Defnyddiwr wedi\'i Dileu', + 'no_activity' => 'Dim actifedd i arddangos', 'no_items' => 'Dim eitemau ar gael', 'back_to_top' => 'Yn ôl i\'r brig', - 'skip_to_main_content' => 'Skip to main content', - 'toggle_details' => 'Toggle Details', - 'toggle_thumbnails' => 'Toggle Thumbnails', - 'details' => 'Details', - 'grid_view' => 'Grid View', - 'list_view' => 'List View', - 'default' => 'Default', + 'skip_to_main_content' => 'Neidio i\'r prif gynnwys', + 'toggle_details' => 'Toglo Manylion', + 'toggle_thumbnails' => 'Toglo Mân-Luniau', + 'details' => 'Manylion', + 'grid_view' => 'Golwg Grid', + 'list_view' => 'Golwg Rhestr', + 'default' => 'Diofyn', 'breadcrumb' => 'Breadcrumb', - 'status' => 'Status', + 'status' => 'Statws', 'status_active' => 'Gweithredol', 'status_inactive' => 'Anweithredol', 'never' => 'Byth', 'none' => 'Dim un', // Header - 'homepage' => 'Homepage', + 'homepage' => 'Tudalen cartref', 'header_menu_expand' => 'Expand Header Menu', - 'profile_menu' => 'Profile Menu', - 'view_profile' => 'View Profile', - 'edit_profile' => 'Edit Profile', - 'dark_mode' => 'Dark Mode', - 'light_mode' => 'Light Mode', + 'profile_menu' => 'Dewislen Proffil', + 'view_profile' => 'Gweld proffil', + 'edit_profile' => 'Golygu Proffil', + 'dark_mode' => 'Modd Tywyll', + 'light_mode' => 'Modd Golau', 'global_search' => 'Global Search', // Layout tabs @@ -105,6 +105,6 @@ // Footer Link Options // Not directly used but available for convenience to users. - 'privacy_policy' => 'Privacy Policy', + 'privacy_policy' => 'Polisi Preifatrwydd', 'terms_of_service' => 'Terms of Service', ]; diff --git a/lang/cy/editor.php b/lang/cy/editor.php index eba7834cc73..ed8119b0538 100644 --- a/lang/cy/editor.php +++ b/lang/cy/editor.php @@ -136,7 +136,7 @@ 'cell_border_ridge' => 'Ridge', 'cell_border_inset' => 'Inset', 'cell_border_outset' => 'Outset', - 'cell_border_none' => 'None', + 'cell_border_none' => 'Dim un', 'cell_border_hidden' => 'Hidden', // Images, links, details/summary & embed diff --git a/lang/cy/entities.php b/lang/cy/entities.php index 9e620b24ed1..6f2e399f62a 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -17,7 +17,7 @@ 'recent_activity' => 'Recent Activity', 'create_now' => 'Create one now', 'revisions' => 'Revisions', - 'meta_revision' => 'Revision #:revisionCount', + 'meta_revision' => 'Diwygiad #:revisionCount', 'meta_created' => 'Created :timeLength', 'meta_created_name' => 'Created :timeLength by :user', 'meta_updated' => 'Updated :timeLength', @@ -26,7 +26,7 @@ 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', 'entity_select' => 'Entity Select', 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', - 'images' => 'Images', + 'images' => 'Delweddau', 'my_recent_drafts' => 'My Recent Drafts', 'my_recently_viewed' => 'My Recently Viewed', 'my_most_viewed_favourites' => 'My Most Viewed Favourites', @@ -36,7 +36,7 @@ 'no_pages_recently_updated' => 'No pages have been recently updated', 'export' => 'Export', 'export_html' => 'Contained Web File', - 'export_pdf' => 'PDF File', + 'export_pdf' => 'Ffeil PDF', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', 'default_template' => 'Default Page Template', @@ -58,7 +58,7 @@ // Search 'search_results' => 'Search Results', 'search_total_results_found' => ':count result found|:count total results found', - 'search_clear' => 'Clear Search', + 'search_clear' => 'Clirio\'r Chwiliad', 'search_no_pages' => 'No pages matched this search', 'search_for_term' => 'Search for :term', 'search_more' => 'More Results', @@ -67,7 +67,7 @@ 'search_content_type' => 'Content Type', 'search_exact_matches' => 'Exact Matches', 'search_tags' => 'Tag Searches', - 'search_options' => 'Options', + 'search_options' => 'Opsiynau', 'search_viewed_by_me' => 'Viewed by me', 'search_not_viewed_by_me' => 'Not viewed by me', 'search_permissions_set' => 'Permissions set', @@ -83,14 +83,14 @@ 'search_update' => 'Update Search', // Shelves - 'shelf' => 'Shelf', - 'shelves' => 'Shelves', - 'x_shelves' => ':count Shelf|:count Shelves', + 'shelf' => 'Silff', + 'shelves' => 'Silffau', + 'x_shelves' => ':count Silff|:count Shelves', 'shelves_empty' => 'No shelves have been created', 'shelves_create' => 'Create New Shelf', 'shelves_popular' => 'Popular Shelves', - 'shelves_new' => 'New Shelves', - 'shelves_new_action' => 'New Shelf', + 'shelves_new' => 'Silffau Newydd', + 'shelves_new_action' => 'Silff Newydd', 'shelves_popular_empty' => 'The most popular shelves will appear here.', 'shelves_new_empty' => 'The most recently created shelves will appear here.', 'shelves_save' => 'Save Shelf', @@ -116,8 +116,8 @@ 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', // Books - 'book' => 'Book', - 'books' => 'Books', + 'book' => 'Llyfr', + 'books' => 'Llyfrau', 'x_books' => ':count Book|:count Books', 'books_empty' => 'No books have been created', 'books_popular' => 'Popular Books', @@ -126,19 +126,19 @@ 'books_new_action' => 'New Book', 'books_popular_empty' => 'The most popular books will appear here.', 'books_new_empty' => 'The most recently created books will appear here.', - 'books_create' => 'Create New Book', - 'books_delete' => 'Delete Book', - 'books_delete_named' => 'Delete Book :bookName', + 'books_create' => 'Creu Llyfr Newydd', + 'books_delete' => 'Dileu Llyfr', + 'books_delete_named' => 'Dileu :bookName Llyfr', 'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.', 'books_delete_confirmation' => 'Are you sure you want to delete this book?', - 'books_edit' => 'Edit Book', - 'books_edit_named' => 'Edit Book :bookName', - 'books_form_book_name' => 'Book Name', + 'books_edit' => 'Golygu\'r Llyfr', + 'books_edit_named' => 'Golygu :bookName Llyfr', + 'books_form_book_name' => 'Enw\'r Llyfr', 'books_save' => 'Save Book', 'books_permissions' => 'Book Permissions', 'books_permissions_updated' => 'Book Permissions Updated', 'books_empty_contents' => 'No pages or chapters have been created for this book.', - 'books_empty_create_page' => 'Create a new page', + 'books_empty_create_page' => 'Creu tudalen newydd', 'books_empty_sort_current_book' => 'Sort the current book', 'books_empty_add_chapter' => 'Add a chapter', 'books_permissions_active' => 'Book Permissions Active', @@ -165,18 +165,18 @@ 'books_sort_move_book_end' => 'Move to End of Book', 'books_sort_move_before_chapter' => 'Move to Before Chapter', 'books_sort_move_after_chapter' => 'Move to After Chapter', - 'books_copy' => 'Copy Book', - 'books_copy_success' => 'Book successfully copied', + 'books_copy' => 'Copio Llyfr', + 'books_copy_success' => 'Llyfr wedi\'i copio\'n llwyddiannus', // Chapters - 'chapter' => 'Chapter', - 'chapters' => 'Chapters', - 'x_chapters' => ':count Chapter|:count Chapters', + 'chapter' => 'Pennod', + 'chapters' => 'Penodau', + 'x_chapters' => ':count Pennod|:count Penodau', 'chapters_popular' => 'Popular Chapters', - 'chapters_new' => 'New Chapter', - 'chapters_create' => 'Create New Chapter', - 'chapters_delete' => 'Delete Chapter', - 'chapters_delete_named' => 'Delete Chapter :chapterName', + 'chapters_new' => 'Pennod Newydd', + 'chapters_create' => 'Creu Pennod Newydd', + 'chapters_delete' => 'Dileu Pennod', + 'chapters_delete_named' => 'Dileu :chapterName Pennod', 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.', 'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?', 'chapters_edit' => 'Edit Chapter', @@ -196,13 +196,13 @@ // Pages 'page' => 'Page', 'pages' => 'Pages', - 'x_pages' => ':count Page|:count Pages', + 'x_pages' => ':count Tudalen|:count Tudalennau', 'pages_popular' => 'Popular Pages', - 'pages_new' => 'New Page', + 'pages_new' => 'Tudalen Newydd', 'pages_attachments' => 'Attachments', 'pages_navigation' => 'Page Navigation', - 'pages_delete' => 'Delete Page', - 'pages_delete_named' => 'Delete Page :pageName', + 'pages_delete' => 'Dileu Tudalen', + 'pages_delete_named' => 'Dileu :pageName Tudalen', 'pages_delete_draft_named' => 'Delete Draft Page :pageName', 'pages_delete_draft' => 'Delete Draft Page', 'pages_delete_success' => 'Page deleted', @@ -234,9 +234,9 @@ 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', 'pages_save' => 'Save Page', - 'pages_title' => 'Page Title', - 'pages_name' => 'Page Name', - 'pages_md_editor' => 'Editor', + 'pages_title' => 'Teitl y Dudalen', + 'pages_name' => 'Enw\'r Dudalen', + 'pages_md_editor' => 'Golygydd', 'pages_md_preview' => 'Preview', 'pages_md_insert_image' => 'Insert Image', 'pages_md_insert_link' => 'Insert Entity Link', @@ -282,7 +282,7 @@ 'pages_permissions_active' => 'Page Permissions Active', 'pages_initial_revision' => 'Initial publish', 'pages_references_update_revision' => 'System auto-update of internal links', - 'pages_initial_name' => 'New Page', + 'pages_initial_name' => 'Tudalen Newydd', 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', @@ -340,7 +340,7 @@ 'attach' => 'Attach', 'attachments_insert_link' => 'Add Attachment Link to Page', 'attachments_edit_file' => 'Edit File', - 'attachments_edit_file_name' => 'File Name', + 'attachments_edit_file_name' => 'Enw\'r Ffeil', 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', 'attachments_order_updated' => 'Attachment order updated', 'attachments_updated_success' => 'Attachment details updated', @@ -412,12 +412,12 @@ 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', // Watch Options - 'watch' => 'Watch', + 'watch' => 'Gwylio', 'watch_title_default' => 'Default Preferences', 'watch_desc_default' => 'Revert watching to just your default notification preferences.', 'watch_title_ignore' => 'Ignore', 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', - 'watch_title_new' => 'New Pages', + 'watch_title_new' => 'Tudalennau Newydd', 'watch_desc_new' => 'Notify when any new page is created within this item.', 'watch_title_updates' => 'All Page Updates', 'watch_desc_updates' => 'Notify upon all new pages and page changes.', diff --git a/lang/cy/preferences.php b/lang/cy/preferences.php index 2872f5f3c65..89087c9deff 100644 --- a/lang/cy/preferences.php +++ b/lang/cy/preferences.php @@ -5,7 +5,7 @@ */ return [ - 'my_account' => 'My Account', + 'my_account' => 'Fy Nghyfrif', 'shortcuts' => 'Shortcuts', 'shortcuts_interface' => 'UI Shortcut Preferences', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index f4c84092c6f..52e03ff15d9 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -7,63 +7,63 @@ return [ // Common Messages - 'settings' => 'Settings', - 'settings_save' => 'Save Settings', - 'system_version' => 'System Version', - 'categories' => 'Categories', + 'settings' => 'Gosodiadau', + 'settings_save' => 'Cadw Gosodiadau', + 'system_version' => 'Fersiwn System', + 'categories' => 'Categorïau', // App Settings - 'app_customization' => 'Customization', - 'app_features_security' => 'Features & Security', - 'app_name' => 'Application Name', - 'app_name_desc' => 'This name is shown in the header and in any system-sent emails.', - 'app_name_header' => 'Show name in header', - 'app_public_access' => 'Public Access', - 'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.', - 'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.', - 'app_public_access_toggle' => 'Allow public access', - 'app_public_viewing' => 'Allow public viewing?', - 'app_secure_images' => 'Higher Security Image Uploads', - 'app_secure_images_toggle' => 'Enable higher security image uploads', - 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', - 'app_default_editor' => 'Default Page Editor', - 'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.', - 'app_custom_html' => 'Custom HTML Head Content', - 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', - 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', - 'app_logo' => 'Application Logo', - 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', - 'app_icon' => 'Application Icon', - 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', - 'app_homepage' => 'Application Homepage', - 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', - 'app_homepage_select' => 'Select a page', - 'app_footer_links' => 'Footer Links', - 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', - 'app_footer_links_label' => 'Link Label', - 'app_footer_links_url' => 'Link URL', - 'app_footer_links_add' => 'Add Footer Link', - 'app_disable_comments' => 'Disable Comments', - 'app_disable_comments_toggle' => 'Disable comments', - 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
    Existing comments are not shown.', + 'app_customization' => 'Addasiad', + 'app_features_security' => 'Nodweddion & Diogelwch', + 'app_name' => 'Enw\'r Rhaglen', + 'app_name_desc' => 'Dangosir yr enw hwn yn y pennawd ac mewn unrhyw negeseuon e-bost a anfonir gan y system.', + 'app_name_header' => 'Dangos enw yn y pennyn', + 'app_public_access' => 'Mynediad Cyhoeddus', + 'app_public_access_desc' => 'Bydd galluogi\'r opsiwn hwn yn caniatáu i ymwelwyr, nad ydynt wedi mewngofnodi, gael mynediad at gynnwys yn achos BookStack.', + 'app_public_access_desc_guest' => 'Gellir rheoli mynediad i ymwelwyr cyhoeddus trwy\'r defnyddiwr "Gwestai".', + 'app_public_access_toggle' => 'Caniatáu mynediad i\'r cyhoedd', + 'app_public_viewing' => 'Caniatáu i\'r cyhoedd weld?', + 'app_secure_images' => 'Uwchlwytho Delwedd Diogelwch Uwch', + 'app_secure_images_toggle' => 'Galluogi uwchlwytho delwedd diogelwch uwch', + 'app_secure_images_desc' => 'Am resymau perfformiad, mae\'r holl ddelweddau yn gyhoeddus. Mae\'r opsiwn hwn yn ychwanegu llinyn ar hap, anodd ei ddyfalu o flaen urls delwedd. Sicrhau nad yw mynegeion cyfeiriadur yn cael eu galluogi i atal mynediad hawdd.', + 'app_default_editor' => 'Golygydd Tudalen Diofyn', + 'app_default_editor_desc' => 'Dewiswch pa olygydd fydd yn cael ei ddefnyddio yn ddiofyn wrth olygu tudalennau newydd. Gellir diystyru hyn ar lefel tudalen os yw’r caniatâd yn galluogi hyn.', + 'app_custom_html' => 'Cynnwys Pen HTML wedi’i deilwra', + 'app_custom_html_desc' => 'Bydd unrhyw gynnwys a ychwanegir yma yn cael ei fewnosod i waelod yr adran ar bob tudalen. Mae hyn yn ddefnyddiol ar gyfer diystyru arddulliau neu ychwanegu cod dadansoddeg.', + 'app_custom_html_disabled_notice' => 'Mae cynnwys pen HTML wedi\'i deilwra wedi’i analluogi ar y dudalen gosodiadau hon i sicrhau y gellir troi unrhyw newidiadau toriadol yn ôl.', + 'app_logo' => 'Logo’r Rhaglen', + 'app_logo_desc' => 'Defnyddir hwn ym mar pennawd y rhaglen, ymhlith ardaloedd eraill. Dylai\'r ddelwedd hon fod yn 86px o uchder. Bydd delweddau mawr yn cael eu graddio i lawr.', + 'app_icon' => 'Eicon yr Ap', + 'app_icon_desc' => 'Defnyddir yr eicon hwn ar gyfer tabiau porwr ac eiconau llwybr byr. Dylai hwn fod yn ddelwedd PNG sgwâr 256px.', + 'app_homepage' => 'Hafan y Rhaglen', + 'app_homepage_desc' => 'Dewiswch wedd i\'w dangos ar yr hafan yn hytrach na\'r wedd ddiofyn. Anwybyddir caniatâd tudalen ar gyfer tudalennau dethol.', + 'app_homepage_select' => 'Dewiswch dudalen', + 'app_footer_links' => 'Dolenni Troedynnau', + 'app_footer_links_desc' => 'Ychwanegwch ddolenni i\'w dangos o fewn troedyn y safle. Bydd y rhain yn cael eu harddangos ar waelod y rhan fwyaf o dudalennau, gan gynnwys y rhai nad oes angen mewngofnodi. Gallwch ddefnyddio label "trans:: i ddefnyddio cyfieithiadau wedi\'u diffinio gan y system. Er enghraifft: Bydd defnyddio "trans::common.privacy_policy" yn darparu\'r testun wedi’i gyfieithu "Polisi Preifatrwydd" a "trans::common.terms_of_service" yn darparu\'r testun wedi’i gyfieithu "Telerau Gwasanaeth".', + 'app_footer_links_label' => 'Label Dolen', + 'app_footer_links_url' => 'URL Dolen', + 'app_footer_links_add' => 'Ychwanegu Dolen Troedyn', + 'app_disable_comments' => 'Analluogi Sylwadau', + 'app_disable_comments_toggle' => 'Analluogi sylwadau', + 'app_disable_comments_desc' => 'Analluogi sylwadau ar draws pob tudalen yn y rhaglen.
    Nid yw\'r sylwadau presennol yn cael eu dangos.', // Color settings - 'color_scheme' => 'Application Color Scheme', - 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', - 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', - 'app_color' => 'Primary Color', - 'link_color' => 'Default Link Color', - 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', - 'bookshelf_color' => 'Shelf Color', - 'book_color' => 'Book Color', - 'chapter_color' => 'Chapter Color', - 'page_color' => 'Page Color', - 'page_draft_color' => 'Page Draft Color', + 'color_scheme' => 'Cynllun Lliw’r Rhaglen', + 'color_scheme_desc' => 'Gosodwch y lliwiau i\'w defnyddio yn rhyngwyneb defnyddiwr y rhaglen. Gellir ffurfweddu lliwiau ar wahân ar gyfer moddau tywyll a golau i gyd-fynd â\'r thema orau a sicrhau ei fod yn ddarllenadwy.', + 'ui_colors_desc' => 'Gosodwch liw sylfaenol y rhaglen a’r lliw diofyn y ddolen. Defnyddir y lliw sylfaenol yn bennaf ar gyfer baner y pennyn, y botymau ac addurniadau rhyngwyneb. Defnyddir lliw diofyn y ddolen ar gyfer dolenni a chamau gweithredu testun, o fewn cynnwys ysgrifenedig ac yn rhyngwyneb y rhaglen.', + 'app_color' => 'Prif Liw', + 'link_color' => 'Lliw Diofyn y Ddolen', + 'content_colors_desc' => 'Gosodwch liwiau ar gyfer pob elfen yn hierarchaeth trefn y dudalen. Argymhellir dewis lliwiau â disgleirdeb tebyg i\'r lliwiau diofyn ar gyfer darllenadwyedd.', + 'bookshelf_color' => 'Lliw Silff', + 'book_color' => 'Lliw Llyfr', + 'chapter_color' => 'Lliw Pennod', + 'page_color' => 'Lliw Tudalen', + 'page_draft_color' => 'Lliw Tudalen Ddrafft', // Registration Settings - 'reg_settings' => 'Registration', - 'reg_enable' => 'Enable Registration', - 'reg_enable_toggle' => 'Enable registration', + 'reg_settings' => 'Cofrestriad', + 'reg_enable' => 'Galluogi Cofrestru', + 'reg_enable_toggle' => 'Galluogi cofrestru', 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', 'reg_default_role' => 'Default user role after registration', 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', @@ -75,215 +75,215 @@ 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', // Maintenance settings - 'maint' => 'Maintenance', - 'maint_image_cleanup' => 'Cleanup Images', - 'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.', - 'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions', - 'maint_image_cleanup_run' => 'Run Cleanup', - 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', - 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', - 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', - 'maint_send_test_email' => 'Send a Test Email', - 'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.', - 'maint_send_test_email_run' => 'Send test email', - 'maint_send_test_email_success' => 'Email sent to :address', - 'maint_send_test_email_mail_subject' => 'Test Email', - 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', - 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', - 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', - 'maint_recycle_bin_open' => 'Open Recycle Bin', - 'maint_regen_references' => 'Regenerate References', - 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', - 'maint_regen_references_success' => 'Reference index has been regenerated!', - 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + 'maint' => 'Cynnal', + 'maint_image_cleanup' => 'Glanhau Delweddau', + 'maint_image_cleanup_desc' => 'Sganio tudalennau a chynnwys adolygu i wirio pa ddelweddau a darluniau sy\'n cael eu defnyddio ar hyn o bryd a pha ddelweddau sy\'n cael eu diddymu. Gwnewch yn siŵr eich bod yn creu cronfa ddata lawn a chopi wrth gefn o’r ddelwedd cyn rhedeg hwn.', + 'maint_delete_images_only_in_revisions' => 'Hefyd dilëwch ddelweddau sydd ond yn bodoli mewn dhen dudalennau', + 'maint_image_cleanup_run' => 'Rhedeg Glanhau', + 'maint_image_cleanup_warning' => 'Daethpwyd o hyd i :count o ddelweddau nas defnyddiwyd o bosibl. Ydych chi\'n siŵr eich bod eisiau dileu’r delweddau hyn?', + 'maint_image_cleanup_success' => 'daethpwyd o hyd i :count o ddelweddau nas defnyddiwyd o bosibl a chawsant eu dileu!', + 'maint_image_cleanup_nothing_found' => 'Daethpwyd o hyd i ddim ddelweddau, Dim byd wedi\'i dileu!', + 'maint_send_test_email' => 'Anfon E-bost Prawf', + 'maint_send_test_email_desc' => 'Mae hyn yn anfon e-bost prawf i\'ch cyfeiriad e-bost a nodir yn eich proffil.', + 'maint_send_test_email_run' => 'Anfon e-bost prawf', + 'maint_send_test_email_success' => 'Anfonwyd e-bost at :address', + 'maint_send_test_email_mail_subject' => 'E-bost Prawf', + 'maint_send_test_email_mail_greeting' => 'Mae\'n ymddangos bod anfon e-bost yn gweithio!', + 'maint_send_test_email_mail_text' => 'Llongyfarchiadau! Gan eich bod wedi derbyn yr hysbysiad e-bost hwn, mae\'n ymddangos bod eich gosodiadau e-bost wedi\'u ffurfweddu\'n iawn.', + 'maint_recycle_bin_desc' => 'Caiff silffoedd, llyfrau, penodau a thudalennau wedi\'u dileu eu hanfon i’r bin ailgylchu fel y gellir eu hadfer neu eu dileu\'n barhaol. Bydd eitemau hŷn yn y bin ailgylchu yn cael eu dileu’n awtomatig ar ôl ychydig o bosibl, gan ddibynnu ar ffurfweddiad y system.', + 'maint_recycle_bin_open' => 'Agor y Bin Ailgylchu', + 'maint_regen_references' => 'Atgynhyrchu Cyfeiriadau', + 'maint_regen_references_desc' => 'Bydd y weithred hon yn ailadeiladu\'r mynegai cyfeirio traws-eitem yn y gronfa ddata. Fel arfer, caiff hyn ei drin yn awtomatig ond gall y weithred hon fod yn ddefnyddiol i fynegeio hen gynnwys neu gynnwys a ychwanegwyd trwy ddulliau answyddogol.', + 'maint_regen_references_success' => 'Mae’r mynegai cyfeirio wedi cael ei atgynhyrchu!', + 'maint_timeout_command_note' => 'Noder: Gall y weithred hon gymryd amser i redeg, a all arwain at faterion amseru mewn rhai amgylcheddau gwe. Fel dewis arall, cyflawnir y weithred hon gan ddefnyddio gorchymyn terfynell.', // Recycle Bin - 'recycle_bin' => 'Recycle Bin', - 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', - 'recycle_bin_deleted_item' => 'Deleted Item', - 'recycle_bin_deleted_parent' => 'Parent', - 'recycle_bin_deleted_by' => 'Deleted By', - 'recycle_bin_deleted_at' => 'Deletion Time', - 'recycle_bin_permanently_delete' => 'Permanently Delete', - 'recycle_bin_restore' => 'Restore', - 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', - 'recycle_bin_empty' => 'Empty Recycle Bin', - 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', - 'recycle_bin_destroy_list' => 'Items to be Destroyed', - 'recycle_bin_restore_list' => 'Items to be Restored', - 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', - 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', - 'recycle_bin_restore_parent' => 'Restore Parent', - 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', - 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + 'recycle_bin' => 'Bin Ailgylchu', + 'recycle_bin_desc' => 'Yma gallwch adfer eitemau sydd wedi\'u dileu neu ddewis eu dileu yn barhaol oddi ar y system. Nid yw’r rhestr hon wedi’i hidlo yn wahanol i restrau gweithgaredd tebyg yn y system lle mae hidlwyr caniatâd yn cael eu cymhwyso.', + 'recycle_bin_deleted_item' => 'Eitem wedi\'i Dileu', + 'recycle_bin_deleted_parent' => 'Rhiant', + 'recycle_bin_deleted_by' => 'Dilëwyd gan', + 'recycle_bin_deleted_at' => 'Amser Dileu', + 'recycle_bin_permanently_delete' => 'Dileu yn Barhaol', + 'recycle_bin_restore' => 'Adfer', + 'recycle_bin_contents_empty' => 'Mae\'r bin ailgylchu yn wag ar hyn o bryd', + 'recycle_bin_empty' => 'Gwagio’r Bin Ailgylchu', + 'recycle_bin_empty_confirm' => 'Bydd hyn yn dileu pob eitem yn y bin ailgylchu yn barhaol gan gynnwys popeth sydd wedi\'i gynnwys ym mhob eitem. Ydych chi\'n siŵr eich bod am wagio’r bin ailgylchu?', + 'recycle_bin_destroy_confirm' => 'Bydd y weithred hon yn dileu\'r eitem hon o\'r system yn barhaol, ynghyd ag unrhyw elfennau plentyn a restrir isod, ac ni fyddwch yn gallu adfer y cynnwys hwn. Ydych chi\'n siŵr eich bod eisiau dileu\'r eitem hon yn barhaol?', + 'recycle_bin_destroy_list' => 'Eitemau i\'w Dinistrio', + 'recycle_bin_restore_list' => 'Eitemau i\'w Hadfer', + 'recycle_bin_restore_confirm' => 'Bydd y weithred hon yn adfer yr eitem a ddilëwyd, gan gynnwys unrhyw elfennau plentyn, i\'w lleoliad gwreiddiol. Os yw\'r lleoliad gwreiddiol wedi\'i ddileu ers hynny, a’i fod bellach yn y bin ailgylchu, bydd angen adfer yr eitem riant hefyd.', + 'recycle_bin_restore_deleted_parent' => 'Mae rhiant yr eitem hon hefyd wedi\'i dileu. Bydd y rhain yn parhau i fod wedi’u dileu nes bod y rhiant hwnnw hefyd yn cael ei adfer.', + 'recycle_bin_restore_parent' => 'Adfer Rhiant', + 'recycle_bin_destroy_notification' => 'Dilëwyd cyfanswm o :count o eitemau o\'r bin ailgylchu.', + 'recycle_bin_restore_notification' => 'Adferwyd cyfanswm o :count o eitemau o\'r bin ailgylchu.', // Audit Log - 'audit' => 'Audit Log', - 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', - 'audit_event_filter' => 'Event Filter', - 'audit_event_filter_no_filter' => 'No Filter', - 'audit_deleted_item' => 'Deleted Item', - 'audit_deleted_item_name' => 'Name: :name', - 'audit_table_user' => 'User', - 'audit_table_event' => 'Event', - 'audit_table_related' => 'Related Item or Detail', - 'audit_table_ip' => 'IP Address', - 'audit_table_date' => 'Activity Date', + 'audit' => 'Log Awdit', + 'audit_desc' => 'Mae\'r cofnod archwilio hwn yn dangos rhestr o weithgareddau sydd wedi\'u holrhain yn y system. Nid yw’r rhestr hon wedi’i hidlo yn wahanol i restrau gweithgaredd tebyg yn y system lle mae hidlwyr caniatâd yn cael eu cymhwyso.', + 'audit_event_filter' => 'Hidlydd Digwyddiad', + 'audit_event_filter_no_filter' => 'Dim Fidlydd', + 'audit_deleted_item' => 'Eitem wedi\'i Dileu', + 'audit_deleted_item_name' => 'Enw: :name', + 'audit_table_user' => 'Defnyddiwr', + 'audit_table_event' => 'Digwyddiad', + 'audit_table_related' => 'Eitem neu Fanylion Cysylltiedig', + 'audit_table_ip' => 'Cyfeiriad IP', + 'audit_table_date' => 'Dyddiad Gweithgaredd', 'audit_date_from' => 'Date Range From', 'audit_date_to' => 'Date Range To', // Role Settings - 'roles' => 'Roles', - 'role_user_roles' => 'User Roles', + 'roles' => 'Rolau', + 'role_user_roles' => 'Rolau Defnyddiwr', 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', 'roles_x_users_assigned' => ':count user assigned|:count users assigned', - 'roles_x_permissions_provided' => ':count permission|:count permissions', + 'roles_x_permissions_provided' => ':count caniatâd|:count caniatâd', 'roles_assigned_users' => 'Assigned Users', 'roles_permissions_provided' => 'Provided Permissions', - 'role_create' => 'Create New Role', - 'role_delete' => 'Delete Role', - 'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.', - 'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', - 'role_delete_no_migration' => "Don't migrate users", - 'role_delete_sure' => 'Are you sure you want to delete this role?', - 'role_edit' => 'Edit Role', - 'role_details' => 'Role Details', - 'role_name' => 'Role Name', - 'role_desc' => 'Short Description of Role', - 'role_mfa_enforced' => 'Requires Multi-Factor Authentication', - 'role_external_auth_id' => 'External Authentication IDs', - 'role_system' => 'System Permissions', - 'role_manage_users' => 'Manage users', - 'role_manage_roles' => 'Manage roles & role permissions', - 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', - 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', - 'role_manage_page_templates' => 'Manage page templates', - 'role_access_api' => 'Access system API', - 'role_manage_settings' => 'Manage app settings', - 'role_export_content' => 'Export content', - 'role_editor_change' => 'Change page editor', - 'role_notifications' => 'Receive & manage notifications', - 'role_asset' => 'Asset Permissions', - 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', - 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', - 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', - 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', - 'role_all' => 'All', - 'role_own' => 'Own', - 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', - 'role_save' => 'Save Role', - 'role_users' => 'Users in this role', - 'role_users_none' => 'No users are currently assigned to this role', + 'role_create' => 'Creu Rôl Newydd', + 'role_delete' => 'Dileu Rôl', + 'role_delete_confirm' => 'Bydd hyn yn dileu\'r rôl gyda\'r enw \':roleName\'.', + 'role_delete_users_assigned' => 'Mae gan y rôl hon :userCount o ddefnyddwyr wedi’u clustnodi iddi. Os hoffech chi fudo\'r defnyddwyr o\'r rôl hon dewiswch rôl newydd isod.', + 'role_delete_no_migration' => "Peidiwch â mudo defnyddwyr", + 'role_delete_sure' => 'Wyt ti\'n bendant eisiau dileu\'r rôl hwn?', + 'role_edit' => 'Golygu Rôl', + 'role_details' => 'Manylion Rôl', + 'role_name' => 'Enw Rôl', + 'role_desc' => 'Disgrifiad Byr o’r Rôl', + 'role_mfa_enforced' => 'Angen Dilysu Aml-Ffactor', + 'role_external_auth_id' => 'ID Dilysu Allanol', + 'role_system' => 'Caniatâd System', + 'role_manage_users' => 'Rheoli defnyddwyr', + 'role_manage_roles' => 'Rheoli rolau & chaniatâd rolau', + 'role_manage_entity_permissions' => 'Rheoli caniatâd pob llyfr, pennod a thudalen', + 'role_manage_own_entity_permissions' => 'Rheoli caniatâd eich llyfr, pennod a thudalennau eich hun', + 'role_manage_page_templates' => 'Rheoli templedi tudalen', + 'role_access_api' => 'Mynediad i Ryngwyneb Rhaglennu Cymwysiadau (API) system', + 'role_manage_settings' => 'Rheoli gosodiadau apiau', + 'role_export_content' => 'Cynnwys allforio', + 'role_editor_change' => 'Newid golygydd tudalen', + 'role_notifications' => 'Derbyn a rheoli hysbysiadau', + 'role_asset' => 'Caniatâd Asedau', + 'roles_system_warning' => 'Byddwch yn ymwybodol y gall mynediad i unrhyw un o\'r tri chaniatâd uchod ganiatáu i ddefnyddiwr newid eu breintiau eu hunain neu freintiau eraill yn y system. Neilltuo rolau gyda\'r caniatâd hyn i ddefnyddwyr dibynadwy yn unig.', + 'role_asset_desc' => 'Mae\'r caniatâd hwn yn rheoli mynediad diofyn i\'r asedau o fewn y system. Bydd caniatâd ar Lyfrau, Penodau a Thudalennau yn diystyru\'r caniatâd hwn.', + 'role_asset_admins' => 'Mae gweinyddwyr yn cael mynediad awtomatig i\'r holl gynnwys ond gall yr opsiynau hyn ddangos neu guddio opsiynau UI.', + 'role_asset_image_view_note' => 'Mae hyn yn ymwneud â gwelededd o fewn y rheolwr delweddau. Bydd mynediad gwirioneddol i ffeiliau delwedd wedi\'u huwchlwytho yn dibynnu ar opsiwn storio delwedd y system.', + 'role_all' => 'Popeth', + 'role_own' => 'Meddu', + 'role_controlled_by_asset' => 'Wedi\'u rheoli gan yr ased y maent yn cael eu huwchlwytho iddo', + 'role_save' => 'Cadw Rôl', + 'role_users' => 'Defnyddwyr yn y rôl hon', + 'role_users_none' => 'Nid oes unrhyw ddefnyddwyr wedi’u neilltuo i\'r rôl hon ar hyn o bryd', // Users - 'users' => 'Users', - 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', - 'user_profile' => 'User Profile', - 'users_add_new' => 'Add New User', - 'users_search' => 'Search Users', - 'users_latest_activity' => 'Latest Activity', - 'users_details' => 'User Details', - 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', - 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', - 'users_role' => 'User Roles', - 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', - 'users_password' => 'User Password', - 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.', - 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', - 'users_send_invite_option' => 'Send user invite email', + 'users' => 'Defnyddwyr', + 'users_index_desc' => 'Creu a rheoli cyfrifon defnyddwyr unigol o fewn y system. Defnyddir cyfrifon defnyddwyr ar gyfer mewngofnodi a phriodoli cynnwys a gweithgaredd. Mae caniatâd mynediad yn seiliedig ar rôl yn bennaf ond gall perchenogaeth cynnwys defnyddwyr, ymhlith ffactorau eraill, hefyd effeithio ar ganiatâd a mynediad.', + 'user_profile' => 'Proffil Defnyddiwr', + 'users_add_new' => 'Ychwanegu Defnyddiwr Newydd', + 'users_search' => 'Chwilio Defnyddwyr', + 'users_latest_activity' => 'Gweithgaredd Diweddaraf', + 'users_details' => 'Manylion Defnyddiwr', + 'users_details_desc' => 'Gosodwch enw arddangos a chyfeiriad e-bost ar gyfer y defnyddiwr hwn. Bydd y cyfeiriad e-bost yn cael ei ddefnyddio ar gyfer mewngofnodi i’r cais.', + 'users_details_desc_no_email' => 'Gosodwch enw arddangos ar gyfer y defnyddiwr hwn fel y gall eraill eu hadnabod.', + 'users_role' => 'Rolau Defnyddiwr', + 'users_role_desc' => 'Dewiswch ba rolau a neilltuir i’r defnyddiwr hwn. Os yw defnyddiwr yn cael ei neilltuo i rolau lluosog, bydd y caniatâd o\'r rolau hynny yn pentyrru a byddant yn derbyn pob gallu o\'r rolau a neilltuwyd.', + 'users_password' => 'Cyfrinair Defnyddiwr', + 'users_password_desc' => 'Gosodwch gyfrinair a ddefnyddir i fewngofnodi i\'r rhaglen. Rhaid i gyfrineiriau fod yn 8 nod o leiaf.', + 'users_send_invite_text' => 'Gallwch ddewis anfon e-bost gwahoddiad i\'r defnyddiwr hwn sy\'n caniatáu iddynt osod eu cyfrinair eu hunain neu gallwch osod eu cyfrinair eich hun.', + 'users_send_invite_option' => 'Anfon e-bost gwahodd i’r defnyddiwr', 'users_external_auth_id' => 'External Authentication ID', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', - 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', - 'users_delete' => 'Delete User', - 'users_delete_named' => 'Delete user :userName', - 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', - 'users_delete_confirm' => 'Are you sure you want to delete this user?', + 'users_external_auth_id_desc' => 'Pan fydd system ddilysu allanol yn cael ei defnyddio (megis SAML2, OIDC neu LDAP) dyma\'r ID sy\'n cysylltu\'r defnyddiwr BookStack hwn â\'r cyfrif system ddilysu. Gallwch anwybyddu\'r maes hwn os ydych chi\'n defnyddio\'r dilysiad diofyn e-bost.', + 'users_password_warning' => 'Llenwch y canlynol os hoffech newid y cyfrinair ar gyfer y defnyddiwr hwn yn unig.', + 'users_system_public' => 'Mae\'r defnyddiwr hwn yn cynrychioli unrhyw westeion sy\'n ymweld â\'ch enghraifft. Ni ellir ei ddefnyddio i fewngofnodi ond mae\'n cael ei aseinio\'n awtomatig.', + 'users_delete' => 'Dileu Defnyddiwr', + 'users_delete_named' => 'Dileu defnyddiwr :userName', + 'users_delete_warning' => 'Bydd hyn yn dileu\'r defnyddiwr hwn yn llawn gyda\'r enw \':userName\' o\'r system.', + 'users_delete_confirm' => 'Ydych chi\'n siŵr eich bod eisiau dileu’r defnyddiwr hwn?', 'users_migrate_ownership' => 'Migrate Ownership', - 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', - 'users_none_selected' => 'No user selected', - 'users_edit' => 'Edit User', - 'users_edit_profile' => 'Edit Profile', - 'users_avatar' => 'User Avatar', - 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', - 'users_preferred_language' => 'Preferred Language', - 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', - 'users_social_accounts' => 'Social Accounts', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', - 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', - 'users_social_connect' => 'Connect Account', - 'users_social_disconnect' => 'Disconnect Account', - 'users_social_status_connected' => 'Connected', - 'users_social_status_disconnected' => 'Disconnected', - 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', - 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', - 'users_api_tokens' => 'API Tokens', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', - 'users_api_tokens_none' => 'No API tokens have been created for this user', - 'users_api_tokens_create' => 'Create Token', - 'users_api_tokens_expires' => 'Expires', - 'users_api_tokens_docs' => 'API Documentation', - 'users_mfa' => 'Multi-Factor Authentication', - 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', - 'users_mfa_x_methods' => ':count method configured|:count methods configured', - 'users_mfa_configure' => 'Configure Methods', + 'users_migrate_ownership_desc' => 'Dewiswch ddefnyddiwr yma os ydych chi am i ddefnyddiwr arall ddod yn berchennog ar yr holl eitemau sy\'n eiddo i\'r defnyddiwr hwn ar hyn o bryd.', + 'users_none_selected' => 'Ni ddewiswyd defnyddiwr', + 'users_edit' => 'Golygu Defnyddiwr', + 'users_edit_profile' => 'Golygu Proffil', + 'users_avatar' => 'Afatar Defnyddiwr', + 'users_avatar_desc' => 'Dewiswch ddelwedd i gynrychioli\'r defnyddiwr hwn. Dylai hwn fod yn ddelwedd sgwâr tua 256px.', + 'users_preferred_language' => 'Dewis Iaith', + 'users_preferred_language_desc' => 'Bydd yr opsiwn hwn yn newid yr iaith a ddefnyddir ar gyfer rhyngwyneb defnyddiwr y rhaglen. Ni fydd hyn yn effeithio ar unrhyw gynnwys a grëwyd gan y defnyddiwr.', + 'users_social_accounts' => 'Cyfrifon Cymdeithasol', + 'users_social_accounts_desc' => 'Gweld statws y cyfrifon cymdeithasol cysylltiedig ar gyfer y defnyddiwr hwn. Gellir defnyddio cyfrifon cymdeithasol yn ychwanegol at y system ddilysu sylfaenol ar gyfer mynediad system.', + 'users_social_accounts_info' => 'Yma gallwch gysylltu eich cyfrifon eraill ar gyfer mewngofnodi cyflymach a haws. Nid yw datgysylltu cyfrif yma yn diddymu mynediad awdurdodedig blaenorol. Diddymu mynediad o\'ch gosodiadau proffil ar y cyfrif cymdeithasol cysylltiedig.', + 'users_social_connect' => 'Cysylltu Cyfrif', + 'users_social_disconnect' => 'Datgysylltu Cyfrif', + 'users_social_status_connected' => 'Wedi Cysylltu', + 'users_social_status_disconnected' => 'Wedi Datgysylltu', + 'users_social_connected' => 'Mae eich cyfrif :socialAccount wedi\'i gysylltu\'n llwyddiannus â\'ch proffil.', + 'users_social_disconnected' => 'Mae eich cyfrif :socialAccount wedi\'i ddatgysylltu\'n llwyddiannus o\'ch proffil.', + 'users_api_tokens' => 'Tocynnau API', + 'users_api_tokens_desc' => 'Creu a rheoli\'r tocynnau mynediad a ddefnyddir i ddilysu gyda\'r API BookStack REST. Rheolir caniatâd ar gyfer yr API trwy\'r defnyddiwr y mae\'r tocyn yn perthyn iddo.', + 'users_api_tokens_none' => 'Nid oes tocynnau API wedi\'u creu ar gyfer y defnyddiwr hwn', + 'users_api_tokens_create' => 'Creu Tocyn', + 'users_api_tokens_expires' => 'Yn dod i ben', + 'users_api_tokens_docs' => 'Dogfennaeth API', + 'users_mfa' => 'Dilysu Aml-Ffactor', + 'users_mfa_desc' => 'Gosod dilysu aml-ffactor fel haen ychwanegol o ddiogelwch ar gyfer eich cyfrif defnyddiwr.', + 'users_mfa_x_methods' => ':count dull wedi\'i ffurfweddu|:count dull wedi\'u ffurfweddu', + 'users_mfa_configure' => 'Ffurfweddu Dulliau', // API Tokens - 'user_api_token_create' => 'Create API Token', - 'user_api_token_name' => 'Name', - 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', - 'user_api_token_expiry' => 'Expiry Date', - 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', - 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', - 'user_api_token' => 'API Token', - 'user_api_token_id' => 'Token ID', - 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', - 'user_api_token_secret' => 'Token Secret', - 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', - 'user_api_token_created' => 'Token created :timeAgo', - 'user_api_token_updated' => 'Token updated :timeAgo', - 'user_api_token_delete' => 'Delete Token', - 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', - 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + 'user_api_token_create' => 'Creu Tocyn API', + 'user_api_token_name' => 'Enw', + 'user_api_token_name_desc' => 'Rhowch enw darllenadwy i\'ch tocyn i\'ch atgoffa o\'i bwrpas arfaethedig yn y dyfodol.', + 'user_api_token_expiry' => 'Dyddiad Dod i Ben', + 'user_api_token_expiry_desc' => 'Gosodwch ddyddiad pan fydd y tocyn hwn yn dod i ben. Ar ôl y dyddiad hwn, ni fydd ceisiadau a wneir gan ddefnyddio\'r tocyn hwn yn gweithio mwyach. Bydd gadael y maes hwn yn wag yn gosod dyddiad dod i ben 100 mlynedd i\'r dyfodol.', + 'user_api_token_create_secret_message' => 'Yn syth ar ôl creu\'r tocyn hwn bydd "ID tocyn" a "Chyfrinach Tocyn" yn cael eu cynhyrchu a\'u harddangos. Dim ond unwaith y bydd y gyfrinach yn cael ei dangos, felly gwnewch yn siŵr eich bod yn copïo\'r gwerth i rywle diogel cyn bwrw ymlaen.', + 'user_api_token' => 'Tocyn API', + 'user_api_token_id' => 'ID Tocyn', + 'user_api_token_id_desc' => 'Mae hwn yn ddynodwr a gynhyrchir gan y system na ellir ei olygu ar gyfer y tocyn hwn y bydd angen ei ddarparu mewn ceisiadau API.', + 'user_api_token_secret' => 'Cyfrinach Tocyn', + 'user_api_token_secret_desc' => 'Mae hwn yn gyfrinach a gynhyrchir gan y system ar gyfer y tocyn hwn y bydd angen ei ddarparu mewn ceisiadau API. Dim ond unwaith y bydd hyn yn cael ei arddangos, felly copïwch y gwerth hwn i rywle diogel.', + 'user_api_token_created' => 'Crëwyd tocyn :timeAgo', + 'user_api_token_updated' => 'Diweddarwyd tocyn :timeAgo', + 'user_api_token_delete' => 'Dileu Tocyn', + 'user_api_token_delete_warning' => 'Bydd hyn yn dileu\'r tocyn API hwn yn llawn gyda\'r enw \':tokenName\' o\'r system.', + 'user_api_token_delete_confirm' => 'Ydych chi\'n siŵr eich bod eisiau dileu’r tocyn API hwn?', // Webhooks - 'webhooks' => 'Webhooks', - 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', - 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', - 'webhooks_create' => 'Create New Webhook', - 'webhooks_none_created' => 'No webhooks have yet been created.', - 'webhooks_edit' => 'Edit Webhook', - 'webhooks_save' => 'Save Webhook', - 'webhooks_details' => 'Webhook Details', + 'webhooks' => 'Gwe-fachau', + 'webhooks_index_desc' => 'Mae gwe-fachau’n ffordd o anfon data at URL allanol pan fydd rhai gweithredoedd a digwyddiadau yn digwydd o fewn y system sy\'n caniatáu integreiddio ar sail digwyddiadau gyda llwyfannau allanol megis systemau negeseuon neu hysbysu.', + 'webhooks_x_trigger_events' => ':count sbarduno digwyddiad|:count sbarduno digwyddiadau', + 'webhooks_create' => 'Creu Gwe-fachyn Newydd', + 'webhooks_none_created' => 'Nid oes unrhyw we-fachau wedi\'u creu eto.', + 'webhooks_edit' => 'Golygu Gwe-fachyn', + 'webhooks_save' => 'Cadw Gwe-fachyn', + 'webhooks_details' => 'Manylion Gwe-fachyn', 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', - 'webhooks_events' => 'Webhook Events', - 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', - 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', - 'webhooks_events_all' => 'All system events', - 'webhooks_name' => 'Webhook Name', - 'webhooks_timeout' => 'Webhook Request Timeout (Seconds)', - 'webhooks_endpoint' => 'Webhook Endpoint', - 'webhooks_active' => 'Webhook Active', - 'webhook_events_table_header' => 'Events', - 'webhooks_delete' => 'Delete Webhook', - 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', - 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', - 'webhooks_format_example' => 'Webhook Format Example', - 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', - 'webhooks_status' => 'Webhook Status', - 'webhooks_last_called' => 'Last Called:', - 'webhooks_last_errored' => 'Last Errored:', - 'webhooks_last_error_message' => 'Last Error Message:', + 'webhooks_events' => 'Digwyddiadau Gwe-fachyn', + 'webhooks_events_desc' => 'Dewiswch yr holl ddigwyddiadau a ddylai sbarduno\'r gwe-fachyn hwn i gael ei alw.', + 'webhooks_events_warning' => 'Cadwch mewn cof y bydd y digwyddiadau hyn yn cael eu sbarduno ar gyfer yr holl ddigwyddiadau a ddewiswyd, hyd yn oed os rhoddir caniatâd arferol ar waith. Gwnewch yn siŵr na fydd defnyddio\'r gwe-fachyn hwn yn datgelu cynnwys cyfrinachol.', + 'webhooks_events_all' => 'Holl ddigwyddiadau\'r system', + 'webhooks_name' => 'Enw Gwe-fachyn', + 'webhooks_timeout' => 'Terfyn Amser Gwneud Cais Gwe-fachyn (Eiliadau)', + 'webhooks_endpoint' => 'Pwynt Terfyn Gwe-fachyn', + 'webhooks_active' => 'Gwe-fachyn Byw', + 'webhook_events_table_header' => 'Digwyddiadau', + 'webhooks_delete' => 'Dileu Gwe-fachyn', + 'webhooks_delete_warning' => 'Bydd hyn yn dileu\'r gwe-fachyn hwn yn llawn gyda\'r enw \':webhookName\', o\'r system.', + 'webhooks_delete_confirm' => 'Ydych chi\'n siŵr eich bod eisiau dileu’r gwe-fachyn hwn?', + 'webhooks_format_example' => 'Enghraifft Fformat Gwe-fachyn', + 'webhooks_format_example_desc' => 'Anfonir data Gwe-fachyn fel cais POST i\'r pwynt terfyn wedi\'i ffurfweddu fel JSON yn dilyn y fformat isod. Mae\'r nodweddion "related_item" ac "url" yn ddewisol a byddant yn dibynnu ar y math o ddigwyddiad a sbardunir.', + 'webhooks_status' => 'Statws Gwe-fachyn', + 'webhooks_last_called' => 'Galwyd Ddiwethaf:', + 'webhooks_last_errored' => 'Cafwyd Gwall Ddiwethaf:', + 'webhooks_last_error_message' => 'Neges Wall Ddiwethaf:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'Trwyddedau', + 'licenses_desc' => 'Mae\'r dudalen hon yn manylu ar wybodaeth am drwydded ar gyfer BookStack yn ychwanegol at y prosiectau a’r llyfrgelloedd a ddefnyddir o fewn BookStack. Dim ond mewn cyd-destun datblygu y gellir defnyddio llawer o’r prosiectau a restrir.', + 'licenses_bookstack' => 'Trwydded BookStack', + 'licenses_php' => 'Trwyddedau Llyfrgell PHP', + 'licenses_js' => 'Trwyddedau Llyfrgell JavaScript', + 'licenses_other' => 'Trwyddedau Eraill', + 'license_details' => 'Manylion y Drwydded', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/cy/validation.php b/lang/cy/validation.php index 87efe285093..00664184174 100644 --- a/lang/cy/validation.php +++ b/lang/cy/validation.php @@ -41,74 +41,74 @@ 'array' => 'Rhaid i\'r :attribute fod yn fwy na :value eitemau.', ], 'gte' => [ - 'numeric' => 'The :attribute must be greater than or equal :value.', - 'file' => 'The :attribute must be greater than or equal :value kilobytes.', - 'string' => 'The :attribute must be greater than or equal :value characters.', - 'array' => 'The :attribute must have :value items or more.', + 'numeric' => 'Rhaid i’r :attribute fod yn fwy na, neu’n gyfartal â :value.', + 'file' => 'Rhaid i’r :attribute fod yn fwy na, neu’n gyfartal â :value cilobeit.', + 'string' => 'Rhaid i’r :attribute fod yn fwy na, neu’n gyfartal â :value nod.', + 'array' => 'Rhaid i’r :attribute fod â :value o eitemau neu fwy.', ], - 'exists' => 'The selected :attribute is invalid.', - 'image' => 'The :attribute must be an image.', - 'image_extension' => 'The :attribute must have a valid & supported image extension.', - 'in' => 'The selected :attribute is invalid.', - 'integer' => 'The :attribute must be an integer.', - 'ip' => 'The :attribute must be a valid IP address.', - 'ipv4' => 'The :attribute must be a valid IPv4 address.', - 'ipv6' => 'The :attribute must be a valid IPv6 address.', - 'json' => 'The :attribute must be a valid JSON string.', + 'exists' => 'Mae\'r dewis :attribute yn annilys.', + 'image' => 'Rhaid i’r :attribute fod yn ddelwedd.', + 'image_extension' => 'Rhaid i’r :attribute fod ag estyniad delwedd dilys & gefnogir.', + 'in' => 'Mae\'r dewis :attribute yn annilys.', + 'integer' => 'Rhaid i’r :attribute fod yn gyfanrif.', + 'ip' => 'Rhaid i’r :attribute fod yn gyfeiriad IP dilys.', + 'ipv4' => 'Rhaid i’r :attribute fod yn gyfeiriad IPv4 dilys.', + 'ipv6' => 'Rhaid i’r :attribute fod yn gyfeiriad IPv6 dilys.', + 'json' => 'Rhaid i’r :attribute fod yn llinyn JSON dilys.', 'lt' => [ - 'numeric' => 'The :attribute must be less than :value.', - 'file' => 'The :attribute must be less than :value kilobytes.', - 'string' => 'The :attribute must be less than :value characters.', - 'array' => 'The :attribute must have less than :value items.', + 'numeric' => 'Rhaid i’r :attribute fod yn llai na :value.', + 'file' => 'Rhaid i’r :attribute fod yn llai na :value cilobeit.', + 'string' => 'Rhaid i’r :attribute fod yn llai na :value nod.', + 'array' => 'Rhaid i’r :attribute fod â llai na :value o eitemau.', ], 'lte' => [ - 'numeric' => 'The :attribute must be less than or equal :value.', - 'file' => 'The :attribute must be less than or equal :value kilobytes.', - 'string' => 'The :attribute must be less than or equal :value characters.', - 'array' => 'The :attribute must not have more than :value items.', + 'numeric' => 'Rhaid i’r :attribute fod yn llai na, neu’n gyfartal â :value.', + 'file' => 'Rhaid i’r :attribute fod yn llai na, neu’n gyfartal â :value cilobeit.', + 'string' => 'Rhaid i’r :attribute fod yn llai na, neu’n gyfartal â :value nod.', + 'array' => 'Ni ddylai’r :attribute fod â mwy na :value o eitemau.', ], 'max' => [ - 'numeric' => 'The :attribute may not be greater than :max.', - 'file' => 'The :attribute may not be greater than :max kilobytes.', - 'string' => 'The :attribute may not be greater than :max characters.', - 'array' => 'The :attribute may not have more than :max items.', + 'numeric' => 'Ni ddylai’r :attribute fod yn fwy na :max.', + 'file' => 'Ni ddylai’r :attribute fod yn fwy na :max cilobeit.', + 'string' => 'Ni ddylai’r :attribute fod yn fwy na :max nod.', + 'array' => 'Ni ddylai’r :attribute fod â mwy na :max o eitemau.', ], - 'mimes' => 'The :attribute must be a file of type: :values.', + 'mimes' => 'Rhaid i’r :attribute fod yn ffeil o fath: :values.', 'min' => [ - 'numeric' => 'The :attribute must be at least :min.', - 'file' => 'The :attribute must be at least :min kilobytes.', - 'string' => 'The :attribute must be at least :min characters.', - 'array' => 'The :attribute must have at least :min items.', + 'numeric' => 'Rhaid i’r :attribute fod yn o leiaf :min.', + 'file' => 'Rhaid i’r :attribute fod yn o leiaf :min cilobeit.', + 'string' => 'Rhaid i’r :attribute fod yn o leiaf :min nod.', + 'array' => 'Rhaid i’r :attribute fod â llai na :min o eitemau.', ], - 'not_in' => 'The selected :attribute is invalid.', - 'not_regex' => 'The :attribute format is invalid.', - 'numeric' => 'The :attribute must be a number.', - 'regex' => 'The :attribute format is invalid.', - 'required' => 'The :attribute field is required.', - 'required_if' => 'The :attribute field is required when :other is :value.', - 'required_with' => 'The :attribute field is required when :values is present.', - 'required_with_all' => 'The :attribute field is required when :values is present.', - 'required_without' => 'The :attribute field is required when :values is not present.', - 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'same' => 'The :attribute and :other must match.', - 'safe_url' => 'The provided link may not be safe.', + 'not_in' => 'Mae\'r dewis :attribute yn annilys.', + 'not_regex' => 'Mae’r fformat :attribute yn annilys.', + 'numeric' => 'Rhaid i’r :attribute fod yn rhif.', + 'regex' => 'Mae’r fformat :attribute yn annilys.', + 'required' => 'Mae :attribute yn faes gofynnol.', + 'required_if' => 'Mae :attribute yn faes gofynnol pan fo :other yn :value.', + 'required_with' => 'Mae :attribute yn faes gofynnol pan fo :values yn bresennol.', + 'required_with_all' => 'Mae :attribute yn faes gofynnol pan fo :values yn bresennol.', + 'required_without' => 'Mae :attitude yn faes gofynnol pan nad yw :values yn bresennol.', + 'required_without_all' => 'Mae angen y maes :attribute os dydi\'r un o :values yn bresennol.', + 'same' => 'Mae’n rhaid i’r :attribute a :other gyd-fynd.', + 'safe_url' => 'Efallai na fydd y ddolen a ddarperir yn ddiogel.', 'size' => [ - 'numeric' => 'The :attribute must be :size.', - 'file' => 'The :attribute must be :size kilobytes.', - 'string' => 'The :attribute must be :size characters.', - 'array' => 'The :attribute must contain :size items.', + 'numeric' => 'Rhaid i’r :attribute fod yn :size.', + 'file' => 'Rhaid i’r :attribute fod yn :size cilobeit.', + 'string' => 'Rhaid i’r :attribute fod yn :size nod.', + 'array' => 'Rhaid i’r :attribute gynnwys eitemau :size.', ], - 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid zone.', - 'totp' => 'The provided code is not valid or has expired.', - 'unique' => 'The :attribute has already been taken.', - 'url' => 'The :attribute format is invalid.', - 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'string' => 'Rhaid i’r :attribute fod yn llinyn.', + 'timezone' => 'Rhaid i’r :attribute fod yn barth dilys.', + 'totp' => 'Nid yw\'r cod a ddarperir yn ddilys neu mae wedi dod i ben.', + 'unique' => 'Mae’r :attribute eisoes wedi ei gymryd.', + 'url' => 'Mae’r fformat :attribute yn annilys.', + 'uploaded' => 'Nid oedd modd uwchlwytho’r ffeil. Efallai na fydd y gweinydd yn derbyn ffeiliau o\'r maint hwn.', // Custom validation lines 'custom' => [ 'password-confirm' => [ - 'required_with' => 'Password confirmation required', + 'required_with' => 'Rhaid cadarnhau cyfrinair', ], ], diff --git a/lang/de/entities.php b/lang/de/entities.php index fd926b3a975..dda99bacfec 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -73,7 +73,7 @@ 'search_permissions_set' => 'Berechtigungen gesetzt', 'search_created_by_me' => 'Von mir erstellt', 'search_updated_by_me' => 'Von mir aktualisiert', - 'search_owned_by_me' => 'Besitzt von mir', + 'search_owned_by_me' => 'In meinem Besitz', 'search_date_options' => 'Datums Optionen', 'search_updated_before' => 'Aktualisiert vor', 'search_updated_after' => 'Aktualisiert nach', diff --git a/lang/et/auth.php b/lang/et/auth.php index 6edcdfc7af0..014a9f858eb 100644 --- a/lang/et/auth.php +++ b/lang/et/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobiilirakendus', 'mfa_option_totp_desc' => 'Mitmeastmelise autentimise kasutamiseks on sul vaja TOTP-toega mobiilirakendust, nagu Google Authenticator, Authy või Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Varukoodid', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Genereerib komplekti ühekordseid tagavarakoode, mille abil saad sisselogimisel oma isikut tuvastada. Hoia neid kindlas ja turvalises kohas.', 'mfa_gen_confirm_and_enable' => 'Kinnita ja lülita sisse', 'mfa_gen_backup_codes_title' => 'Varukoodide seadistamine', 'mfa_gen_backup_codes_desc' => 'Hoiusta allolevad koodid turvalises kohas. Saad neid kasutada sisselogimisel sekundaarse autentimismeetodina.', diff --git a/lang/fa/common.php b/lang/fa/common.php index 97f808f2ab3..18b80de92a5 100644 --- a/lang/fa/common.php +++ b/lang/fa/common.php @@ -20,7 +20,7 @@ 'description' => 'توضیحات', 'role' => 'نقش', 'cover_image' => 'تصویر روی جلد', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'تصویر باید حدودا 250*440 پیکسل باشد اما از آنجا که متناسب با رابط کاربری کوچک و بزرگ شده و بریده میشود، پس ابعاد نمایش داده شده متفاوت خواهند بود.', // Actions 'actions' => 'عملیات', diff --git a/lang/fa/errors.php b/lang/fa/errors.php index 8c16396b353..e014ca83e3c 100644 --- a/lang/fa/errors.php +++ b/lang/fa/errors.php @@ -10,7 +10,7 @@ // Auth 'error_user_exists_different_creds' => 'کاربری با ایمیل :email از قبل وجود دارد اما دارای اطلاعات متفاوتی می باشد.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'auth_pre_register_theme_prevention' => 'بر اساس اطلاعات ارائه شده، حساب کاربری نمی‌نواند ایجاد بشود', 'email_already_confirmed' => 'ایمیل قبلا تایید شده است، وارد سیستم شوید.', 'email_confirmation_invalid' => 'این کلمه عبور معتبر نمی باشد و یا قبلا استفاده شده است، لطفا دوباره ثبت نام نمایید.', 'email_confirmation_expired' => 'کلمه عبور منقضی شده است، یک ایمیل تایید جدید ارسال شد.', @@ -37,22 +37,22 @@ 'social_driver_not_found' => 'درایور شبکه اجتماعی یافت نشد', 'social_driver_not_configured' => 'تنظیمات شبکه اجتماعی :socialAccount به درستی پیکربندی نشده است.', 'invite_token_expired' => 'این پیوند دعوت منقضی شده است. در عوض می توانید سعی کنید رمز عبور حساب خود را بازنشانی کنید.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'کاربری برای این کار پیدا نمی‌شود.', // System 'path_not_writable' => 'مسیر فایل :filePath را نمی توان در آن آپلود کرد. مطمئن شوید که روی سرور قابل نوشتن است.', 'cannot_get_image_from_url' => 'نمی توان تصویر را از :url دریافت کرد', 'cannot_create_thumbs' => 'سرور نمی تواند تصاویر کوچک ایجاد کند. لطفاً بررسی کنید که پسوند GD PHP را نصب کرده اید.', 'server_upload_limit' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.', - 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'server_post_limit' => 'سرور نمی‌تواند داده مقادیر ارائه شده داده را دریافت کند. با مقدار کمتر و فایل کوچکتر دوباره امتحان کنید.', 'uploaded' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.', // Drawing & Images 'image_upload_error' => 'هنگام آپلود تصویر خطایی روی داد', 'image_upload_type_error' => 'نوع تصویر در حال آپلود نامعتبر است', 'image_upload_replace_type' => 'جایگزینی فایل تصویری باید از یک نوع باشد', - 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', - 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', + 'image_upload_memory_limit' => 'به دلیل محدودیت منابع سامانه، بارگذاری فایل و/یا ایجاد تصاویر کاور (thumbnails) ناموفق بود.', + 'image_thumbnail_memory_limit' => 'به دلیل محدودیت منابع سیستم، تصاویر با اندازه گوناگون ایجاد نشدند.', 'image_gallery_thumbnail_memory_limit' => 'به دلیل محدودیت منابع سیستم، تصاویر کوچک گالری ایجاد نشد.', 'drawing_data_not_found' => 'داده های طرح قابل بارگذاری نیستند. ممکن است فایل طرح دیگر وجود نداشته باشد یا شما به آن دسترسی نداشته باشید.', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index d2df873a984..c6c4ddddd5e 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -109,7 +109,7 @@ 'recycle_bin_contents_empty' => 'سطل بازیافت در حال حاضر خالی است', 'recycle_bin_empty' => 'سطل آشغال خالی', 'recycle_bin_empty_confirm' => 'این کار همه اقلام موجود در سطل بازیافت از جمله محتوای موجود در هر مورد را برای همیشه از بین می برد. آیا مطمئن هستید که می خواهید سطل بازیافت را خالی کنید؟', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'این کار تمامی عناصر زیر مجموعه آیتم که در پایین فهرست شده را به همراه خودش برای همیشه از سامانه پاک میکند و شما امکان بازیابی آن را نخواهید داشت. آیا مطمئن هستید که میخواهید برای همیشه این آیتم را پاک کنید؟', 'recycle_bin_destroy_list' => 'مواردی که باید نابود شوند', 'recycle_bin_restore_list' => 'مواردی که باید بازیابی شوند', 'recycle_bin_restore_confirm' => 'این اقدام، مورد حذف شده، از جمله هر عنصر فرزند، را به مکان اصلی خود باز می گرداند. اگر مکان اصلی از آن زمان حذف شده باشد، و اکنون در سطل بازیافت است، مورد اصلی نیز باید بازیابی شود.', @@ -193,7 +193,7 @@ 'users_send_invite_text' => 'می توانید انتخاب کنید که برای این کاربر یک ایمیل دعوت نامه ارسال شود که به آنها امکان می دهد رمز عبور خود را تعیین کنند در غیر این صورت می توانید رمز عبور خود را تعیین کنید.', 'users_send_invite_option' => 'ارسال ایمیل دعوت کاربر', 'users_external_auth_id' => 'شناسه احراز هویت خارجی', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', + 'users_external_auth_id_desc' => 'هنگامی که یک سامانه احراز هویت خارجی مورد استفاده است (مانند SAML2، OIDC یا LDAP)، شناسه ای که کاربر BookStack را به حساب سامانه احراز هویت مرتبط میکند بدین‌گونه می‌باشد. اگر از احراز هویت پیش فرض ایمیلی استفاده می‌کنید، میتوانید این را در نظر نگیرید.', 'users_password_warning' => 'فقط در صورتی که مایل به تغییر رمز عبور این کاربر هستید، موارد زیر را پر کنید.', 'users_system_public' => 'این کاربر نماینده هر کاربر مهمانی است که از نمونه شما بازدید می کند. نمی توان از آن برای ورود استفاده کرد اما به طور خودکار اختصاص داده می شود.', 'users_delete' => 'حذف کاربر', @@ -214,12 +214,12 @@ 'users_social_accounts_info' => 'در اینجا می‌توانید حساب‌های دیگر خود را برای ورود سریع‌تر و آسان‌تر متصل کنید. قطع ارتباط حساب در اینجا، دسترسی مجاز قبلی را لغو نمی کند. دسترسی را از تنظیمات نمایه خود در حساب اجتماعی متصل لغو کنید.', 'users_social_connect' => 'اتصال حساب کاربری', 'users_social_disconnect' => 'قطع حساب', - 'users_social_status_connected' => 'Connected', + 'users_social_status_connected' => 'ارتباط برقرار شد', 'users_social_status_disconnected' => 'قطع اتصال', 'users_social_connected' => 'حساب :socialAccount با موفقیت به نمایه شما پیوست شد.', 'users_social_disconnected' => 'حساب :socialAccount با موفقیت از نمایه شما قطع شد.', 'users_api_tokens' => 'توکن‌های API', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_desc' => 'توکن های دسترسی مورد استفاده در BookStack REST API را بسازید و مدیریت کنید. اجازه‌های مربوط به API توسط کاربری که صاحب آنها است مدیریت می‌شوند.', 'users_api_tokens_none' => 'هیچ نشانه API برای این کاربر ایجاد نشده است', 'users_api_tokens_create' => 'ایجاد توکن', 'users_api_tokens_expires' => 'منقضی شده ها', @@ -277,13 +277,13 @@ 'webhooks_last_error_message' => 'آخرین پیغام خطا:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'مجوز‌ها', + 'licenses_desc' => 'این صفحه اطلاعات مجوز‌های بوک استک به همراه پروژه‌ها و کتابخانه‌های مورد استفاده با آن را به نمایش می‌گذارد. بسیاری از پروژه‌های فهرست شده ممکن است فقط در بحث توسعه به کار گرفته شده باشند.', + 'licenses_bookstack' => 'مجوز‌های بوک استک', + 'licenses_php' => 'مجوز‌های کتابخانه PHP', + 'licenses_js' => 'مجوز‌های کتابخانه جاوا اسکریپت', + 'licenses_other' => 'سایر مجوز‌ها', + 'license_details' => 'جزئیات مجوز', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/fr/entities.php b/lang/fr/entities.php index d6b054fe83e..c04ef24e3bb 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -63,10 +63,10 @@ 'search_for_term' => 'recherche pour :term', 'search_more' => 'Plus de résultats', 'search_advanced' => 'Recherche avancée', - 'search_terms' => 'Mot-clé', + 'search_terms' => 'Termes de recherche', 'search_content_type' => 'Type de contenu', 'search_exact_matches' => 'Correspondances exactes', - 'search_tags' => 'Recherche par mots-clés', + 'search_tags' => 'Recherche par tags', 'search_options' => 'Options', 'search_viewed_by_me' => 'Vu par moi', 'search_not_viewed_by_me' => 'Non vu par moi', @@ -307,7 +307,7 @@ 'tag' => 'Mot-clé', 'tags' => 'Mots-clés', 'tags_index_desc' => 'Les tags peuvent être appliqués au contenu du système pour appliquer une forme flexible de catégorisation. Les tags peuvent avoir à la fois une clé et une valeur, la valeur étant facultative. Une fois appliqué, le contenu peut ensuite être interrogé à l’aide du nom et de la valeur du tag.', - 'tag_name' => 'Nom du mot-clé', + 'tag_name' => 'Nom de l’étiquette', 'tag_value' => 'Valeur du mot-clé (optionnel)', 'tags_explain' => "Ajouter des mots-clés pour catégoriser votre contenu.", 'tags_add' => 'Ajouter un autre mot-clé', diff --git a/lang/he/activities.php b/lang/he/activities.php index 4eadd419671..587e4bdddce 100644 --- a/lang/he/activities.php +++ b/lang/he/activities.php @@ -15,7 +15,7 @@ 'page_restore' => 'הדף שוחזר', 'page_restore_notification' => 'הדף שוחזר בהצלחה', 'page_move' => 'דף הועבר', - 'page_move_notification' => 'Page successfully moved', + 'page_move_notification' => 'הדף הוזז בהצלחה', // Chapters 'chapter_create' => 'פרק נוצר', @@ -25,7 +25,7 @@ 'chapter_delete' => 'פרק נמחק', 'chapter_delete_notification' => 'הפרק נמחק בהצלחה', 'chapter_move' => 'פרק הועבר', - 'chapter_move_notification' => 'Chapter successfully moved', + 'chapter_move_notification' => 'פרק הוזז בהצלחה', // Books 'book_create' => 'ספר נוצר', @@ -50,13 +50,13 @@ 'bookshelf_delete_notification' => 'מדף נמחק בהצלחה', // Revisions - 'revision_restore' => 'restored revision', - 'revision_delete' => 'deleted revision', - 'revision_delete_notification' => 'Revision successfully deleted', + 'revision_restore' => 'גרסא שוחזרה', + 'revision_delete' => 'גרסא הוסרה', + 'revision_delete_notification' => 'גרסא הוסרה בהצלחה', // Favourites - 'favourite_add_notification' => '":name" has been added to your favourites', - 'favourite_remove_notification' => '":name" has been removed from your favourites', + 'favourite_add_notification' => '":name" הוסף למועדפים', + 'favourite_remove_notification' => '":name" הוסר מהמועדפים', // Watching 'watch_update_level_notification' => 'Watch preferences successfully updated', diff --git a/lang/lv/auth.php b/lang/lv/auth.php index 1b84480f250..71b92ac0109 100644 --- a/lang/lv/auth.php +++ b/lang/lv/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobilā aplikācija', 'mfa_option_totp_desc' => 'Lai lietotu vairākfaktoru autentifikāciju, jums būs nepieciešama mobilā aplikācija, kas atbalsta TOTP, piemēram, Google Authenticator, Authy vai Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Rezerves kodi', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Izveido vienreizējas lietošanas rezerves kodus, ko var izmantot pierakstoties, lai apstiprinātu savu identitāti. Pārliecinieties, ka tie ir noglabāti drošā vietā.', 'mfa_gen_confirm_and_enable' => 'Apstiprināt un ieslēgt', 'mfa_gen_backup_codes_title' => 'Rezerves kodu iestatījumi', 'mfa_gen_backup_codes_desc' => 'Noglabājiet zemāk esošo kodu sarakstu drošā vietā. Kad piekļūsiet sistēmai, jūs varēsiet izmantot vienu no kodiem kā papildus autentifikācijas mehānismu.', diff --git a/lang/lv/common.php b/lang/lv/common.php index dcee2878223..657d9be64fd 100644 --- a/lang/lv/common.php +++ b/lang/lv/common.php @@ -20,7 +20,7 @@ 'description' => 'Apraksts', 'role' => 'Loma', 'cover_image' => 'Vāka attēls', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'Šim attēlam jābūt apmēram 440x250px izmērā, taču tas tiks pielāgots lietotāja saskarnei dažādos scenārijos pēc nepieciešamības, un attēla izmēri tad var atšķirties.', // Actions 'actions' => 'Darbības', diff --git a/lang/lv/editor.php b/lang/lv/editor.php index 4a5747e9e28..5443e505d66 100644 --- a/lang/lv/editor.php +++ b/lang/lv/editor.php @@ -81,9 +81,9 @@ 'table_properties' => 'Tabulas īpašības', 'table_properties_title' => 'Tabulas īpašības', 'delete_table' => 'Dzēst tabulu', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => 'Notīrīt tabulas formatējumu', + 'resize_to_contents' => 'Pielāgot izmēru saturam', + 'row_header' => 'Rindas galvene', 'insert_row_before' => 'Ievietot rindu augstāk', 'insert_row_after' => 'Ievietot rindu zemāk', 'delete_row' => 'Dzēst rindu', diff --git a/lang/lv/entities.php b/lang/lv/entities.php index 6db02da6a8c..147f5d28b28 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -39,9 +39,9 @@ 'export_pdf' => 'PDF fails', 'export_text' => 'Vienkāršs teksta fails', 'export_md' => 'Markdown fails', - 'default_template' => 'Default Page Template', + 'default_template' => 'Noklusētā lapas sagatave', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', - 'default_template_select' => 'Select a template page', + 'default_template_select' => 'Izvēlēt sagataves lapu', // Permissions and restrictions 'permissions' => 'Atļaujas', @@ -299,7 +299,7 @@ 'pages_is_template' => 'Lapas šablons', // Editor Sidebar - 'toggle_sidebar' => 'Toggle Sidebar', + 'toggle_sidebar' => 'Pārslēgt sānjoslu', 'page_tags' => 'Lapas birkas', 'chapter_tags' => 'Nodaļas birkas', 'book_tags' => 'Grāmatas birkas', @@ -330,7 +330,7 @@ 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', 'attachments_set_link' => 'Uzstādīt saiti', 'attachments_delete' => 'Vai tiešām vēlaties dzēst šo pielikumu?', - 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_dropzone' => 'Ievelciet failus šeit, lai augšupielādētu', 'attachments_no_files' => 'Neviens fails nav augšupielādēts', 'attachments_explain_link' => 'Ja nevēlaties augšupielādēt failu, varat pievienot saiti. Tā var būt saite uz citu lapu vai saite uz failu mākonī.', 'attachments_link_name' => 'Saites nosaukums', @@ -416,16 +416,16 @@ 'watch_title_default' => 'Noklusētie uzstādījumi', 'watch_desc_default' => 'Revert watching to just your default notification preferences.', 'watch_title_ignore' => 'Ignorēt', - 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_desc_ignore' => 'Ignorēt visus paziņojumus, tai skaitā arī tādus, kas uzstādīti lietotāja uzstādījumos.', 'watch_title_new' => 'Jaunas lapas', 'watch_desc_new' => 'Notify when any new page is created within this item.', 'watch_title_updates' => 'Visi lapu atjauninājumi', - 'watch_desc_updates' => 'Notify upon all new pages and page changes.', - 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_desc_updates' => 'Paziņot par visām jaunām lapām un lapu izmaiņām.', + 'watch_desc_updates_page' => 'Paziņot par visām lapu izmaiņām.', 'watch_title_comments' => 'Visi lapu atjauninājumi un komentāri', - 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', - 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', - 'watch_change_default' => 'Change default notification preferences', + 'watch_desc_comments' => 'Paziņot par visām jaunām lapām, lapu izmaiņām un jauniem komentāriem.', + 'watch_desc_comments_page' => 'Paziņot par lapu izmaiņām un jauniem komentāriem.', + 'watch_change_default' => 'Izmainīt noklusētos paziņojumu uzstādījumus', 'watch_detail_ignore' => 'Ignorēt paziņojumus', 'watch_detail_new' => 'Watching for new pages', 'watch_detail_updates' => 'Watching new pages and updates', diff --git a/lang/lv/errors.php b/lang/lv/errors.php index 22ccbe3833a..192676f35a7 100644 --- a/lang/lv/errors.php +++ b/lang/lv/errors.php @@ -10,7 +10,7 @@ // Auth 'error_user_exists_different_creds' => 'Lietotājs ar epastu :email bet ar citiem piekļuves datiem jau eksistē.', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'auth_pre_register_theme_prevention' => 'Lietotāja kontu nevar reģistrēt ar norādītajām detaļām', 'email_already_confirmed' => 'Epasts jau ir apstiprināts, mēģini ielogoties.', 'email_confirmation_invalid' => 'Šis apstiprinājuma žetons nav derīgs vai jau ir izmantots. Lūdzu, mēģiniet reģistrēties vēlreiz.', 'email_confirmation_expired' => 'Apstiprinājuma žetona derīguma termiņš ir beidzies. Ir nosūtīts jauns apstiprinājuma e-pasts.', @@ -37,7 +37,7 @@ 'social_driver_not_found' => 'Sociālā tīkla savienojums nav atrasts', 'social_driver_not_configured' => 'Jūsu :socialAccount sociālie iestatījumi nav uzstādīti pareizi.', 'invite_token_expired' => 'Šī uzaicinājuma saite ir novecojusi. Tā vietā jūs varat mēģināt atiestatīt sava konta paroli.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'Šai darbībai netika atrasts lietotājs.', // System 'path_not_writable' => 'Faila ceļā :filePath nav iespējams ielādēt failus. Lūdzu pārliecinieties, ka serverim tur ir rakstīšanas tiesības.', diff --git a/lang/lv/notifications.php b/lang/lv/notifications.php index d0fee395021..9fd60222a72 100644 --- a/lang/lv/notifications.php +++ b/lang/lv/notifications.php @@ -10,10 +10,10 @@ 'new_page_intro' => 'Jauna lapa izveidota :appName:', 'updated_page_subject' => 'Atjaunināta lapa: :pageName', 'updated_page_intro' => 'Lapa atjaunināta :appName:', - 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'updated_page_debounce' => 'Lai novērstu pārliecīgu paziņojumu sūtīšanu, uz laiku jums tiks pārtraukti paziņojumi par turpmākiem šī lietotāja labojumiem šai lapai.', 'detail_page_name' => 'Lapas nosaukums:', - 'detail_page_path' => 'Page Path:', + 'detail_page_path' => 'Ceļš uz lapu:', 'detail_commenter' => 'Komentētājs:', 'detail_comment' => 'Komentārs:', 'detail_created_by' => 'Izveidoja:', @@ -22,6 +22,6 @@ 'action_view_comment' => 'Skatīt komentāru', 'action_view_page' => 'Skatīt lapu', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', + 'footer_reason' => 'Šis paziņojums nosūtīts tāpēc, ka :link paredz šādu aktivitāti šai vienībai.', 'footer_reason_link' => 'paziņojumu vēlamie iestatījumi', ]; diff --git a/lang/lv/settings.php b/lang/lv/settings.php index cb38482da98..895d9a89809 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -109,7 +109,7 @@ 'recycle_bin_contents_empty' => 'Miskaste ir tukša', 'recycle_bin_empty' => 'Iztīrīt miskasti', 'recycle_bin_empty_confirm' => 'Šī darbība pilnībā dzēsīs visas vienības miskastē, ieskaitot saturu, kas ievietots katrā no šīm vienībām. Vai tiešām vēlaties dzēst visu miskastes saturu?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Šī darbība pilnībā no sistēmas izdzēsis šo vienību kopā ar tai pakārtotajiem elementiem, un jūs nevarēsiet šo saturu atjaunot. Vai tiešām vēlaties pilnībā izdzēst šo vienību?', 'recycle_bin_destroy_list' => 'Dzēšamās vienības', 'recycle_bin_restore_list' => 'Atjaunojamās vienības', 'recycle_bin_restore_confirm' => 'Šī darbība atjaunos dzēsto vienību, tai skaitā visus tai pakārtotos elementus, uz tās sākotnējo atrašanās vietu. Ja sākotnējā atrašanās vieta ir izdzēsta un atrodas miskastē, būs nepieciešams atjaunot arī to.', @@ -277,13 +277,13 @@ 'webhooks_last_error_message' => 'Pēdējais kļūdas paziņojums:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'Licences', + 'licenses_desc' => 'Šī lapa attēlo BookStack licences informāciju, kā arī licences citiem projektiem un bibliotēkām, kas tiek izmantoti BookStack. Daļa no minētajiem projektiem var būt izmantoti tikai izstrādē.', + 'licenses_bookstack' => 'BookStack licence', + 'licenses_php' => 'PHP bibliotēku licences', + 'licenses_js' => 'JavaScript bibliotēku licences', + 'licenses_other' => 'Citas licences', + 'license_details' => 'Licences informācija', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/nb/auth.php b/lang/nb/auth.php index cdb2f19be30..125e6daf5d4 100644 --- a/lang/nb/auth.php +++ b/lang/nb/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobilapplikasjon', 'mfa_option_totp_desc' => 'For å bruke flerfaktorautentisering trenger du en mobilapplikasjon som støtter TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Sikkerhetskoder', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Genererer et sett med engangskoder som du kan bruke ved innlogging for å bekrefte identiteten din. Sørg for å lagre disse på et sikkert sted.', 'mfa_gen_confirm_and_enable' => 'Bekreft og aktiver', 'mfa_gen_backup_codes_title' => 'Konfigurasjon av sikkerhetskoder', 'mfa_gen_backup_codes_desc' => 'Lagre nedeforstående liste med koder på et trygt sted. Når du skal ha tilgang til systemet kan du bruke en av disse som en faktor under innlogging.', diff --git a/lang/nb/errors.php b/lang/nb/errors.php index 2f3e802baae..7b7f6e8a930 100644 --- a/lang/nb/errors.php +++ b/lang/nb/errors.php @@ -37,7 +37,7 @@ 'social_driver_not_found' => 'Autentiseringstjeneste fra sosiale medier er ikke installert', 'social_driver_not_configured' => 'Dine :socialAccount innstilliner er ikke angitt.', 'invite_token_expired' => 'Invitasjonslenken har utgått, du kan forsøke å be om nytt passord istede.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'En bruker for denne handlingen ble ikke funnet.', // System 'path_not_writable' => 'Filstien :filePath aksepterer ikke filer, du må sjekke filstitilganger i systemet.', @@ -76,7 +76,7 @@ 'guests_cannot_save_drafts' => 'Gjester kan ikke lagre utkast', // Users - 'users_cannot_delete_only_admin' => 'Du kan ikke kaste ut den eneste administratoren', + 'users_cannot_delete_only_admin' => 'Du kan ikke slette den eneste administratoren', 'users_cannot_delete_guest' => 'Du kan ikke slette gjestebrukeren (Du kan deaktivere offentlig visning istede)', // Roles diff --git a/lang/nl/activities.php b/lang/nl/activities.php index 08c168e9fae..52a8e3e3762 100644 --- a/lang/nl/activities.php +++ b/lang/nl/activities.php @@ -94,11 +94,11 @@ // API Tokens 'api_token_create' => 'API-token aangemaakt', - 'api_token_create_notification' => 'API-token met succes aangemaakt', + 'api_token_create_notification' => 'API-token succesvol aangemaakt', 'api_token_update' => 'wijzigde API-token', - 'api_token_update_notification' => 'API-token met succes bijgewerkt', + 'api_token_update_notification' => 'API-token succesvol bijgewerkt', 'api_token_delete' => 'verwijderde API-token', - 'api_token_delete_notification' => 'API-token met succes verwijderd', + 'api_token_delete_notification' => 'API-token succesvol verwijderd', // Roles 'role_create' => 'maakte rol aan', @@ -110,8 +110,8 @@ // Recycle Bin 'recycle_bin_empty' => 'leegde prullenbak', - 'recycle_bin_restore' => 'is van prullenbak hersteld', - 'recycle_bin_destroy' => 'is van prullenbak verwijderd', + 'recycle_bin_restore' => 'herstelde van prullenbak', + 'recycle_bin_destroy' => 'verwijderde van prullenbak', // Comments 'commented_on' => 'reageerde op', diff --git a/lang/nl/auth.php b/lang/nl/auth.php index 8caa1dd1915..12c6b6c7bff 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -47,11 +47,11 @@ 'reset_password' => 'Wachtwoord Herstellen', 'reset_password_send_instructions' => 'Geef je e-mailadres op en er wordt een link gestuurd om je wachtwoord te herstellen.', 'reset_password_send_button' => 'Stuur Herstel Link', - 'reset_password_sent' => 'Een link om het wachtwoord te resetten zal verstuurd worden naar :email als dat e-mailadres in het systeem gevonden is.', + 'reset_password_sent' => 'Een wachtwoordherstel-link zal worden verstuurd naar :email als dat e-mailadres in het systeem gevonden is.', 'reset_password_success' => 'Je wachtwoord is succesvol hersteld.', 'email_reset_subject' => 'Herstel je wachtwoord van :appName', - 'email_reset_text' => 'Je ontvangt deze e-mail omdat we een wachtwoordresetverzoek voor uw account hebben ontvangen.', - 'email_reset_not_requested' => 'Als je geen wachtwoord herstel hebt aangevraagd, hoef je niets te doen.', + 'email_reset_text' => 'Je ontvangt deze e-mail omdat we een wachtwoordherstelverzoek voor uw account hebben ontvangen.', + 'email_reset_not_requested' => 'Als je geen wachtwoordherstel hebt aangevraagd, hoef je niets te doen.', // Email Confirmation 'email_confirm_subject' => 'Bevestig je e-mailadres op :appName', diff --git a/lang/nl/errors.php b/lang/nl/errors.php index 58647b66d77..896b7a71d09 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -36,7 +36,7 @@ 'social_account_register_instructions' => 'Als je nog geen account hebt, kun je je registreren met de :socialAccount optie.', 'social_driver_not_found' => 'Social driver niet gevonden', 'social_driver_not_configured' => 'Je :socialAccount instellingen zijn niet correct geconfigureerd.', - 'invite_token_expired' => 'Deze uitnodigingslink is verlopen. Je kunt in plaats daarvan proberen je wachtwoord opnieuw in te stellen.', + 'invite_token_expired' => 'Deze uitnodigingslink is verlopen. Je kunt in plaats daarvan proberen je wachtwoord te herstellen.', 'login_user_not_found' => 'Er is geen gebruiker gevonden voor deze actie.', // System diff --git a/lang/nl/passwords.php b/lang/nl/passwords.php index 1781256f254..5223bdf39ee 100644 --- a/lang/nl/passwords.php +++ b/lang/nl/passwords.php @@ -6,9 +6,9 @@ */ return [ - 'password' => 'Wachtwoorden moeten uit ten minste acht tekens bestaan en overeenkomen met de bevestiging.', - 'user' => "We kunnen niemand vinden met dat e-mailadres.", - 'token' => 'De wachtwoord reset token is ongeldig voor dit e-mailadres.', + 'password' => 'Wachtwoorden moeten uit minstens acht tekens bestaan en overeenkomen met de bevestiging.', + 'user' => "We kunnen geen gebruiker vinden met dat e-mailadres.", + 'token' => 'De wachtwoordhersteltoken is ongeldig voor dit e-mailadres.', 'sent' => 'We hebben je een link gestuurd om je wachtwoord te herstellen!', 'reset' => 'Je wachtwoord is hersteld!', diff --git a/lang/ru/auth.php b/lang/ru/auth.php index 62e1911e6c0..d39da119dea 100644 --- a/lang/ru/auth.php +++ b/lang/ru/auth.php @@ -6,7 +6,7 @@ */ return [ - 'failed' => 'Учетная запись не найдена.', + 'failed' => 'Введенные вами данные не найдены в нашей базе.', 'throttle' => 'Слишком много попыток входа. Пожалуйста, повторите попытку через :seconds секунд.', // Login & Register @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Мобильное приложение', 'mfa_option_totp_desc' => 'Для использования многофакторной аутентификации вам понадобится мобильное приложение, поддерживающее TOTP, например Google Authenticator, Authy или Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Резервные коды', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Генерирует набор одноразовых резервных кодов, которые вы вводите при входе, чтобы проверить вашу личность. Не забудьте сохранить их в безопасном месте.', 'mfa_gen_confirm_and_enable' => 'Подтвердить и включить', 'mfa_gen_backup_codes_title' => 'Настройка резервных кодов', 'mfa_gen_backup_codes_desc' => 'Сохраните приведенный ниже список кодов в безопасном месте. При доступе к системе вы сможете использовать один из кодов в качестве второго механизма аутентификации.', diff --git a/lang/ru/errors.php b/lang/ru/errors.php index 596d8b78a59..a234bd9c9cc 100644 --- a/lang/ru/errors.php +++ b/lang/ru/errors.php @@ -37,7 +37,7 @@ 'social_driver_not_found' => 'Драйвер для Соцсети не найден', 'social_driver_not_configured' => 'Настройки вашего :socialAccount заданы неправильно.', 'invite_token_expired' => 'Срок действия приглашения истек. Вместо этого вы можете попытаться сбросить пароль своей учетной записи.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'Пользователь для этого действия не найден.', // System 'path_not_writable' => 'Невозможно загрузить файл по пути :filePath. Убедитесь что сервер доступен для записи.', diff --git a/lang/sr/auth.php b/lang/sr/auth.php index c98cb0fe4a4..0100e666fa3 100644 --- a/lang/sr/auth.php +++ b/lang/sr/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Aplikacije za mobilne uređaje', 'mfa_option_totp_desc' => 'Да бисте користили вишефакторску аутентификацију, биће вам потребна мобилна апликација која подржава ТОТП, као што јеGoogle Authenticator, Authy или Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Резервни кодови', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Генерише скуп резервних кодова за једнократну употребу које ћете унети приликом пријављивања да бисте потврдили свој идентитет. Обавезно их чувајте на безбедном и безбедном месту.', 'mfa_gen_confirm_and_enable' => 'Потврдите и омогућите', 'mfa_gen_backup_codes_title' => 'Подешавање резервних кодова', 'mfa_gen_backup_codes_desc' => 'Чувајте доњу листу кодова на безбедном месту. Када приступате систему, моћи ћете да користите један од кодова као други механизам за аутентификацију.', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index 9e620b24ed1..0666a58ead9 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -6,59 +6,59 @@ return [ // Shared - 'recently_created' => 'Recently Created', - 'recently_created_pages' => 'Recently Created Pages', - 'recently_updated_pages' => 'Recently Updated Pages', - 'recently_created_chapters' => 'Recently Created Chapters', - 'recently_created_books' => 'Recently Created Books', - 'recently_created_shelves' => 'Recently Created Shelves', - 'recently_update' => 'Recently Updated', - 'recently_viewed' => 'Recently Viewed', - 'recent_activity' => 'Recent Activity', - 'create_now' => 'Create one now', - 'revisions' => 'Revisions', - 'meta_revision' => 'Revision #:revisionCount', - 'meta_created' => 'Created :timeLength', - 'meta_created_name' => 'Created :timeLength by :user', - 'meta_updated' => 'Updated :timeLength', - 'meta_updated_name' => 'Updated :timeLength by :user', - 'meta_owned_name' => 'Owned by :user', - 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', - 'entity_select' => 'Entity Select', - 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', - 'images' => 'Images', - 'my_recent_drafts' => 'My Recent Drafts', - 'my_recently_viewed' => 'My Recently Viewed', - 'my_most_viewed_favourites' => 'My Most Viewed Favourites', - 'my_favourites' => 'My Favourites', - 'no_pages_viewed' => 'You have not viewed any pages', - 'no_pages_recently_created' => 'No pages have been recently created', - 'no_pages_recently_updated' => 'No pages have been recently updated', - 'export' => 'Export', - 'export_html' => 'Contained Web File', - 'export_pdf' => 'PDF File', - 'export_text' => 'Plain Text File', + 'recently_created' => 'Недавно додато', + 'recently_created_pages' => 'Недавно креиране странице', + 'recently_updated_pages' => 'Недавно ажуриране странице', + 'recently_created_chapters' => 'Недавно креирана поглавља', + 'recently_created_books' => 'Недавно креиране књиге', + 'recently_created_shelves' => 'Недавно креиране полице', + 'recently_update' => 'Недавно ажурирано', + 'recently_viewed' => 'Недавно прегледано', + 'recent_activity' => 'Скорашња активност', + 'create_now' => 'Направи један сада', + 'revisions' => 'Ревизије', + 'meta_revision' => 'Ревизија #:revisionCount', + 'meta_created' => 'Направљено :timeLength', + 'meta_created_name' => 'Направљено :timeLength од :user', + 'meta_updated' => 'Ажурирано :timeLength', + 'meta_updated_name' => 'Ажурирано :timeLength од :user', + 'meta_owned_name' => 'Власништво :user', + 'meta_reference_count' => 'Референтна од :count item|Референтна од :count items', + 'entity_select' => 'Избор ентитета', + 'entity_select_lack_permission' => 'Немате потребне дозволе да изаберете ову ставку', + 'images' => 'Слике', + 'my_recent_drafts' => 'Моји недавни нацрти', + 'my_recently_viewed' => 'Моје недавно прегледано', + 'my_most_viewed_favourites' => 'Моји најгледанији фаворити', + 'my_favourites' => 'Моји омиљени', + 'no_pages_viewed' => 'Нисте погледали ниједну страницу', + 'no_pages_recently_created' => 'Недавно није направљена ниједна страница', + 'no_pages_recently_updated' => 'Ниједна страница није недавно ажурирана', + 'export' => 'Извоз', + 'export_html' => 'Садржана веб датотека', + 'export_pdf' => 'PDF датотека', + 'export_text' => 'Датотеке чистог текста', 'export_md' => 'Markdown File', - 'default_template' => 'Default Page Template', - 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', - 'default_template_select' => 'Select a template page', + 'default_template' => 'Подразумевани шаблон странице', + 'default_template_explain' => 'Доделите шаблон странице који ће се користити као подразумевани садржај за све странице креиране у оквиру ове ставке. Имајте на уму да ће се ово користити само ако креатор странице има приступ за преглед изабране странице шаблона.', + 'default_template_select' => 'Изаберите страницу са шаблоном', // Permissions and restrictions - 'permissions' => 'Permissions', - 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', - 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', - 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', - 'permissions_save' => 'Save Permissions', - 'permissions_owner' => 'Owner', - 'permissions_role_everyone_else' => 'Everyone Else', - 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', - 'permissions_role_override' => 'Override permissions for role', - 'permissions_inherit_defaults' => 'Inherit defaults', + 'permissions' => 'Дозволе', + 'permissions_desc' => 'Подесите дозволе овде да бисте заменили подразумеване дозволе које дају корисничке улоге.', + 'permissions_book_cascade' => 'Дозволе постављене за књиге ће се аутоматски пребацивати на подређена поглавља и странице, осим ако немају дефинисане сопствене дозволе.', + 'permissions_chapter_cascade' => 'Дозволе постављене на поглављима ће се аутоматски каскадно пребацивати на подређене странице, осим ако немају дефинисане сопствене дозволе.', + 'permissions_save' => 'Сачувај дозволе', + 'permissions_owner' => 'Власник', + 'permissions_role_everyone_else' => 'Сви остали', + 'permissions_role_everyone_else_desc' => 'Подесите дозволе за све улоге које нису посебно замењене.', + 'permissions_role_override' => 'Замени дозволе за улогу', + 'permissions_inherit_defaults' => 'Наследи подразумеване вредности', // Search - 'search_results' => 'Search Results', - 'search_total_results_found' => ':count result found|:count total results found', - 'search_clear' => 'Clear Search', + 'search_results' => 'Резултати претраге', + 'search_total_results_found' => ':count пронађених резултата|:count укупно пронађених резултата', + 'search_clear' => 'Обриши претрагу', 'search_no_pages' => 'No pages matched this search', 'search_for_term' => 'Search for :term', 'search_more' => 'More Results', diff --git a/lang/sr/errors.php b/lang/sr/errors.php index ba0043600a7..44df0307a48 100644 --- a/lang/sr/errors.php +++ b/lang/sr/errors.php @@ -5,11 +5,11 @@ return [ // Permissions - 'permission' => 'You do not have permission to access the requested page.', - 'permissionJson' => 'You do not have permission to perform the requested action.', + 'permission' => 'Немате дозволу да приступите овој страни.', + 'permissionJson' => 'Немате овлашћење да извршите ову акцију.', // Auth - 'error_user_exists_different_creds' => 'A user with the email :email already exists but with different credentials.', + 'error_user_exists_different_creds' => 'Корисник са е-мејл адресом :email већ постоји са другим приступним подацима.', 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', 'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', 'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.', diff --git a/lang/sv/components.php b/lang/sv/components.php index dc587a44df6..315e1683c50 100644 --- a/lang/sv/components.php +++ b/lang/sv/components.php @@ -34,8 +34,8 @@ 'image_delete_success' => 'Bilden har tagits bort', 'image_replace' => 'Ersätt bild', 'image_replace_success' => 'Bildfilen har uppdaterats', - 'image_rebuild_thumbs' => 'Regenerate Size Variations', - 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + 'image_rebuild_thumbs' => 'Återskapa variationer av bildstorlekar', + 'image_rebuild_thumbs_success' => 'Variationer av bildstorlekar har återskapats!', // Code Editor 'code_editor' => 'Redigera kod', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index 1be2a56c6aa..6023c6cd3b4 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -23,7 +23,7 @@ 'meta_updated' => 'Uppdaterad :timeLength', 'meta_updated_name' => 'Uppdaterad :timeLength av :user', 'meta_owned_name' => 'Ägs av :user', - 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'meta_reference_count' => 'Refererad till av :count item|Referenced by :count items', 'entity_select' => 'Välj enhet', 'entity_select_lack_permission' => 'Du har inte den behörighet som krävs för att välja det här objektet', 'images' => 'Bilder', @@ -53,7 +53,7 @@ 'permissions_role_everyone_else' => 'Alla andra', 'permissions_role_everyone_else_desc' => 'Ställ in rättigheter för alla roller som inte uttryckligen har åsidosatts.', 'permissions_role_override' => 'Åsidosätt rättigheter för roll', - 'permissions_inherit_defaults' => 'Inherit defaults', + 'permissions_inherit_defaults' => 'Ärv standardrättigheter', // Search 'search_results' => 'Sökresultat', @@ -155,16 +155,16 @@ 'books_sort_show_other' => 'Visa andra böcker', 'books_sort_save' => 'Spara ordning', 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', - 'books_sort_move_up' => 'Move Up', - 'books_sort_move_down' => 'Move Down', - 'books_sort_move_prev_book' => 'Move to Previous Book', - 'books_sort_move_next_book' => 'Move to Next Book', - 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', - 'books_sort_move_next_chapter' => 'Move Into Next Chapter', - 'books_sort_move_book_start' => 'Move to Start of Book', - 'books_sort_move_book_end' => 'Move to End of Book', - 'books_sort_move_before_chapter' => 'Move to Before Chapter', - 'books_sort_move_after_chapter' => 'Move to After Chapter', + 'books_sort_move_up' => 'Flytta upp', + 'books_sort_move_down' => 'Flytta ned', + 'books_sort_move_prev_book' => 'Gå till förgående bok', + 'books_sort_move_next_book' => 'Gå till nästa bok', + 'books_sort_move_prev_chapter' => 'Gå till förgående kapitel', + 'books_sort_move_next_chapter' => 'Gå till nästa kapitel', + 'books_sort_move_book_start' => 'Gå till början av boken', + 'books_sort_move_book_end' => 'Gå till slutet av boken', + 'books_sort_move_before_chapter' => 'Gå till innan kapitlet', + 'books_sort_move_after_chapter' => 'Gå till efter kapitlet', 'books_copy' => 'Kopiera bok', 'books_copy_success' => 'Boken har kopierats', @@ -373,7 +373,7 @@ 'comment_new' => 'Ny kommentar', 'comment_created' => 'kommenterade :createDiff', 'comment_updated' => 'Uppdaterade :updateDiff av :username', - 'comment_updated_indicator' => 'Updated', + 'comment_updated_indicator' => 'Uppdaterad', 'comment_deleted_success' => 'Kommentar borttagen', 'comment_created_success' => 'Kommentaren har sparats', 'comment_updated_success' => 'Kommentaren har uppdaterats', @@ -412,12 +412,12 @@ 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', // Watch Options - 'watch' => 'Watch', + 'watch' => 'Följ', 'watch_title_default' => 'Default Preferences', 'watch_desc_default' => 'Revert watching to just your default notification preferences.', 'watch_title_ignore' => 'Ignorera', 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', - 'watch_title_new' => 'New Pages', + 'watch_title_new' => 'Nya sidor', 'watch_desc_new' => 'Notify when any new page is created within this item.', 'watch_title_updates' => 'All Page Updates', 'watch_desc_updates' => 'Notify upon all new pages and page changes.', diff --git a/lang/sv/preferences.php b/lang/sv/preferences.php index e06c0fbaa79..945099151f2 100644 --- a/lang/sv/preferences.php +++ b/lang/sv/preferences.php @@ -14,7 +14,7 @@ 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', 'shortcuts_section_navigation' => 'Navigation', 'shortcuts_section_actions' => 'Common Actions', - 'shortcuts_save' => 'Save Shortcuts', + 'shortcuts_save' => 'Spara genvägar', 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', 'shortcuts_update_success' => 'Shortcut preferences have been updated!', 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', @@ -32,11 +32,11 @@ 'auth' => 'Access & Security', 'auth_change_password' => 'Change Password', 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', - 'auth_change_password_success' => 'Password has been updated!', + 'auth_change_password_success' => 'Lösenordet har uppdaterats!', - 'profile' => 'Profile Details', + 'profile' => 'Profildetaljer', 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', - 'profile_view_public' => 'View Public Profile', + 'profile_view_public' => 'Visa publik profil', 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', @@ -44,8 +44,8 @@ 'profile_admin_options' => 'Administrator Options', 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', - 'delete_account' => 'Delete Account', - 'delete_my_account' => 'Delete My Account', + 'delete_account' => 'Radera konto', + 'delete_my_account' => 'Radera mitt konto', 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', 'delete_my_account_warning' => 'Are you sure you want to delete your account?', ]; diff --git a/lang/sv/settings.php b/lang/sv/settings.php index 5b5738f79d0..ef1ee584eb8 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -33,8 +33,8 @@ 'app_custom_html_disabled_notice' => 'Anpassat innehåll i HTML-huvudet är inaktiverat på denna inställningssida för att säkerställa att eventuella ändringar som påverkar funktionaliteten kan återställas.', 'app_logo' => 'Applikationslogotyp', 'app_logo_desc' => 'Detta används bland annat i applikationshuvudet. Bilden bör vara 86 pixlar i höjd. Stora bilder skalas ned.', - 'app_icon' => 'Application Icon', - 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', + 'app_icon' => 'Applikationsikon', + 'app_icon_desc' => 'Den här ikonen syns i webbläsarens flikar, bokmärken och genvägar. Den skall vara 256x256 pixlar i PNG format.', 'app_homepage' => 'Startsida', 'app_homepage_desc' => 'Välj en vy att använda som startsida istället för standardvyn. Behörigheter för valda sidor kommer att ignoreras.', 'app_homepage_select' => 'Välj en sida', @@ -279,11 +279,11 @@ // Licensing 'licenses' => 'Licenser', 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', + 'licenses_bookstack' => 'BookStack licens', + 'licenses_php' => 'Licenser för PHP-bibliotek', + 'licenses_js' => 'Licenser för JavaScript-bibliotek', 'licenses_other' => 'Andra licenser', - 'license_details' => 'License Details', + 'license_details' => 'Licensinformation', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/zh_TW/auth.php b/lang/zh_TW/auth.php index 0e7b7f0f543..1168b48b270 100644 --- a/lang/zh_TW/auth.php +++ b/lang/zh_TW/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => '手機App', 'mfa_option_totp_desc' => '您必須在行動裝置上安裝了支援TOTP的身份驗證程式(例如Google Authenticator, Authy 或是 Microsoft Authenticator)才能使用雙重身份驗證。', 'mfa_option_backup_codes_title' => '備用驗證碼', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => '產生一次使用的備份代碼,你可以在登入時用來辨識身份。請確認代碼被存放在安全的地方。', 'mfa_gen_confirm_and_enable' => '確認並啟用', 'mfa_gen_backup_codes_title' => '備援代碼設定', 'mfa_gen_backup_codes_desc' => '將以下代碼列表儲存在安全的地方。存取系統時,您可以使用其中一個代碼作為第二個身份驗證機制。', diff --git a/lang/zh_TW/components.php b/lang/zh_TW/components.php index 84bfad4562d..d66dd383e8d 100644 --- a/lang/zh_TW/components.php +++ b/lang/zh_TW/components.php @@ -10,7 +10,7 @@ 'image_details' => '圖片詳細資訊', 'image_upload' => '上傳圖片', 'image_intro' => '您可以在這裡選取和管理上傳到系統的圖片。', - 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_intro_upload' => '透過拖曳圖檔至視窗中,或是使用下方的「上傳圖片」按鍵', 'image_all' => '全部', 'image_all_title' => '檢視所有圖片', 'image_book_title' => '檢視上傳到此書本的圖片', @@ -34,8 +34,8 @@ 'image_delete_success' => '圖片刪除成功', 'image_replace' => '替換圖片', 'image_replace_success' => '圖片更新成功', - 'image_rebuild_thumbs' => 'Regenerate Size Variations', - 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + 'image_rebuild_thumbs' => '重建影像縮圖', + 'image_rebuild_thumbs_success' => '縮圖建立成功', // Code Editor 'code_editor' => '編輯程式碼', diff --git a/lang/zh_TW/editor.php b/lang/zh_TW/editor.php index c4a351f9b6c..2ee37f863de 100644 --- a/lang/zh_TW/editor.php +++ b/lang/zh_TW/editor.php @@ -81,9 +81,9 @@ 'table_properties' => '表格屬性', 'table_properties_title' => '表格屬性', 'delete_table' => '刪除表格', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => '清除格式', + 'resize_to_contents' => '將大小調整到符合內容', + 'row_header' => '列標題', 'insert_row_before' => '插入上方列', 'insert_row_after' => '插入下方列', 'delete_row' => '刪除列', @@ -93,84 +93,84 @@ 'table_cell' => '儲存格', 'table_row' => '行', 'table_column' => '欄', - 'cell_properties' => 'Cell properties', - 'cell_properties_title' => 'Cell Properties', - 'cell_type' => 'Cell type', - 'cell_type_cell' => 'Cell', - 'cell_scope' => 'Scope', - 'cell_type_header' => 'Header cell', - 'merge_cells' => 'Merge cells', - 'split_cell' => 'Split cell', - 'table_row_group' => 'Row Group', - 'table_column_group' => 'Column Group', - 'horizontal_align' => 'Horizontal align', - 'vertical_align' => 'Vertical align', - 'border_width' => 'Border width', - 'border_style' => 'Border style', - 'border_color' => 'Border color', - 'row_properties' => 'Row properties', - 'row_properties_title' => 'Row Properties', - 'cut_row' => 'Cut row', - 'copy_row' => 'Copy row', - 'paste_row_before' => 'Paste row before', - 'paste_row_after' => 'Paste row after', - 'row_type' => 'Row type', + 'cell_properties' => '表格屬性', + 'cell_properties_title' => '表格屬性', + 'cell_type' => '儲存格類型', + 'cell_type_cell' => '儲存格', + 'cell_scope' => '範圍', + 'cell_type_header' => '標題儲存格', + 'merge_cells' => '合併儲存格', + 'split_cell' => '分割儲存格', + 'table_row_group' => '依行分組', + 'table_column_group' => '依列分組', + 'horizontal_align' => '水平對齊', + 'vertical_align' => '垂直對齊', + 'border_width' => '邊框寬度', + 'border_style' => '邊框樣式', + 'border_color' => '邊框顏色', + 'row_properties' => '行屬性', + 'row_properties_title' => '行屬性', + 'cut_row' => '剪切行', + 'copy_row' => '複製行', + 'paste_row_before' => '插入上方行', + 'paste_row_after' => '插入下方行', + 'row_type' => '行類型', 'row_type_header' => '頁眉', 'row_type_body' => '正文', 'row_type_footer' => '页脚', - 'alignment' => 'Alignment', - 'cut_column' => 'Cut column', - 'copy_column' => 'Copy column', - 'paste_column_before' => 'Paste column before', - 'paste_column_after' => 'Paste column after', - 'cell_padding' => 'Cell padding', - 'cell_spacing' => 'Cell spacing', - 'caption' => 'Caption', - 'show_caption' => 'Show caption', - 'constrain' => 'Constrain proportions', - 'cell_border_solid' => 'Solid', - 'cell_border_dotted' => 'Dotted', - 'cell_border_dashed' => 'Dashed', - 'cell_border_double' => 'Double', - 'cell_border_groove' => 'Groove', - 'cell_border_ridge' => 'Ridge', - 'cell_border_inset' => 'Inset', - 'cell_border_outset' => 'Outset', - 'cell_border_none' => 'None', - 'cell_border_hidden' => 'Hidden', + 'alignment' => '對齊', + 'cut_column' => '剪切列', + 'copy_column' => '剪切列', + 'paste_column_before' => '插入前方列', + 'paste_column_after' => '插入後方列', + 'cell_padding' => '單元格填充', + 'cell_spacing' => '單元格間距', + 'caption' => '標題', + 'show_caption' => '顯示標題', + 'constrain' => '維持比例', + 'cell_border_solid' => '實線', + 'cell_border_dotted' => '點線', + 'cell_border_dashed' => '虛線', + 'cell_border_double' => '兩倍', + 'cell_border_groove' => '凹線', + 'cell_border_ridge' => '浮出', + 'cell_border_inset' => '嵌入', + 'cell_border_outset' => '外嵌', + 'cell_border_none' => '無', + 'cell_border_hidden' => '隱藏', // Images, links, details/summary & embed 'source' => '來源', - 'alt_desc' => 'Alternative description', - 'embed' => 'Embed', - 'paste_embed' => 'Paste your embed code below:', + 'alt_desc' => '替代描述', + 'embed' => '內嵌', + 'paste_embed' => '在下面黏貼您的嵌入代碼:', 'url' => '網址', - 'text_to_display' => 'Text to display', + 'text_to_display' => '要顯示的文字', 'title' => '標題', - 'open_link' => 'Open link', - 'open_link_in' => 'Open link in...', - 'open_link_current' => 'Current window', - 'open_link_new' => 'New window', + 'open_link' => '開啟連結', + 'open_link_in' => '打開連結於……', + 'open_link_current' => '當前視窗', + 'open_link_new' => '新視窗', 'remove_link' => '移除連結', - 'insert_collapsible' => 'Insert collapsible block', - 'collapsible_unwrap' => 'Unwrap', + 'insert_collapsible' => '插入可折疊塊', + 'collapsible_unwrap' => '展開', 'edit_label' => '編輯標記', - 'toggle_open_closed' => 'Toggle open/closed', - 'collapsible_edit' => 'Edit collapsible block', + 'toggle_open_closed' => '切換打開/關閉', + 'collapsible_edit' => '編輯可折疊塊', 'toggle_label' => '切換標籤', // About view 'about' => '關於編輯器', - 'about_title' => 'About the WYSIWYG Editor', - 'editor_license' => 'Editor License & Copyright', - 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', - 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', - 'save_continue' => 'Save Page & Continue', - 'callouts_cycle' => '(Keep pressing to toggle through types)', - 'link_selector' => 'Link to content', + 'about_title' => '關於所見即所得(WYSIWYG)編輯器', + 'editor_license' => '編輯器許可證與版權信息', + 'editor_tiny_license' => '此編輯器是用 :tinyLink 構建的,基於 MIT 許可證。', + 'editor_tiny_license_link' => 'TinyMCE 的版權和許可證詳細信息可以在這裡找到。', + 'save_continue' => '保存頁面並繼續', + 'callouts_cycle' => '(繼續按下以切換類型)', + 'link_selector' => '鏈接到內容', 'shortcuts' => '快捷鍵', 'shortcut' => '快捷鍵', - 'shortcuts_intro' => 'The following shortcuts are available in the editor:', + 'shortcuts_intro' => '編輯器中提供了以下快捷鍵:', 'windows_linux' => '(Windows/Linux)', 'mac' => '(Mac)', 'description' => '說明', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index e8309d6a59a..8aa9255c511 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -23,9 +23,9 @@ 'meta_updated' => '更新於 :timeLength', 'meta_updated_name' => '由 :user 更新於 :timeLength', 'meta_owned_name' => ':user 所擁有', - 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'meta_reference_count' => '被 :count 個項目引用', 'entity_select' => '選取項目', - 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', + 'entity_select_lack_permission' => '你沒有權限使用此項目', 'images' => '圖片', 'my_recent_drafts' => '我最近的草稿', 'my_recently_viewed' => '我最近檢視', @@ -39,21 +39,21 @@ 'export_pdf' => 'PDF 檔案', 'export_text' => '純文字檔案', 'export_md' => 'Markdown 檔案', - 'default_template' => 'Default Page Template', - 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', - 'default_template_select' => 'Select a template page', + 'default_template' => '預設頁面範本', + 'default_template_explain' => '請設定一個頁面範本,作為新頁面的預設內容。請注意,這僅限於作者擁有頁面範本讀取權限時才能夠使用。', + 'default_template_select' => '選擇一個頁面範本', // Permissions and restrictions 'permissions' => '權限', - 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', - 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', - 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', + 'permissions_desc' => '設定權限,並覆蓋角色預設權限', + 'permissions_book_cascade' => '除非章節、頁面有自訂權限,否則書籍設定的權限將自動套用', + 'permissions_chapter_cascade' => '除非章節、頁面有自訂權限,否則章節的權限將自動套用', 'permissions_save' => '儲存權限', 'permissions_owner' => '擁有者', - 'permissions_role_everyone_else' => 'Everyone Else', - 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', - 'permissions_role_override' => 'Override permissions for role', - 'permissions_inherit_defaults' => 'Inherit defaults', + 'permissions_role_everyone_else' => '所有其他人', + 'permissions_role_everyone_else_desc' => '設定未被指派角色時的權限', + 'permissions_role_override' => '覆蓋角色權限', + 'permissions_inherit_defaults' => '繼承預設值', // Search 'search_results' => '搜尋結果', @@ -96,24 +96,24 @@ 'shelves_save' => '儲存書架', 'shelves_books' => '此書架上的書本', 'shelves_add_books' => '新增書本至此書架', - 'shelves_drag_books' => 'Drag books below to add them to this shelf', + 'shelves_drag_books' => '拖曳書籍至下方,以便新增至此書架', 'shelves_empty_contents' => '此書架沒有分配任何書本', 'shelves_edit_and_assign' => '編輯書架以分配書本', - 'shelves_edit_named' => 'Edit Shelf :name', - 'shelves_edit' => 'Edit Shelf', - 'shelves_delete' => 'Delete Shelf', - 'shelves_delete_named' => 'Delete Shelf :name', + 'shelves_edit_named' => '編輯書架', + 'shelves_edit' => '編輯書架', + 'shelves_delete' => '刪除書架', + 'shelves_delete_named' => '刪除書架「:name」', 'shelves_delete_explain' => "這將刪除名為「:name」的書架。但其中的書本不會被刪除。", 'shelves_delete_confirmation' => '您確定要刪除此書架嗎?', - 'shelves_permissions' => 'Shelf Permissions', - 'shelves_permissions_updated' => 'Shelf Permissions Updated', - 'shelves_permissions_active' => 'Shelf Permissions Active', - 'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', - 'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.', + 'shelves_permissions' => '書架權限', + 'shelves_permissions_updated' => '已更新書架權限', + 'shelves_permissions_active' => '已啟用書架權限', + 'shelves_permissions_cascade_warning' => '因書籍可位於多個書架,因此書架權限不會自動套用至書籍上。但仍可透過下方的選項來複製權限到子書籍。', + 'shelves_permissions_create' => '書架創建權限僅用於使用下面的操作將權限複製到子圖書。這個權限不是用來控制創建書籍的。', 'shelves_copy_permissions_to_books' => '將權限複製到書本', 'shelves_copy_permissions' => '複製權限', - 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', - 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + 'shelves_copy_permissions_explain' => '此操作會將此書架的當前權限設置應用於其中包含的所有圖書上。 啓用前請確保已保存對此書架權限的任何更改。', + 'shelves_copy_permission_success' => '書架權限已複製到 :count 本書籍', // Books 'book' => '書本', @@ -145,7 +145,7 @@ 'books_search_this' => '搜尋此書本', 'books_navigation' => '書本導覽', 'books_sort' => '排序書本內容', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_desc' => '移動章節、頁面來重新組織內容。也可以透過顯示多本書籍來調整書籍之間的內容', 'books_sort_named' => '排序書本 :bookName', 'books_sort_name' => '按名稱排序', 'books_sort_created' => '按建立時間排序', @@ -154,19 +154,19 @@ 'books_sort_chapters_last' => '最後一章', 'books_sort_show_other' => '顯示其他書本', 'books_sort_save' => '儲存新順序', - 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', - 'books_sort_move_up' => 'Move Up', - 'books_sort_move_down' => 'Move Down', - 'books_sort_move_prev_book' => 'Move to Previous Book', - 'books_sort_move_next_book' => 'Move to Next Book', - 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', - 'books_sort_move_next_chapter' => 'Move Into Next Chapter', - 'books_sort_move_book_start' => 'Move to Start of Book', - 'books_sort_move_book_end' => 'Move to End of Book', - 'books_sort_move_before_chapter' => 'Move to Before Chapter', - 'books_sort_move_after_chapter' => 'Move to After Chapter', - 'books_copy' => 'Copy Book', - 'books_copy_success' => 'Book successfully copied', + 'books_sort_show_other_desc' => '新增書籍到排序清單中,以便跨書籍重組資料', + 'books_sort_move_up' => '上移', + 'books_sort_move_down' => '下移', + 'books_sort_move_prev_book' => '移動至前一書籍', + 'books_sort_move_next_book' => '移動至前一書籍', + 'books_sort_move_prev_chapter' => '移動至前一章節', + 'books_sort_move_next_chapter' => '移動至下一章節', + 'books_sort_move_book_start' => '移動到書籍開頭', + 'books_sort_move_book_end' => '移動到書籍結尾', + 'books_sort_move_before_chapter' => '移動到章節之前', + 'books_sort_move_after_chapter' => '移動到章節之後', + 'books_copy' => '複製書籍', + 'books_copy_success' => '書籍已成功被複製', // Chapters 'chapter' => '章節', @@ -184,14 +184,14 @@ 'chapters_save' => '儲存章節', 'chapters_move' => '移動章節', 'chapters_move_named' => '移動章節 :chapterName', - 'chapters_copy' => 'Copy Chapter', - 'chapters_copy_success' => 'Chapter successfully copied', + 'chapters_copy' => '複製章節', + 'chapters_copy_success' => '章節已成功被複製', 'chapters_permissions' => '章節權限', 'chapters_empty' => '本章目前沒有頁面。', 'chapters_permissions_active' => '章節權限已啟用', 'chapters_permissions_success' => '章節權限已更新', 'chapters_search_this' => '搜尋此章節', - 'chapter_sort_book' => 'Sort Book', + 'chapter_sort_book' => '排序書籍內容', // Pages 'page' => '頁面', @@ -207,7 +207,7 @@ 'pages_delete_draft' => '刪除草稿頁面', 'pages_delete_success' => '頁面已刪除', 'pages_delete_draft_success' => '草稿頁面已刪除', - 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_warning_template' => '此頁面是當前書籍或章節的默認頁面模板。刪除此頁面後,這些書籍或章節的默認頁面模板將被取消。', 'pages_delete_confirm' => '您確定要刪除此頁面嗎?', 'pages_delete_draft_confirm' => '您確定要刪除此草稿頁面嗎?', 'pages_editing_named' => '正在編輯頁面 :pageName', @@ -218,21 +218,21 @@ 'pages_editing_page' => '正在編輯頁面', 'pages_edit_draft_save_at' => '草稿儲存於 ', 'pages_edit_delete_draft' => '刪除草稿', - 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.', + 'pages_edit_delete_draft_confirm' => '您確定要刪除您的草稿頁面更改嗎?自上次完整保存以來的所有更改都將丟失,編輯器將更新為最新非草稿頁面。', 'pages_edit_discard_draft' => '放棄草稿', - 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', - '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_markdown' => '切換到 Markdown 編輯器', + 'pages_edit_switch_to_markdown_clean' => '(清除內容)', + 'pages_edit_switch_to_markdown_stable' => '(保留內容)', + 'pages_edit_switch_to_wysiwyg' => '切換到所見即所得編輯器', 'pages_edit_set_changelog' => '設定變更日誌', 'pages_edit_enter_changelog_desc' => '輸入對您所做變動的簡易描述', 'pages_edit_enter_changelog' => '輸入變更日誌', 'pages_editor_switch_title' => '切換編輯器', - 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', - 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', - 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', - 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', - 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', + 'pages_editor_switch_are_you_sure' => '你想要更改這頁所使用的編輯器嗎?', + 'pages_editor_switch_consider_following' => '更換編輯器時請考慮以下事項:', + 'pages_editor_switch_consideration_a' => '一旦選擇使用新的編輯器,其他頁面以及沒有權限更換編輯器的使用者都將使用新的編輯器', + 'pages_editor_switch_consideration_b' => '在某些情況下,將遺失細部設定以及語法', + 'pages_editor_switch_consideration_c' => '切換編輯器時,本次的標籤設定、版本修訂記錄將不會被保留', 'pages_save' => '儲存頁面', 'pages_title' => '頁面標題', 'pages_name' => '頁面名稱', @@ -242,9 +242,9 @@ 'pages_md_insert_link' => '插入連結', 'pages_md_insert_drawing' => '插入繪圖', 'pages_md_show_preview' => '顯示預覽', - 'pages_md_sync_scroll' => 'Sync preview scroll', - 'pages_drawing_unsaved' => 'Unsaved Drawing Found', - 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', + 'pages_md_sync_scroll' => '預覽頁面同步捲動', + 'pages_drawing_unsaved' => '偵測到未儲存的繪圖', + 'pages_drawing_unsaved_confirm' => '從之前保存失敗的繪圖中發現了可恢復的數據。您想恢復並繼續編輯這個未保存的繪圖嗎?', 'pages_not_in_chapter' => '頁面不在章節中', 'pages_move' => '移動頁面', 'pages_copy' => '複製頁面', @@ -254,17 +254,17 @@ 'pages_permissions_success' => '頁面權限已更新', 'pages_revision' => '修訂版本', 'pages_revisions' => '頁面修訂版本', - 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', + 'pages_revisions_desc' => '下面列出的是該頁面的所有過去修訂。如果權限允許,您可以回顧、比較和恢復舊的頁面版本。頁面的完整歷史可能不會在這裡完全反映出來,因為根據系統配置,舊的修訂可能會被自動刪除。', 'pages_revisions_named' => ':pageName 頁面修訂版本', 'pages_revision_named' => ':pageName 頁面修訂版本', 'pages_revision_restored_from' => '從 #:id; :summary 復原', 'pages_revisions_created_by' => '建立者', 'pages_revisions_date' => '修訂日期', 'pages_revisions_number' => '#', - 'pages_revisions_sort_number' => 'Revision Number', + 'pages_revisions_sort_number' => '修訂版號', 'pages_revisions_numbered' => '修訂版本 #:id', 'pages_revisions_numbered_changes' => '修訂版本 #:id 變更', - 'pages_revisions_editor' => 'Editor Type', + 'pages_revisions_editor' => '編輯器類型', 'pages_revisions_changelog' => '變動日誌', 'pages_revisions_changes' => '變動', 'pages_revisions_current' => '目前版本', @@ -272,20 +272,20 @@ 'pages_revisions_restore' => '還原', 'pages_revisions_none' => '此頁面沒有修訂', 'pages_copy_link' => '複製連結', - 'pages_edit_content_link' => 'Jump to section in editor', - 'pages_pointer_enter_mode' => 'Enter section select mode', - 'pages_pointer_label' => 'Page Section Options', - 'pages_pointer_permalink' => 'Page Section Permalink', - 'pages_pointer_include_tag' => 'Page Section Include Tag', - 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', - 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_edit_content_link' => '移動到編輯器區段', + 'pages_pointer_enter_mode' => '進入區段選取模式', + 'pages_pointer_label' => '頁面區段選項', + 'pages_pointer_permalink' => '頁面區段永久連結', + 'pages_pointer_include_tag' => '包含標籤的頁面區段', + 'pages_pointer_toggle_link' => '永久連結模式,點擊顯示包含的標籤', + 'pages_pointer_toggle_include' => '包含標籤模式,按下顯示永久鏈接', 'pages_permissions_active' => '頁面權限已啟用', 'pages_initial_revision' => '初次發布', - 'pages_references_update_revision' => 'System auto-update of internal links', + 'pages_references_update_revision' => '系統自動更新的內部鏈接', 'pages_initial_name' => '新頁面', 'pages_editing_draft_notification' => '您正在編輯最後儲存為 :timeDiff 的草稿。', 'pages_draft_edited_notification' => '此頁面已經被更新過。建議您放棄此草稿。', - 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_page_changed_since_creation' => '這個頁面在您的草稿創建後被其他用戶更新了,您目前的草稿不包含新的內容。建議您放棄此草稿,或是注意不要覆蓋新的頁面更改。', 'pages_draft_edit_active' => [ 'start_a' => ':count 位使用者已經開始編輯此頁面', 'start_b' => '使用者 :userName 已經開始編輯此頁面', @@ -293,35 +293,35 @@ 'time_b' => '在最近:minCount分鐘', 'message' => ':start :time。注意不要覆寫其他人的更新!', ], - 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', - 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', + 'pages_draft_discarded' => '草稿已丟棄!編輯器已更新到當前頁面內容', + 'pages_draft_deleted' => '草稿已刪除!編輯器已更新為當前頁面內容', 'pages_specific' => '特定頁面', 'pages_is_template' => '頁面模板', // Editor Sidebar - 'toggle_sidebar' => 'Toggle Sidebar', + 'toggle_sidebar' => '切換側邊欄', 'page_tags' => '頁面標籤', 'chapter_tags' => '章節標籤', 'book_tags' => '書本標籤', 'shelf_tags' => '書架標籤', 'tag' => '標籤', 'tags' => '標籤', - 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', + 'tags_index_desc' => '為內容加上標籤可靈活得進行分類。標籤可以設定名稱與數值,其中數值為選用。一旦標籤設定完成,便可透過標籤名稱與數值來進行搜尋。', 'tag_name' => '標籤名稱', 'tag_value' => '標籤值(選擇性)', 'tags_explain' => "加入一些標籤以更好地對您的內容進行分類。 \n 您可以為標籤分配一個值,以進行更深入的組織。", 'tags_add' => '新增另一個標籤', 'tags_remove' => '移除此標籤', - 'tags_usages' => 'Total tag usages', - 'tags_assigned_pages' => 'Assigned to Pages', - 'tags_assigned_chapters' => 'Assigned to Chapters', - 'tags_assigned_books' => 'Assigned to Books', - 'tags_assigned_shelves' => 'Assigned to Shelves', - 'tags_x_unique_values' => ':count unique values', - 'tags_all_values' => 'All values', + 'tags_usages' => '總用量', + 'tags_assigned_pages' => '頁面', + 'tags_assigned_chapters' => '章節', + 'tags_assigned_books' => '書籍', + 'tags_assigned_shelves' => '書架', + 'tags_x_unique_values' => ':count 個不同的數值', + 'tags_all_values' => '所有數值', 'tags_view_tags' => '檢視標籤', 'tags_view_existing_tags' => '檢視已存在的標籤', - 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', + 'tags_list_empty_hint' => '可在編輯書架、書籍時設定標籤,或是在編輯章節、頁面時透過側邊欄設定', 'attachments' => '附件', 'attachments_explain' => '上傳一些檔案或附加連結以顯示在您的網頁上。將顯示在在頁面的側邊欄。', 'attachments_explain_instant_save' => '此處的變動將會立刻儲存。', @@ -330,7 +330,7 @@ 'attachments_upload_drop' => '你也可以將檔案拖曳到此來上傳附加檔案', 'attachments_set_link' => '設定連結', 'attachments_delete' => '您確定要刪除此附件嗎?', - 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_dropzone' => '將檔案拖曳至此來上傳', 'attachments_no_files' => '尚未上傳檔案', 'attachments_explain_link' => '如果您不想上傳檔案,則可以附加連結。這可以是指向其他頁面的連結,也可以是指向雲端檔案的連結。', 'attachments_link_name' => '連結名稱', @@ -373,7 +373,7 @@ 'comment_new' => '新評論', 'comment_created' => '評論於 :createDiff', 'comment_updated' => '由 :username 於 :updateDiff 更新', - 'comment_updated_indicator' => 'Updated', + 'comment_updated_indicator' => '已更新', 'comment_deleted_success' => '評論已刪除', 'comment_created_success' => '評論已加入', 'comment_updated_success' => '評論已更新', @@ -387,51 +387,51 @@ 'revision_cannot_delete_latest' => '無法刪除最新修訂版本。', // Copy view - 'copy_consider' => 'Please consider the below when copying content.', - 'copy_consider_permissions' => 'Custom permission settings will not be copied.', - 'copy_consider_owner' => 'You will become the owner of all copied content.', - 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', - 'copy_consider_attachments' => 'Page attachments will not be copied.', - 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + 'copy_consider' => '複製內容時請注意以下事項', + 'copy_consider_permissions' => '自定義權限設置將不會被複製。', + 'copy_consider_owner' => '您將成為所有已複製內容的所有者。', + 'copy_consider_images' => '頁面中的圖像文件不會被複製,原始圖像將保留它們與最初上傳到的頁面的關係。', + 'copy_consider_attachments' => '頁面中的附件不會被複製。', + 'copy_consider_access' => '改變位置、所有者或權限可能會導致此內容被以前無法訪問的人訪問。', // Conversions - 'convert_to_shelf' => 'Convert to Shelf', - 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', - 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', - 'convert_book' => 'Convert Book', - 'convert_book_confirm' => 'Are you sure you want to convert this book?', - 'convert_undo_warning' => 'This cannot be as easily undone.', - 'convert_to_book' => 'Convert to Book', - 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', - 'convert_chapter' => 'Convert Chapter', - 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + 'convert_to_shelf' => '轉換成書架', + 'convert_to_shelf_contents_desc' => '你可以將此書籍轉換成包含相同內容的新書架,書籍中的章節則會轉換成書籍。若書籍中有不屬於任何章節的頁面,這些頁面會自動轉換成新書架中的書籍。', + 'convert_to_shelf_permissions_desc' => '在這本書上設置的任何權限都將複製到所有未強制執行權限的新書架和新子圖書上。請注意,書架上的權限不會像圖書那樣繼承到內容物上。', + 'convert_book' => '轉換成書本', + 'convert_book_confirm' => '您確定要轉換此書本嗎?', + 'convert_undo_warning' => '這可不能輕易撤消。', + 'convert_to_book' => '轉換成書籍', + 'convert_to_book_desc' => '您可以將此章節轉換為具有相同內容的新書本。此章節中設置的任何權限都將複製到新書本上,但從父圖書繼承的任何權限都不會被複製,這可能會導致訪問控制發生變化。', + 'convert_chapter' => '轉換章節', + 'convert_chapter_confirm' => '您確定要轉換此章節嗎?', // References - 'references' => 'References', - 'references_none' => 'There are no tracked references to this item.', - 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + 'references' => '引用', + 'references_none' => '沒有跟蹤到對此項目的引用。', + 'references_to_desc' => '下方列出了系統中鏈接到此項目的所有已知內容。', // Watch Options 'watch' => '追蹤', 'watch_title_default' => '預設偏好設定', 'watch_desc_default' => '還原成預設的通知設定', - 'watch_title_ignore' => 'Ignore', - 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', - 'watch_title_new' => 'New Pages', - 'watch_desc_new' => 'Notify when any new page is created within this item.', - 'watch_title_updates' => 'All Page Updates', - 'watch_desc_updates' => 'Notify upon all new pages and page changes.', - 'watch_desc_updates_page' => 'Notify upon all page changes.', - 'watch_title_comments' => 'All Page Updates & Comments', - 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', - 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', - 'watch_change_default' => 'Change default notification preferences', - 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_title_ignore' => '忽略', + 'watch_desc_ignore' => '忽略所有通知,包括來自用戶級偏好的通知。', + 'watch_title_new' => '新頁面', + 'watch_desc_new' => '在此項目中創建任何新頁面時通知我。', + 'watch_title_updates' => '所有頁面更新', + 'watch_desc_updates' => '在所有新頁面和頁面更改時通知我。', + 'watch_desc_updates_page' => '在有頁面發生更改時通知我。', + 'watch_title_comments' => '所有頁面更新和評論', + 'watch_desc_comments' => '在有新頁面、頁面更改和新評論時通知我。', + 'watch_desc_comments_page' => '在有頁面更改和新評論時通知我。', + 'watch_change_default' => '更改默認通知偏好', + 'watch_detail_ignore' => '忽略通知', 'watch_detail_new' => '追蹤新頁面', 'watch_detail_updates' => '追蹤新頁面與異動', 'watch_detail_comments' => '追蹤新頁面、自動與評論', - 'watch_detail_parent_book' => 'Watching via parent book', - 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', - 'watch_detail_parent_chapter' => 'Watching via parent chapter', - 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', + 'watch_detail_parent_book' => '已關注—繼承自父書本', + 'watch_detail_parent_book_ignore' => '已忽略—繼承自父書本', + 'watch_detail_parent_chapter' => '已關注—繼承自父章節', + 'watch_detail_parent_chapter_ignore' => '已忽略—繼承自父章節', ]; diff --git a/lang/zh_TW/errors.php b/lang/zh_TW/errors.php index 5b075135312..6fec01ea0d4 100644 --- a/lang/zh_TW/errors.php +++ b/lang/zh_TW/errors.php @@ -10,7 +10,7 @@ // Auth 'error_user_exists_different_creds' => '電子郵件為 :email 已存在,但帳號密碼不同。', - 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'auth_pre_register_theme_prevention' => '無法使用資料建立帳號', 'email_already_confirmed' => '已確認電子郵件,請嘗試登入。', 'email_confirmation_invalid' => '這個確認權杖無效或已被使用,請嘗試重新註冊。', 'email_confirmation_expired' => '這個確認權杖無效或已被使用,已傳送新的確認電子郵件。', @@ -37,32 +37,32 @@ 'social_driver_not_found' => '找不到社交驅動程式', 'social_driver_not_configured' => '您的 :socialAccount 社交設定不正確。', 'invite_token_expired' => '此邀請連結已過期。您可以嘗試重設您的帳號密碼。', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => '使用者不存在', // System 'path_not_writable' => '無法上傳到 :filePath 檔案路徑。請確定其對伺服器來說是可寫入的。', 'cannot_get_image_from_url' => '無法從 :url 取得圖片', 'cannot_create_thumbs' => '伺服器無法建立縮圖。請檢查您是否安裝了 PHP 的 GD 擴充程式。', 'server_upload_limit' => '伺服器不允許上傳這個大的檔案。請嘗試較小的檔案。', - 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'server_post_limit' => '伺服器無法處理提供的資料,請嘗試刪減內容或較小的檔案', 'uploaded' => '伺服器不允許上傳這個大的檔案。請嘗試較小的檔案。', // Drawing & Images 'image_upload_error' => '上傳圖片時發生錯誤', 'image_upload_type_error' => '上傳圖片類型無效', 'image_upload_replace_type' => '必須使用的檔案類型才能置換圖檔', - 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', - 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', - 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', + 'image_upload_memory_limit' => '由於系統限制,無法處理上傳的檔案或縮圖', + 'image_thumbnail_memory_limit' => '由於系統限制,無法建立不同尺寸的圖片', + 'image_gallery_thumbnail_memory_limit' => '由於系統限制,無法建立縮圖', 'drawing_data_not_found' => '無法載入繪圖資料,繪圖檔案可能不存在,或您可能沒有權限存取它。', // Attachments 'attachment_not_found' => '找不到附件', - 'attachment_upload_error' => 'An error occurred uploading the attachment file', + 'attachment_upload_error' => '上傳檔案時發生錯誤', // Pages 'page_draft_autosave_fail' => '無法儲存草稿。請確保您在儲存此頁面前已連線至網際網路', - 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', + 'page_draft_delete_fail' => '無法刪除草稿並取得最新的頁面存檔', 'page_custom_home_deletion' => '無法刪除被設定為首頁的頁面', // Entities @@ -116,5 +116,5 @@ 'maintenance_test_email_failure' => '寄送測試電子郵件時發生錯誤:', // HTTP errors - 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', + 'http_ssr_url_no_match' => 'URL 與設置的 SSR 主機不符', ]; diff --git a/lang/zh_TW/notifications.php b/lang/zh_TW/notifications.php index 1afd23f1dc4..7fcfa640090 100644 --- a/lang/zh_TW/notifications.php +++ b/lang/zh_TW/notifications.php @@ -4,24 +4,24 @@ */ return [ - 'new_comment_subject' => 'New comment on page: :pageName', - 'new_comment_intro' => 'A user has commented on a page in :appName:', - 'new_page_subject' => 'New page: :pageName', - 'new_page_intro' => 'A new page has been created in :appName:', - 'updated_page_subject' => 'Updated page: :pageName', - 'updated_page_intro' => 'A page has been updated in :appName:', - 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'new_comment_subject' => '頁面上有新評論::pageName', + 'new_comment_intro' => '一位用戶在 :appName: 的頁面上發表了評論', + 'new_page_subject' => '新頁面::pageName', + 'new_page_intro' => ':appName: 中創建了一個新頁面', + 'updated_page_subject' => '頁面更新::pageName', + 'updated_page_intro' => ':appName: 中的一個頁面已被更新', + 'updated_page_debounce' => '為了防止出現大量通知,一段時間內您不會收到同一編輯者再次編輯本頁面的通知。', - 'detail_page_name' => 'Page Name:', - 'detail_page_path' => 'Page Path:', - 'detail_commenter' => 'Commenter:', - 'detail_comment' => 'Comment:', - 'detail_created_by' => 'Created By:', - 'detail_updated_by' => 'Updated By:', + 'detail_page_name' => '頁面名稱:', + 'detail_page_path' => '頁面路徑:', + 'detail_commenter' => '評論者:', + 'detail_comment' => '評論:', + 'detail_created_by' => '建立者:', + 'detail_updated_by' => '更新者:', - 'action_view_comment' => 'View Comment', - 'action_view_page' => 'View Page', + 'action_view_comment' => '檢視評論', + 'action_view_page' => '查看頁面', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', - 'footer_reason_link' => 'your notification preferences', + 'footer_reason' => '向您發送此通知是因為 :link 涵蓋了該項目的此類活動。', + 'footer_reason_link' => '個人偏好通知設定', ]; diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 572739f2933..acfbe334d7b 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -27,7 +27,7 @@ 'app_secure_images_toggle' => '啟用更高安全性的圖片上傳', 'app_secure_images_desc' => '因為效能因素,所有圖片都是公開的。此選項會在圖片的網址前加入一串隨機且難以猜測的字串。確保未啟用目錄索引,讓直接進入變得更困難。', 'app_default_editor' => '預設頁面編輯器', - 'app_default_editor_desc' => '选择编辑新页面时默认使用的编辑器。这可以在权限允许的页面级别被覆盖。', + 'app_default_editor_desc' => '選擇編輯頁面時預設使用的編輯器,這項設定值可被頁面的權限覆蓋', 'app_custom_html' => '自訂 HTML 標題內容', 'app_custom_html_desc' => '此處加入的任何內容都將插入到每個頁面的 部分的底部,這對於覆蓋樣式或加入分析程式碼很方便。', 'app_custom_html_disabled_notice' => '在此設定頁面上停用了自訂 HTML 標題內容,以確保任何重大變更都能被還原。', @@ -49,8 +49,8 @@ // Color settings 'color_scheme' => '應用程式配色', - 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', - 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', + 'color_scheme_desc' => '設定使用者頁面欲使用的顏色。可分別針對淺色模式與暗色模式設定顏色,以確保容易閱讀', + 'ui_colors_desc' => '設定頁面主要色彩以及頁面連結預設顏色。主要色彩用於標題橫幅、按鈕以及主要操作界面,頁面連結顏色,主要用於主頁面、編輯頁面中的文字連結、操作按鈕。', 'app_color' => '主要顏色', 'link_color' => '連結預設顏色', 'content_colors_desc' => '設定頁面層次結構中的元素顏色;為了提高可讀性,建議選擇亮度與預設顏色相似的顏色。', @@ -92,9 +92,9 @@ 'maint_send_test_email_mail_text' => '恭喜!您收到這封電子郵件通知時,代表您的電子郵件設定已正確設定。', 'maint_recycle_bin_desc' => '刪除的書架、書本、章節與頁面將會被傳送到回收桶,這樣仍可以還原或永久刪除。回收桶中較舊的項目可能會在一段時間後自動移除,取決於您的系統設定。', 'maint_recycle_bin_open' => '開啟回收桶', - 'maint_regen_references' => 'Regenerate References', - 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', - 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_regen_references' => '重新生成引用', + 'maint_regen_references_desc' => '此操作將重建數據庫中的跨項目引用索引。這通常是自動處理的,但可能有助於索引舊內容或通過非官方方法添加的內容。', + 'maint_regen_references_success' => '引用索引已重新生成!', 'maint_timeout_command_note' => '備註:這項操作需要較長的時間,可能導致多數的網路環境發生連線逾時的問題。若有需要,可以透過終端機指令來替代。', // Recycle Bin @@ -109,7 +109,7 @@ 'recycle_bin_contents_empty' => '回收桶目前是空的', 'recycle_bin_empty' => '清空回收桶', 'recycle_bin_empty_confirm' => '這將會永久破壞回收桶中的所有項目,包括每個項目中包含的內容。您確定您想要清空回收桶嗎?', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => '此操作將從系統中永久刪除此項目以及下面列出的所有子元素,並且您將無法還原此內容。您確定要永久刪除該項目嗎?', 'recycle_bin_destroy_list' => '要被銷毀的項目', 'recycle_bin_restore_list' => '要被還原的項目', 'recycle_bin_restore_confirm' => '此動作將會還原已被刪除的項目(包含任何下層元素)到其原始位置。如果原始位置已被刪除,且目前位於垃圾桶裡,那麼上層項目也需要被還原。', @@ -138,10 +138,10 @@ 'role_user_roles' => '使用者角色', 'roles_index_desc' => '「角色」用於將系統權限套用至使用者群組。當使用者擁有多角色時, 使用者會自動繼承角色中的所有系統權限', - 'roles_x_users_assigned' => ':count user assigned|:count users assigned', - 'roles_x_permissions_provided' => ':count permission|:count permissions', - 'roles_assigned_users' => 'Assigned Users', - 'roles_permissions_provided' => 'Provided Permissions', + 'roles_x_users_assigned' => ':count 位用戶已分配|:count 位用戶已分配', + 'roles_x_permissions_provided' => ':count 個權限|:count 個權限', + 'roles_assigned_users' => '已分配用戶', + 'roles_permissions_provided' => '已提供權限', 'role_create' => '建立新角色', 'role_delete' => '刪除角色', 'role_delete_confirm' => '這將會刪除名為「:roleName」的角色.', @@ -164,12 +164,12 @@ 'role_manage_settings' => '管理應用程式設定', 'role_export_content' => '匯出內容', 'role_editor_change' => '重設頁面編輯器', - 'role_notifications' => 'Receive & manage notifications', + 'role_notifications' => '管理和接收通知', 'role_asset' => '資源權限', 'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。', 'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。', 'role_asset_admins' => '管理員會自動取得對所有內容的存取權,但這些選項可能會顯示或隱藏使用者介面的選項。', - 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_asset_image_view_note' => '這與圖像管理器中的可見性有關。已經上傳的圖片的實際訪問取決於系統圖像存儲選項。', 'role_all' => '全部', 'role_own' => '擁有', 'role_controlled_by_asset' => '依據隸屬的資源來決定', @@ -194,8 +194,8 @@ 'users_send_invite_text' => '您可以選擇向此使用者傳送邀請電子郵件,讓他們可以設定自己的密碼,您也可以自行設定他們的密碼。', 'users_send_invite_option' => '傳送邀請電子郵件給使用者', 'users_external_auth_id' => '外部身份驗證 ID', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_external_auth_id_desc' => '使用外部驗證系統時 (如 SAML2、OIDC、LDAP),將使這個帳號與驗證系統帳號連結。若使用一般的 email 認證方式,可以忽略此欄位。', + 'users_password_warning' => '如果您想更改此用戶的密碼,請填寫以下內容:', 'users_system_public' => '此使用者代表造訪您站台的任何訪客使用者。其不能用於登入,而會自動分配。', 'users_delete' => '刪除使用者', 'users_delete_named' => '刪除使用者 :userName', @@ -211,23 +211,23 @@ 'users_preferred_language' => '偏好語言', 'users_preferred_language_desc' => '此選項將會變更用於應用程式使用者介面的語言。不會影響任何使用者建立的內容。', 'users_social_accounts' => '社群網站帳號', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_desc' => '查看此用戶已連接的社交賬戶狀態。 除了主要認證系統外,社交賬戶也可用於系統訪問。', 'users_social_accounts_info' => '您可以在此處連結您其他的帳號以供快速登入。從此處取消連結帳號並不會撤銷先前已授權的存取。請從您連結的社群網站帳號的個人設定中撤銷存取權。', 'users_social_connect' => '連結帳號', 'users_social_disconnect' => '取消連結帳號', - 'users_social_status_connected' => 'Connected', - 'users_social_status_disconnected' => 'Disconnected', + 'users_social_status_connected' => '已連接', + 'users_social_status_disconnected' => '已斷開連接', 'users_social_connected' => ':socialAccount 帳號已經成功連結到您的個人資料。', 'users_social_disconnected' => ':socialAccount 帳號已經成功取消連結。', 'users_api_tokens' => 'API 權杖', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_desc' => '創建和管理用於 BookStack REST API 認證的訪問令牌。 API 的權限是通過令牌所屬的用戶管理的。', 'users_api_tokens_none' => '尚未為此使用者建立 API 權杖', 'users_api_tokens_create' => '建立權杖', 'users_api_tokens_expires' => '過期', 'users_api_tokens_docs' => 'API 文件', 'users_mfa' => '多重身分驗證', 'users_mfa_desc' => '設定多重身份驗證為您的帳戶多增加了一道防線', - 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_x_methods' => ':count 個措施已配置|:count 個措施已配置', 'users_mfa_configure' => '方式設置', // API Tokens @@ -250,30 +250,30 @@ // Webhooks 'webhooks' => 'Webhooks', - 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', - 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_index_desc' => 'Webhook 是一種在系統內發生某些操作和事件時將數據發送到外部 URL 的方法,它允許與外部平台(例如消息傳遞或通知系統)進行基於事件的集成。', + 'webhooks_x_trigger_events' => ':count 個觸發事件 |:count 個觸發事件', 'webhooks_create' => '建立 Webhook', 'webhooks_none_created' => '沒有已建立的 Webhook', 'webhooks_edit' => '設置 Webhook', 'webhooks_save' => '儲存 Webhook', 'webhooks_details' => 'WebHook 詳細資料', - 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', - 'webhooks_events' => 'Webhook Events', - 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', - 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_details_desc' => '提供一個用戶友好的名稱和一個 POST Endpoint 作為 Webhook 數據發送的位置。', + 'webhooks_events' => 'Webhook 事件', + 'webhooks_events_desc' => '選擇所有應觸發此 Webhook 的事件。', + 'webhooks_events_warning' => '請記住,即使應用了自定義權限,所有選定的事件也仍然會被觸發。 確保使用此 Webhook 不會洩露機密內容。', 'webhooks_events_all' => '全部系統活動', 'webhooks_name' => 'Webhook 名稱', - 'webhooks_timeout' => 'Webhook Request Timeout (Seconds)', - 'webhooks_endpoint' => 'Webhook Endpoint', - 'webhooks_active' => 'Webhook Active', + 'webhooks_timeout' => 'Webhook 請求超時(秒)', + 'webhooks_endpoint' => 'Webhook 端點', + 'webhooks_active' => 'Webhook 啟用', 'webhook_events_table_header' => '事件', 'webhooks_delete' => '刪除 Webhook', - 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', - 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', + 'webhooks_delete_warning' => '這將會從系統中完全刪除名為 “:webhookName” 的 webhook。', + 'webhooks_delete_confirm' => '確定要刪除此 Webhook 嗎?', 'webhooks_format_example' => 'Webhook 格式範例', - 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + 'webhooks_format_example_desc' => 'Webhook 數據會以 POST 請求按照以下 JSON 格式發送到設置的 Endpoint。 “related_item” 和 “url” 屬性是可選的,取決於觸發的事件類型。', 'webhooks_status' => 'Webhook 狀態', - 'webhooks_last_called' => 'Last Called:', + 'webhooks_last_called' => '最後一次調用:', 'webhooks_last_errored' => '上次錯誤', 'webhooks_last_error_message' => '上次錯誤信息', From 1c9afcb84ef702412d6a004df1a3d861a8f57f1b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Sep 2024 14:07:10 +0100 Subject: [PATCH 108/800] 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 ef6bf35724a..77c854b419d 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 73208cb2e43..4159cd457e5 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 5df26bd8cfd..795f7ab9c04 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 78de3c9a243..75440aed819 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 74dd9452743..791eb749904 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 109/800] 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 fdcfa5b7ef0..c312919dbb7 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 77c854b419d..c9d11d871b3 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 795f7ab9c04..064d65e4c49 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 d110bc49930..00000000000 --- 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 75440aed819..329b1195609 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 00000000000..cceb58b6b3b --- /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 f10e85b470a..8fda66cb25f 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 3b6d195b76d..71a2623d6aa 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 791eb749904..4f565fa10db 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 78e518bd566..80633df94d5 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 110/800] 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 c312919dbb7..64b59492be1 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 cc45dc9107c..ff957f95300 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 8018190c8a7..ededc0f2909 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 4159cd457e5..5b3c1b9c271 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 064d65e4c49..92042295c90 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 cceb58b6b3b..c8105eafcbb 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 a307bdd7531..d5c63a816e4 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 80633df94d5..31ce564be37 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 111/800] 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 3867a1d1fb0..1d252766152 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 71debf2bd68..d39bf5a2cb2 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 64b59492be1..1e5e4b3ce3e 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 c9d11d871b3..b6d362b62c9 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 92042295c90..15a59c7345f 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 2a2fecc40c4..8829d241f16 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 46556d3d136..fd95f9f35d2 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 8fda66cb25f..73253037583 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 112/800] 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 1e5e4b3ce3e..9015cea0c56 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 5b3c1b9c271..fb940f8934e 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 85d0235d88b..07e35d4438e 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 15a59c7345f..9c196d6d306 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 c8105eafcbb..2e4f2939ca2 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 d5c63a816e4..bbb07cb414d 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 31ce564be37..04f18702e4e 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 113/800] 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 00000000000..5d0850282f3 --- /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 9c196d6d306..498d286fd45 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 49e36bdacbe..fc4196f0a08 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 48e313f5cca..b068fb4f0bf 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 a7905a6dde2..cba141f6c77 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 00000000000..6f6c8cf1b5f --- /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 00000000000..c0ef353e696 --- /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 87ecae03e2b..e7d486cd530 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 04f18702e4e..61a9f2de01f 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 114/800] 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 9015cea0c56..c5dd151af67 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 a71e21e2e73..76c17197111 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 00000000000..7e3323f86de --- /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 650bd64c550..37d3df588c3 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 476c2be5a62579d755157a27de67d217bea23593 Mon Sep 17 00:00:00 2001 From: Maximilian Walter Date: Mon, 9 Sep 2024 22:54:33 +0200 Subject: [PATCH 115/800] Add XML for OpenSearch --- app/App/MetaController.php | 10 ++++++++++ resources/views/layouts/base.blade.php | 3 +++ resources/views/misc/opensearch.blade.php | 11 +++++++++++ routes/web.php | 1 + 4 files changed, 25 insertions(+) create mode 100644 resources/views/misc/opensearch.blade.php diff --git a/app/App/MetaController.php b/app/App/MetaController.php index 1515b4f7eda..a94334c5851 100644 --- a/app/App/MetaController.php +++ b/app/App/MetaController.php @@ -64,4 +64,14 @@ public function licenses() 'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')), ]); } + + /** + * Show the view for /opensearch.xml. + */ + public function opensearch() + { + return response() + ->view('misc.opensearch') + ->header('Content-Type', 'application/opensearchdescription+xml'); + } } diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 4d4d07dc2ac..ddecb8e76e1 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -32,6 +32,9 @@ class="{{ setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '' + + + @yield('head') diff --git a/resources/views/misc/opensearch.blade.php b/resources/views/misc/opensearch.blade.php new file mode 100644 index 00000000000..cfcca8e4194 --- /dev/null +++ b/resources/views/misc/opensearch.blade.php @@ -0,0 +1,11 @@ + + {{ setting('app-name') }} + Search {{ setting('app-name') }} + {{ setting('app-icon') ?: url('/icon.png') }} + {{ setting('app-icon-180') ?: url('/icon-180.png') }} + {{ setting('app-icon-128') ?: url('/icon-128.png') }} + {{ setting('app-icon-64') ?: url('/icon-64.png') }} + {{ setting('app-icon-32') ?: url('/icon-32.png') }} + + + diff --git a/routes/web.php b/routes/web.php index 58b8f4e5431..81b938f32ec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ Route::get('/favicon.ico', [MetaController::class, 'favicon']); Route::get('/manifest.json', [MetaController::class, 'pwaManifest']); Route::get('/licenses', [MetaController::class, 'licenses']); +Route::get('/opensearch.xml', [MetaController::class, 'opensearch']); // Authenticated routes... Route::middleware('auth')->group(function () { From 20364382034c4979dc05e207242baf2871bf6283 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 10 Sep 2024 12:14:26 +0100 Subject: [PATCH 116/800] 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 fb940f8934e..8658a12167b 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 7e3323f86de..65a8e42547d 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 714d5f64b91..f1575953b51 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 e33cfda7ce6..b8bb8de9a2d 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 4f565fa10db..4aa21045f30 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 117/800] 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 ff957f95300..8a0475c7bda 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 ededc0f2909..cfe32ec854c 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 885622ad338..5df6245f5a5 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 498d286fd45..34367a36b9c 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 edec3ea0076..0857fb70a49 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 e7d486cd530..0ad638410ad 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 b8bb8de9a2d..48fbe043f39 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 118/800] 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 2b4d7414632..659a55a157a 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 953bcb8cd15..a6c47399905 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 65a8e42547d..791fb0bed3f 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 34367a36b9c..2662350afe1 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 0857fb70a49..87630eb27fd 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 00000000000..edde994e5a0 --- /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 4aa21045f30..2110ea4beb9 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 119/800] 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 791fb0bed3f..2c7bfdbbae7 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 c110a97d8a871b3f2e56af728c4460096399cbf3 Mon Sep 17 00:00:00 2001 From: Maximilian Walter Date: Sat, 14 Sep 2024 15:24:42 +0200 Subject: [PATCH 120/800] Remove unofficial method-attribute from OpenSearch-XML --- resources/views/misc/opensearch.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/misc/opensearch.blade.php b/resources/views/misc/opensearch.blade.php index cfcca8e4194..2b5a6c143f1 100644 --- a/resources/views/misc/opensearch.blade.php +++ b/resources/views/misc/opensearch.blade.php @@ -6,6 +6,6 @@ {{ setting('app-icon-128') ?: url('/icon-128.png') }} {{ setting('app-icon-64') ?: url('/icon-64.png') }} {{ setting('app-icon-32') ?: url('/icon-32.png') }} - + From 4f890c431c6fb5f83e047eebcea7f078cb1c9d5c Mon Sep 17 00:00:00 2001 From: Maximilian Walter Date: Sat, 14 Sep 2024 15:31:56 +0200 Subject: [PATCH 121/800] Limit short-name for OpenSearch XML to 16 characters The specification does not allow more than 16 characters. --- resources/views/misc/opensearch.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/misc/opensearch.blade.php b/resources/views/misc/opensearch.blade.php index 2b5a6c143f1..e83e83c12fe 100644 --- a/resources/views/misc/opensearch.blade.php +++ b/resources/views/misc/opensearch.blade.php @@ -1,5 +1,5 @@ - {{ setting('app-name') }} + {{ mb_strimwidth(setting('app-name'), 0, 16) }} Search {{ setting('app-name') }} {{ setting('app-icon') ?: url('/icon.png') }} {{ setting('app-icon-180') ?: url('/icon-180.png') }} From 5f46d71af0af216e413af8771f227f2a73deb2ec Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Sep 2024 16:10:46 +0100 Subject: [PATCH 122/800] 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 ebc142e2abc..eed1c61555f 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 c5dd151af67..c4403773bf2 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 2662350afe1..874ac537fe3 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 61a9f2de01f..dd1e1a2c368 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 ca59c85ca1a..6e6f7bb7e21 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 123/800] 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 00000000000..16befc75c2e --- /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 00000000000..5125472a007 --- /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 329b1195609..130fd6b726c 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 0ad638410ad..b064a2a9f1b 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 2110ea4beb9..f1055d98ac8 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 124/800] 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 | 7322 ++++++++++++----- 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, 56625 insertions(+), 1958 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 55cc0557b8c..3582c410221 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 0680f4ac3da..fea8c01e353 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 00000000000..0243b39cddc --- /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 1d252766152..0b6c970806c 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,1467 +31,3877 @@ }, "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/@codemirror/autocomplete": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", - "integrity": "sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==", + "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": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@codemirror/commands": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz", - "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==", + "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": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@codemirror/lang-css": { - "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.1.7" + "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/@codemirror/lang-html": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", - "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "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": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/lang-css": "^6.0.0", - "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/language": "^6.4.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/css": "^1.1.0", - "@lezer/html": "^1.3.0" + "@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/@codemirror/lang-javascript": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", - "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.6.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/javascript": "^1.0.0" + "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/@codemirror/lang-json": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", - "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", + "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": { - "@codemirror/language": "^6.0.0", - "@lezer/json": "^1.0.0" + "@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/@codemirror/lang-markdown": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz", - "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==", + "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": { - "@codemirror/autocomplete": "^6.7.1", - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.3.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.2.1", - "@lezer/markdown": "^1.0.0" + "@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/@codemirror/lang-php": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz", - "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==", + "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": { - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/php": "^1.0.0" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@codemirror/lang-xml": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", - "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "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": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.4.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0", - "@lezer/xml": "^1.0.0" + "@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/@codemirror/language": { - "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", - "@lezer/common": "^1.1.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.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/@codemirror/legacy-modes": { - "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==", + "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": { - "@codemirror/language": "^6.0.0" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@codemirror/lint": { - "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", - "crelt": "^1.0.5" + "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/@codemirror/search": { - "version": "6.5.6", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", - "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "crelt": "^1.0.5" + "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/@codemirror/state": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", - "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + "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/@codemirror/theme-one-dark": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", - "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "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": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@codemirror/view": { - "version": "6.33.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.33.0.tgz", - "integrity": "sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==", + "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": { - "@codemirror/state": "^6.4.0", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" + "@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/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], + "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": ">=12" + "node": ">=4" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], + "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": ">=12" + "node": ">=4" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" + "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/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], + "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": ">=12" + "node": ">=0.8.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], + "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": ">=12" + "node": ">=4" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], + "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": ">=12" + "node": ">=4" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", - "cpu": [ - "arm64" - ], + "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, - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=12" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", - "cpu": [ - "x64" - ], + "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, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", - "cpu": [ - "arm" - ], + "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, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", - "cpu": [ - "arm64" - ], + "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, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", - "cpu": [ - "ia32" - ], + "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, - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, "engines": { - "node": ">=12" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", - "cpu": [ - "loong64" - ], + "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, - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, "engines": { - "node": ">=12" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", - "cpu": [ - "mips64el" - ], + "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, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", - "cpu": [ - "ppc64" - ], + "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, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", - "cpu": [ - "riscv64" - ], + "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, - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, "engines": { - "node": ">=12" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", - "cpu": [ - "s390x" - ], + "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, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", - "cpu": [ - "x64" - ], + "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, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", - "cpu": [ - "x64" - ], + "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, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", - "cpu": [ - "x64" - ], + "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, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", - "cpu": [ - "x64" - ], + "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, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", - "cpu": [ - "arm64" - ], + "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, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", - "cpu": [ - "ia32" - ], + "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, - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, "engines": { - "node": ">=12" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", - "cpu": [ - "x64" - ], + "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, - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, "engines": { - "node": ">=12" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "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": { - "eslint-visitor-keys": "^3.3.0" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=6.9.0" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "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": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=6.9.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "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": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@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": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=6.9.0" } }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "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": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=4" } }, - "node_modules/@humanwhocodes/config-array": { - "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", + "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": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" }, "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=6.9.0" } }, - "node_modules/@humanwhocodes/object-schema": { - "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", + "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/@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/@codemirror/autocomplete": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", + "integrity": "sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==", "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" + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" } }, - "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/@codemirror/commands": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz", + "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" } }, - "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/@codemirror/lang-css": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", + "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", "dependencies": { - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" } }, - "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/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" } }, - "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/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" } }, - "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/@codemirror/lang-json": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", + "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", "dependencies": { - "@lexical/clipboard": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" } }, - "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/@codemirror/lang-markdown": { + "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": { - "lexical": "0.17.1" + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" } }, - "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==", + "node_modules/@codemirror/lang-php": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz", + "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" } }, - "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/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", "dependencies": { - "@lexical/list": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/table": "0.17.1", - "lexical": "0.17.1" + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" } }, - "node_modules/@lezer/common": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", - "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" - }, - "node_modules/@lezer/css": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz", - "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==", + "node_modules/@codemirror/language": { + "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": { - "@lezer/common": "^1.2.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" } }, - "node_modules/@lezer/generator": { - "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, + "node_modules/@codemirror/legacy-modes": { + "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" + } + }, + "node_modules/@codemirror/lint": { + "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", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", + "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "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", + "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", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "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" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "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", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "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/@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": { + "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/@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": { + "sprintf-js": "~1.0.2" + } + }, + "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": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/@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": { + "@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": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "node_modules/@lezer/css": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz", + "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/generator": { + "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", "@lezer/lr": "^1.3.0" }, "bin": { - "lezer-generator": "src/lezer-generator.cjs" + "lezer-generator": "src/lezer-generator.cjs" + } + }, + "node_modules/@lezer/highlight": { + "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" + } + }, + "node_modules/@lezer/html": { + "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", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "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", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", + "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "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.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" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz", + "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.5.tgz", + "integrity": "sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "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/@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", + "integrity": "sha512-F0ut1kmdbT3eORk3xVIKfQsGCZiQdh+6sLayBa0+FTex2gyIQlVQZRRA7bPSlchI3uZtWwNnqGNz5O/QLWRlFg==" + }, + "node_modules/@ssddanbrown/codemirror-lang-twig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-twig/-/codemirror-lang-twig-1.0.0.tgz", + "integrity": "sha512-7WIMIh8Ssc54TooGCY57WU2rKEqZZrcV2tZSVRPtd0gKYsrDEKCSLWpQjUWEx7bdgh3NKHUjq1O4ugIzI/+dwQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/highlight": "^1.0.0", + "@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", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "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", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "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", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "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", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "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", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "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", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "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==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "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", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar-cli": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz", + "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1", + "yargs": "^13.3.0" + }, + "bin": { + "chokidar": "index.js" + }, + "engines": { + "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", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "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", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "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==" + }, + "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", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "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/@lezer/highlight": { + "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", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "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", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "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.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "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", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "dependencies": { - "@lezer/common": "^1.0.0" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@lezer/html": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", - "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "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", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@lezer/javascript": { - "version": "1.4.17", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz", - "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==", + "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": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.0" + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" } }, - "node_modules/@lezer/json": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", - "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "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": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/@lezer/lr": { - "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/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/@lezer/markdown": { - "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" + "node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/@lezer/php": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz", - "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==", + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.1.0" + "is-arrayish": "^0.2.1" } }, - "node_modules/@lezer/xml": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.5.tgz", - "integrity": "sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==", + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", "dev": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "get-intrinsic": "^1.2.4" }, "engines": { - "node": ">= 8" + "node": ">= 0.4" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "engines": { - "node": ">= 8" + "node": ">= 0.4" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", "dev": true, "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "es-errors": "^1.3.0" }, "engines": { - "node": ">= 8" + "node": ">= 0.4" } }, - "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", - "integrity": "sha512-F0ut1kmdbT3eORk3xVIKfQsGCZiQdh+6sLayBa0+FTex2gyIQlVQZRRA7bPSlchI3uZtWwNnqGNz5O/QLWRlFg==" - }, - "node_modules/@ssddanbrown/codemirror-lang-twig": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-twig/-/codemirror-lang-twig-1.0.0.tgz", - "integrity": "sha512-7WIMIh8Ssc54TooGCY57WU2rKEqZZrcV2tZSVRPtd0gKYsrDEKCSLWpQjUWEx7bdgh3NKHUjq1O4ugIzI/+dwQ==", + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, "dependencies": { - "@codemirror/language": "^6.0.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" } }, - "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/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } }, - "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/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dev": true, + "hasInstallScript": true, "bin": { - "acorn": "bin/acorn" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=0.4.0" + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "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, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "engines": { + "node": ">=6" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "engines": { + "node": ">=10" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "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", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "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": ">=8" + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/eslint" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/eslint-config-airbnb-base": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", + "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", "dev": true, "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "confusing-browser-globals": "^1.0.10", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5", + "semver": "^6.3.0" }, "engines": { - "node": ">= 8" + "node": "^10.12.0 || >=12.0.0" + }, + "peerDependencies": { + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.2" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "ms": "^2.1.1" } }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "node_modules/eslint-module-utils": { + "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": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "debug": "^3.2.7" }, "engines": { - "node": ">= 0.4" + "node": ">=4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "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": { + "@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.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.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" }, "engines": { - "node": ">= 0.4" + "node": ">=4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "dependencies": { - "possible-typed-array-names": "^1.0.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10.13.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "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, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "dependencies": { - "fill-range": "^7.1.1" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">=0.10" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "engines": { - "node": ">=6" + "node": ">=4.0" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "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/chalk/chalk?sponsor=1" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "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, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": ">= 0.8.0" } }, - "node_modules/chokidar-cli": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz", - "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==", - "dev": true, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dependencies": { - "chokidar": "^3.5.2", - "lodash.debounce": "^4.0.8", - "lodash.throttle": "^4.1.1", - "yargs": "^13.3.0" - }, - "bin": { - "chokidar": "index.js" + "@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": ">= 8.10.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "dev": true, - "dependencies": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.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", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "engines": { - "node": ">=6" - } + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, - "node_modules/codemirror": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", - "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" + "reusify": "^1.0.4" } }, - "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==", + "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", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "flat-cache": "^3.0.4" }, "engines": { - "node": ">=7.0.0" + "node": "^10.12.0 || >=12.0.0" } }, - "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 - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true + "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/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + "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/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "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": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 8" + "node": ">=10" } }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, + "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==", "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", "dev": true, "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "is-callable": "^1.1.3" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "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": ">=0.10.0" + "node": ">= 6" } }, - "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==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -1508,96 +3910,44 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "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": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=6.9.0" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" + "engines": { + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "hasown": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -1606,537 +3956,541 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "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, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { - "node": ">= 0.4" + "node": ">=8.0.0" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "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": ">= 0.4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, "dependencies": { - "es-errors": "^1.3.0" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "node_modules/glob": { + "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": { - "get-intrinsic": "^1.2.4", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">= 0.4" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { - "hasown": "^2.0.0" + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "type-fest": "^0.20.2" }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "node_modules/globalthis": { + "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, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", "dev": true, - "engines": { - "node": ">=10" + "dependencies": { + "get-intrinsic": "^1.1.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "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==" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" + "es-define-property": "^1.0.0" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-config-airbnb-base": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", - "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "dev": true, - "dependencies": { - "confusing-browser-globals": "^1.0.10", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5", - "semver": "^6.3.0" - }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 0.4" }, - "peerDependencies": { - "eslint": "^7.32.0 || ^8.2.0", - "eslint-plugin-import": "^2.25.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { - "ms": "^2.1.1" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/eslint-module-utils": { - "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==", + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "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": { - "debug": "^3.2.7" + "whatwg-encoding": "^2.0.0" }, "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "node": ">=12" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "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": { - "ms": "^2.1.1" + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/eslint-plugin-import": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", - "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", + "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": { - "@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.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.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.0", - "semver": "^6.3.1", - "tsconfig-paths": "^3.15.0" + "agent-base": "6", + "debug": "4" }, "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "node": ">= 6" } }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "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, - "dependencies": { - "ms": "^2.1.1" + "engines": { + "node": ">=10.17.0" } }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "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": { - "esutils": "^2.0.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/idb-keyval": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, + "node_modules/ignore": { + "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.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=6" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "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": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, "engines": { - "node": ">=10.13.0" + "node": ">=0.8.19" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/inflight": { + "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": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 0.4" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "dependencies": { - "estraverse": "^5.1.0" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" }, "engines": { - "node": ">=0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", "dev": true, "dependencies": { - "estraverse": "^5.2.0" + "has-bigints": "^1.0.1" }, - "engines": { - "node": ">=4.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, "engines": { - "node": ">=4.0" + "node": ">=8" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, - "dependencies": { - "reusify": "^1.0.4" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "node_modules/is-core-module": { + "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": { - "flat-cache": "^3.0.4" + "hasown": "^2.0.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", "dev": true, "dependencies": { - "to-regex-range": "^5.0.1" + "is-typed-array": "^1.1.13" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", "dev": true, "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=0.10.0" } }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", "dev": true, - "dependencies": { - "is-callable": "^1.1.3" + "engines": { + "node": ">=4" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=6" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, "engines": { "node": ">= 0.4" }, @@ -2144,35 +4498,52 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=8" } }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "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", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dev": true, "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -2181,15 +4552,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dev": true, "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "call-bind": "^1.0.7" }, "engines": { "node": ">= 0.4" @@ -2198,62 +4567,55 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "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", + "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, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, "engines": { - "node": "*" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", "dev": true, "dependencies": { - "is-glob": "^4.0.1" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", "dev": true, "dependencies": { - "type-fest": "^0.20.2" + "has-symbols": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -2262,486 +4624,842 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", "dev": true, "dependencies": { - "get-intrinsic": "^1.1.3" + "call-bind": "^1.0.2" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "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, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=8" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "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": ">=8" + "node": ">=10" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "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": { - "es-define-property": "^1.0.0" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=10" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "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": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "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": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "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/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "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": { - "has-symbols": "^1.0.3" + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" }, "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "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/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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": { - "function-bind": "^1.1.2" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "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/idb-keyval": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", - "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "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": ">= 4" + "node": ">=8" } }, - "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true + "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/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "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": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "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": ">=0.8.19" + "node": ">=10" } }, - "node_modules/inflight": { - "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.", + "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": { - "once": "^1.3.0", - "wrappy": "1" + "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/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "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": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "@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": ">= 0.4" + "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/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "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": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "@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": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, + "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": { - "has-bigints": "^1.0.1" + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "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": { - "binary-extensions": "^2.0.0" + "detect-newline": "^3.0.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "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": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "@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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "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": ">= 0.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "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": { - "hasown": "^2.0.2" + "@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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "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": { - "is-typed-array": "^1.1.13" + "@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": ">= 0.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "fsevents": "^2.3.2" } }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "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": { - "has-tostringtag": "^1.0.0" + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "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": ">=0.10.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, + "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": ">=4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "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": { - "is-extglob": "^2.1.1" + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" }, "engines": { - "node": ">=0.10.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "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": ">= 0.4" + "node": ">=6" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "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": ">=0.12.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "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": { - "has-tostringtag": "^1.0.0" + "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": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "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/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "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": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "@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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "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, - "dependencies": { - "call-bind": "^1.0.7" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "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": { - "has-tostringtag": "^1.0.0" + "@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": ">= 0.4" + "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/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "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": { - "has-symbols": "^1.0.2" + "@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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "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": { - "which-typed-array": "^1.1.14" + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "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": { - "call-bind": "^1.0.2" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "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", @@ -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 d39bf5a2cb2..163df34ed22 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 e08b90ba1e4..7f4bbe54d63 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 1f216b7a53c..0d7efc4d43c 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 00000000000..b93be90515c --- /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 00000000000..1d79c2d7ba4 --- /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 00000000000..ffa1f19f6ac --- /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 00000000000..0f1c0a5d31e --- /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 00000000000..82461e74d95 --- /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 00000000000..b0b90002eaa --- /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 00000000000..f84d2e40a8c --- /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 00000000000..5fd671a76c5 --- /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 00000000000..9405ae6cfb8 --- /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 00000000000..56f364501ee --- /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 00000000000..c6bc2e642ee --- /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 00000000000..59a7be64489 --- /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 00000000000..0162d2281f9 --- /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 00000000000..db18cfc4a77 --- /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 00000000000..86ed2740fcd --- /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 00000000000..71096b19dce --- /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 00000000000..5d6a9311bf7 --- /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 00000000000..b146548383c --- /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 00000000000..4ca6b77c814 --- /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 00000000000..09b49b738d7 --- /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 00000000000..a2968c2592b --- /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 00000000000..7373f898d80 --- /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 00000000000..ecfbe6bf77f --- /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 00000000000..7055f361a96 --- /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 00000000000..9237bc9d3dd --- /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 00000000000..0026cf5d6ad --- /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 00000000000..b7ccfab1ebd --- /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 00000000000..5ef926b5afc --- /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 00000000000..0f01d2c3493 --- /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 00000000000..99d2669d92f --- /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 00000000000..88c6d56780f --- /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 00000000000..2d28db08c12 --- /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 00000000000..deab3a2cc13 --- /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 00000000000..74c8d5a7f9b --- /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 00000000000..5fa3623d41d --- /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 00000000000..43bef7e83c0 --- /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 00000000000..e165df7a9ce --- /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 00000000000..2c7e978a1fc --- /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 00000000000..110086ac816 --- /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 00000000000..1f7c4cfc3a7 --- /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 00000000000..123cb3375d6 --- /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 00000000000..0c06273eca1 --- /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 00000000000..7fc509dfd13 --- /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 00000000000..ff3b7cbf194 --- /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 00000000000..78db6aafd4c --- /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 00000000000..642e070e1c5 --- /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 00000000000..c05d332217f --- /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 00000000000..0e73848bac1 --- /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 00000000000..22ea3a940bf --- /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 00000000000..8e086744d5e --- /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 00000000000..9685cd89e95 --- /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 00000000000..39f3d3b339d --- /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 00000000000..6149879cbb5 --- /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 00000000000..d29e99e02bc --- /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 00000000000..afa65708d4b --- /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 00000000000..2b8eddb8ed3 --- /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 00000000000..8c731d3aaf4 --- /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 00000000000..55d120bdd1b --- /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 00000000000..2975315cc35 --- /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 00000000000..8ef2aa0519f --- /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 00000000000..3ad6cbad8b8 --- /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 00000000000..fe2b9757048 --- /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 00000000000..7d12b5bd38a --- /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 00000000000..e22fbf7717f --- /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 00000000000..d36b8f1cbd5 --- /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 00000000000..6abcbbd4cb7 --- /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 00000000000..1fa327379b9 --- /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 00000000000..aa95a7a01b0 --- /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 00000000000..b9ca011696a --- /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 00000000000..157fe79de1d --- /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 00000000000..c451a45085d --- /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 00000000000..31db8fab1ca --- /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 00000000000..057999ba031 --- /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 00000000000..e64c418803e --- /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 00000000000..fd9162566a4 --- /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 00000000000..e6086783159 --- /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 00000000000..01390ed7180 --- /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 00000000000..84c82edecfa --- /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 00000000000..104f57df530 --- /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 00000000000..b2d18b1645a --- /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 00000000000..82f7d330ed1 --- /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 00000000000..dbadaf346b6 --- /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 00000000000..0608706eab7 --- /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 00000000000..455d39bf6c9 --- /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 00000000000..8fb542383d2 --- /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 00000000000..3e695eaa475 --- /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 00000000000..0d40d0699e2 --- /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 00000000000..eddea69a27e --- /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 00000000000..4564ace7fb7 --- /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 00000000000..812cccc0d25 --- /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 00000000000..cdbc846584d --- /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 00000000000..9c56db63bf3 --- /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 00000000000..b11b99490b6 --- /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 00000000000..cf110634b5b --- /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 00000000000..5eb631c31f7 --- /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 00000000000..ffa6ba1c35b --- /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 00000000000..2429eb608a9 --- /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 00000000000..0bca8a9ea64 --- /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 00000000000..2b49e3bd7b8 --- /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 00000000000..82d2dddf88d --- /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 00000000000..070107583b5 --- /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 00000000000..b4b18ef017f --- /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 00000000000..f3db39390d6 --- /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 00000000000..0e46573e7f6 --- /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 00000000000..01228f629ef --- /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 00000000000..7984126e394 --- /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 00000000000..b1359c6dfb3 --- /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 00000000000..0d1a19255e4 --- /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 00000000000..468d25c0825 --- /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 00000000000..c306cc7d685 --- /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 00000000000..4d3ac01f463 --- /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 00000000000..3578ed7f504 --- /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 00000000000..b3866043d21 --- /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 00000000000..6d1267f8ed9 --- /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 00000000000..86caf91f2c9 --- /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 00000000000..721fbb68f44 --- /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 00000000000..c2dd077481e --- /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 00000000000..c0e6bc96d0b --- /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 00000000000..248e344268b --- /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 00000000000..d8807a288ed --- /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 0be5421c7c8..4026872ac9a 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 125/800] 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 0243b39cddc..11a86c672ce 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 b0b90002eaa..092429156be 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 5d6a9311bf7..00000000000 --- 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 b146548383c..056de19e49e 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 4ca6b77c814..4e3e622ced9 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'; - - 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); - }, []); + let edContainer: HTMLElement = document.createElement('div'); + edContainer.setAttribute('contenteditable', 'true'); + setContainerChild(edContainer); + editorInstance.setRootElement(edContainer); - const ref = useCallback((node: HTMLElement | null) => { - editor.setRootElement(node); - }, []); + function runUpdate(changeElement: boolean) { + editorInstance.update(() => { + const root = $getRoot(); + const firstChild = root.getFirstChild() as ParagraphNode | null; + const text = changeElement ? 'Change successful' : 'Not changed'; - 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 a2968c2592b..5493b696257 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 b7ccfab1ebd..090ace10d57 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; - }, []); - - 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} - - ); - }; + const editorEl = document.createElement('div'); + editorEl.contentEditable = 'true'; + testEnv.container.append(editorEl); - 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 8ef2aa0519f..ffcefd7c8d9 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 3ad6cbad8b8..fe978849b15 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 d36b8f1cbd5..a1ccd50207c 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 6abcbbd4cb7..497e096b1c2 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 1fa327379b9..ba397128916 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 057999ba031..dcbd62ab3c1 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 e64c418803e..66374bf5ff0 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 e6086783159..68e9dcab555 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 01390ed7180..7b5bef4518b 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 9c56db63bf3..70b3278667b 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 b11b99490b6..37049e598d6 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 cf110634b5b..285d587bf5f 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 5eb631c31f7..9e9dbac8131 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 2b49e3bd7b8..2d5db2c6913 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 82d2dddf88d..1d994e14020 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 070107583b5..369caaea4af 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 b4b18ef017f..a62b7bae176 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 f3db39390d6..f04bb5d2e46 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 0e46573e7f6..9664b2d80ca 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 126/800] 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 5493b696257..00000000000 --- 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 9237bc9d3dd..02231f8bf0d 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 090ace10d57..30b0bc8c818 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 e165df7a9ce..fb5c98f8a6c 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 0c06273eca1..2d751f5fd7b 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 7fc509dfd13..337c96a4163 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(); - - function TestBase() { - editor = useLexicalEditor(ref); + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container.innerHTML = ''; + container.appendChild(root); - 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 55d120bdd1b..3dbe5da8b3e 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 127/800] 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} | 1032 ++++++----------- ...Node.test.tsx => LexicalTableNode.test.ts} | 206 ---- ...test.tsx => LexicalTableSelection.test.ts} | 63 +- ...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, 558 insertions(+), 1224 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 11a86c672ce..3c04f05b218 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 30b0bc8c818..f7230595a43 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 337c96a4163..57e1dcb3baf 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 8e086744d5e..00000000000 --- 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 ffcefd7c8d9..0f35136821b 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 fe978849b15..1aff9186306 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 a1ccd50207c..22e555f35b5 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 aa95a7a01b0..00000000000 --- 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 68e9dcab555..665f5d854e2 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} - /> - - - - - ); - } + mergeRegister( + registerHistory(editor, createEmptyHistoryState(), 300), + registerRichText(editor), + ); - await ReactTestUtils.act(async () => { - reactRoot.render(); - await Promise.resolve().then(); + editor.setRootElement(root); + editor.update(() => { + const p = $createParagraphNode(); + $insertNodes([p]); }); + editor.commitUpdates(); + editor.focus(); - await Promise.resolve().then(); // 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(); - - 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); - } - }); + 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); + } }); }); }, @@ -1903,162 +1577,154 @@ 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(); + await editor!.update(() => { + const root = $getRoot(); - const listNode = $createListNode('bullet'); - const listItemNode = $createListItemNode(); - const paragraph = $createParagraphNode(); + const listNode = $createListNode('bullet'); + const listItemNode = $createListItemNode(); + const paragraph = $createParagraphNode(); - root.append(listNode); + root.append(listNode); - listNode.append(listItemNode); - listItemNode.select(); - listNode.insertAfter(paragraph); - listItemNode.remove(); + listNode.append(listItemNode); + listItemNode.select(); + listNode.insertAfter(paragraph); + listItemNode.remove(); - const selection = $getSelection(); + const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } + if (!$isRangeSelection(selection)) { + return; + } - expect(selection.anchor.getNode().__type).toBe('paragraph'); - expect(selection.focus.getNode().__type).toBe('paragraph'); - }); + 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); - }); + 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(); + await editor!.update(() => { + // Arrange + // Root + // |- Paragraph + // |- Link + // |- Text + // |- LineBreak + // |- Text + // |- Text + const root = $getRoot(); - const selection = $getSelection(); + const paragraph = $createParagraphNode(); + const link = $createLinkNode('bullet'); + const textOne = $createTextNode('Hello'); + const br = $createLineBreakNode(); + const textTwo = $createTextNode('world'); + const textThree = $createTextNode(' '); - if (!$isRangeSelection(selection)) { - return; - } + root.append(paragraph); + link.append(textOne); + link.append(br); + link.append(textTwo); - const {anchor, focus} = selection; + paragraph.append(link); + paragraph.append(textThree); - expect(anchor.getNode().getKey()).toBe(expectedKey); - expect(focus.getNode().getKey()).toBe(expectedKey); - expect(anchor.offset).toBe(3); - expect(focus.offset).toBe(3); - }); + 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(); + 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 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(); + const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } + 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'); + 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); + expect(selection.isBackward()).toBe(false); - anchor.set(paragraphKey, 1, 'element'); - focus.set(paragraphKey, 1, 'element'); + anchor.set(paragraphKey, 1, 'element'); + focus.set(paragraphKey, 1, 'element'); - expect(selection.isBackward()).toBe(false); + expect(selection.isBackward()).toBe(false); - anchor.set(paragraphKey, 0, 'element'); - focus.set(paragraphKey, 1, 'element'); + anchor.set(paragraphKey, 0, 'element'); + focus.set(paragraphKey, 1, 'element'); - expect(selection.isBackward()).toBe(false); + expect(selection.isBackward()).toBe(false); - anchor.set(paragraphKey, 1, 'element'); - focus.set(paragraphKey, 0, 'element'); + anchor.set(paragraphKey, 1, 'element'); + focus.set(paragraphKey, 0, 'element'); - expect(selection.isBackward()).toBe(true); - }); + expect(selection.isBackward()).toBe(true); }); }); @@ -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; - } - - const expectedTextContent = fn({ - anchor: invertSelection ? selection.focus : selection.anchor, - decorator, - focus: invertSelection ? selection.anchor : selection.focus, - paragraph, - textNode1, - textNode2, - }); + if (!$isRangeSelection(selection)) { + return; + } - 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', - }); + $setAnchorPoint({ + key: text1.getKey(), + offset: 2, + type: 'text', + }); - $setFocusPoint({ - key: text3.getKey(), - offset: 0, - type: 'text', - }); + $setFocusPoint({ + key: text3.getKey(), + offset: 0, + type: 'text', + }); - const selection = $getSelection(); + const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } + if (!$isRangeSelection(selection)) { + return; + } - selection.formatText('bold'); + selection.formatText('bold'); - expect(text2.hasFormat('bold')).toBe(true); - }); + 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); - - const selection = $createRangeSelection(); - $setSelection(selection); - selection.insertParagraph(); - $setAnchorPoint({ - key: textNode.getKey(), - offset: 0, - type: 'text', - }); + 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); - $setFocusPoint({ - key: textNode.getKey(), - offset: 10, - type: 'text', - }); + const selection = $createRangeSelection(); + $setSelection(selection); + selection.insertParagraph(); + $setAnchorPoint({ + key: textNode.getKey(), + offset: 0, + type: 'text', + }); - const newStyle = { - color: 'blue', - 'font-family': 'Arial', - }; + $setFocusPoint({ + key: textNode.getKey(), + offset: 10, + type: 'text', + }); + + 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'; - - 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', - }); + await editor!.update(() => { + const currentColor = 'red'; + const nextColor = 'blue'; - $setFocusPoint({ - key: textNode.getKey(), - offset: 10, - type: 'text', - }); + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const textNode = $createTextNode('Hello, World!'); + textNode.setStyle(`color: ${currentColor};`); + $addNodeStyle(textNode); + paragraph.append(textNode); + root.append(paragraph); - const newStyle = { - color: jest.fn( + 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, - ), - }; + 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 37049e598d6..6ce133d184e 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 9e9dbac8131..35ee65b688c 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(); - - function TestBase() { - editor = useLexicalEditor(ref, onError); - - return
                                      ; - } - - ReactTestUtils.act(() => { - reactRoot.render(); - }); + editor = createTestEditor({ + nodes: [], + onError: onError || jest.fn(), + theme: { + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + underline: 'editor-text-underline', + }, + }, + }) + + 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 2d5db2c6913..53c743df637 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', + }, + }, + }; + + editor = createTestEditor(config); + registerRichText(editor); + + const root = document.createElement('div'); + root.setAttribute('contenteditable', 'true'); + container?.append(root); - return ( - - - } - placeholder={null} - ErrorBoundary={LexicalErrorBoundary} - /> - - - - ); - } + editor.setRootElement(root); - ReactTestUtils.act(() => { - createRoot(container!).render(); + 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 369caaea4af..1322b482be9 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 128/800] 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 0162d2281f9..09d01bffd43 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 056de19e49e..9f832b69e40 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 4e3e622ced9..f3c6f710509 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 09b49b738d7..38ecf03bce2 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 7373f898d80..fcf666213d6 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 7055f361a96..ac0ec15e576 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 02231f8bf0d..5599604c059 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 2d751f5fd7b..a57ff3f42f6 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 9685cd89e95..00000000000 --- 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 6149879cbb5..00000000000 --- 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 22e555f35b5..581db0294f5 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 dcbd62ab3c1..a94f9ee0bb8 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 665f5d854e2..5f2d9dcc093 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 7b5bef4518b..4d88bde0e4c 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 6ce133d184e..abc50962949 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 35ee65b688c..d5b85ccaaa0 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 53c743df637..7655b454053 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 8a0475c7bda..36e692f255b 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 874ac537fe3..e07792a1c06 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 130fd6b726c..b1c701dda4d 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 87630eb27fd..9cfa8168ecf 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 4026872ac9a..8bffc25f898 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 129/800] 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 e07792a1c06..7fcf2544455 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 b1c701dda4d..6233e6ce64c 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 dd1e1a2c368..91aef992086 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 ee5a2e19b11..d927a03d3fb 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 130/800] 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 deab3a2cc13..4e69dc21c3c 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 fd9162566a4..d937060c658 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 36e692f255b..71849bb4589 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 a6c47399905..4b05fa62e25 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 119619da6ad..de87696f345 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 7fcf2544455..66878f37db0 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 131/800] 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 eed1c61555f..56dbe8d7c5f 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 6233e6ce64c..f0f46ddc674 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 80e49348616..f86e33c311e 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 8829d241f16..77223dac3f7 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 a967ecb2fc0..c3726acf001 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 44d4e036094..c4392377828 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 a2db34dd7a1..615d5b4ded0 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 71a2623d6aa..8bfdb8965d2 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 d927a03d3fb..73e6557527f 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -9,7 +9,7 @@ class="flex-container-column flex-fill flex">
                                                              -
                                                              +{{--
                                                              --}}
                                                              @@ -18,4 +18,4 @@ class="flex-container-column flex-fill flex">
                                                              {{ $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 132/800] 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 b22c467318e..8bf26fbb51c 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 7d12b5bd38a..5026a01293e 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 66878f37db0..91e1a967853 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 f1575953b51..228566d442e 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 615d5b4ded0..36371e30238 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 ae69302f6d0..3eea62ebb94 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 133/800] 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 3a433338bfe..499ef4d7288 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 2526b6c445d..0d9541c52e5 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -12,6 +12,7 @@ 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 @@ protected function updateTemplateStatusAndContentFromInput(Page $page, array $in } $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 @@ protected function updateTemplateStatusAndContentFromInput(Page $page, array $in 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 f0bd235897d..e4fe2fd25bb 100644 --- a/app/Entities/Tools/PageEditorData.php +++ b/app/Entities/Tools/PageEditorData.php @@ -74,17 +74,17 @@ protected function build(): array ]; } - 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 @@ protected function updateContentForEditor(Page $page, string $editorType): void * 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 00000000000..1c1d430e4a2 --- /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 9e620b24ed1..35e6f050bb8 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -224,6 +224,8 @@ '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 91e1a967853..31e3533b1e0 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 d25f6a0a402..341fbf67d6c 100644 --- a/resources/views/pages/parts/editor-toolbar.blade.php +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -55,7 +55,7 @@ class="text-link icon-list-item">
                                                            • - @if($editor === 'wysiwyg') + @if($editor !== \BookStack\Entities\Tools\PageEditorType::Markdown) @icon('swap-horizontal')
                                                              @@ -72,12 +72,23 @@ class="text-link icon-list-item"> {{ 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 490374e40c4..e1104b40642 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 4845e2055fc..70a490298c7 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 b2fb8595543..9340249568c 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -4,6 +4,7 @@ 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 @@ public function test_default_editor_is_wysiwyg_for_new_pages() 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 @@ public function test_markdown_content_given_to_editor() { $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 @@ public function test_switching_from_markdown_to_wysiwyg_works() $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 @@ public function test_switching_from_markdown_to_wysiwyg_works() 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 @@ public function test_page_editor_changes_with_editor_property() $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 @@ public function test_editor_type_switch_options_show() $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 @@ public function test_page_editor_type_switch_does_not_work_without_change_editor $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 @@ public function test_page_save_does_not_change_active_editor_without_change_edit $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 134/800] 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 9f832b69e40..534663a54b4 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 43bef7e83c0..4a3a489504e 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 a57ff3f42f6..d8525fb369f 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 57e1dcb3baf..b1ea099ac1e 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 afa65708d4b..c4dedd47d13 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 3dbe5da8b3e..947e591b4ff 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 abc50962949..6848e55325d 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 7655b454053..fd7731f9061 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 f04bb5d2e46..a70200d6349 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 9664b2d80ca..fb04e628413 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 31e3533b1e0..bcd4851e805 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 73253037583..7c0975da7e7 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 8bfdb8965d2..3811f44b9bf 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 b064a2a9f1b..35146e5a440 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 91aef992086..b33cb4d0558 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 6e6f7bb7e21..426f7961c72 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 135/800] 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 0391ce5b5c6..9aceea2a26c 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 00000000000..13f9a8a9819 --- /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 11bf2c8886f..29983eafff0 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 1eb293523b6..09126ddadb6 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
                                  From 56b9107c6b47dd84aa91f4f1660ab9ecaf695fd8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Sep 2024 12:29:19 +0100 Subject: [PATCH 136/800] Dependancies: Updated php & JS deps, updated license lists Fixed issue now picked up by newer TS version --- composer.lock | 574 +++--- dev/licensing/js-library-licenses.txt | 1745 ++++++++++++++++- dev/licensing/php-library-licenses.txt | 9 +- package-lock.json | 769 ++++++-- package.json | 3 +- .../wysiwyg/lexical/selection/lexical-node.ts | 2 +- version | 2 +- 7 files changed, 2577 insertions(+), 527 deletions(-) diff --git a/composer.lock b/composer.lock index c5649d7c8d7..1abbf7e9483 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.320.7", + "version": "3.322.6", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "702b9955160d2dacdf2cdf4d4476fcf95eae1aaf" + "reference": "ae7b0edab466c3440fe007c07cb62ae32a4dbfca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/702b9955160d2dacdf2cdf4d4476fcf95eae1aaf", - "reference": "702b9955160d2dacdf2cdf4d4476fcf95eae1aaf", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ae7b0edab466c3440fe007c07cb62ae32a4dbfca", + "reference": "ae7b0edab466c3440fe007c07cb62ae32a4dbfca", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.320.7" + "source": "https://github.com/aws/aws-sdk-php/tree/3.322.6" }, - "time": "2024-08-23T18:13:50+00:00" + "time": "2024-09-26T18:12:45+00:00" }, { "name": "bacon/bacon-qr-code", @@ -561,16 +561,16 @@ }, { "name": "doctrine/dbal", - "version": "3.9.0", + "version": "3.9.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6" + "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/d8f68ea6cc00912e5313237130b8c8decf4d28c6", - "reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", + "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", "shasum": "" }, "require": { @@ -586,7 +586,7 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.11.7", + "phpstan/phpstan": "1.12.0", "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "9.6.20", "psalm/plugin-phpunit": "0.18.4", @@ -654,7 +654,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.0" + "source": "https://github.com/doctrine/dbal/tree/3.9.1" }, "funding": [ { @@ -670,7 +670,7 @@ "type": "tidelift" } ], - "time": "2024-08-15T07:34:42+00:00" + "time": "2024-09-01T13:49:23+00:00" }, { "name": "doctrine/deprecations", @@ -1777,16 +1777,16 @@ }, { "name": "intervention/gif", - "version": "4.1.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/Intervention/gif.git", - "reference": "3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3" + "reference": "42c131a31b93c440ad49061b599fa218f06f93be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/gif/zipball/3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3", - "reference": "3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3", + "url": "https://api.github.com/repos/Intervention/gif/zipball/42c131a31b93c440ad49061b599fa218f06f93be", + "reference": "42c131a31b93c440ad49061b599fa218f06f93be", "shasum": "" }, "require": { @@ -1825,7 +1825,7 @@ ], "support": { "issues": "https://github.com/Intervention/gif/issues", - "source": "https://github.com/Intervention/gif/tree/4.1.0" + "source": "https://github.com/Intervention/gif/tree/4.2.0" }, "funding": [ { @@ -1835,9 +1835,13 @@ { "url": "https://github.com/Intervention", "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" } ], - "time": "2024-03-26T17:23:47+00:00" + "time": "2024-09-20T13:35:02+00:00" }, { "name": "intervention/image", @@ -1984,16 +1988,16 @@ }, { "name": "laravel/framework", - "version": "v10.48.20", + "version": "v10.48.22", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "be2be342d4c74db6a8d2bd18469cd6d488ab9c98" + "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/be2be342d4c74db6a8d2bd18469cd6d488ab9c98", - "reference": "be2be342d4c74db6a8d2bd18469cd6d488ab9c98", + "url": "https://api.github.com/repos/laravel/framework/zipball/c4ea52bb044faef4a103d7dd81746c01b2ec860e", + "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e", "shasum": "" }, "require": { @@ -2187,7 +2191,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-08-09T07:55:45+00:00" + "time": "2024-09-12T15:00:09+00:00" }, { "name": "laravel/prompts", @@ -2249,16 +2253,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v1.3.4", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "61b87392d986dc49ad5ef64e75b1ff5fee24ef81" + "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/61b87392d986dc49ad5ef64e75b1ff5fee24ef81", - "reference": "61b87392d986dc49ad5ef64e75b1ff5fee24ef81", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c", + "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c", "shasum": "" }, "require": { @@ -2306,20 +2310,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-08-02T07:48:17+00:00" + "time": "2024-09-23T13:33:08+00:00" }, { "name": "laravel/socialite", - "version": "v5.15.1", + "version": "v5.16.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "cc02625f0bd1f95dc3688eb041cce0f1e709d029" + "reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/cc02625f0bd1f95dc3688eb041cce0f1e709d029", - "reference": "cc02625f0bd1f95dc3688eb041cce0f1e709d029", + "url": "https://api.github.com/repos/laravel/socialite/zipball/40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf", + "reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf", "shasum": "" }, "require": { @@ -2378,20 +2382,20 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2024-06-28T20:09:34+00:00" + "time": "2024-09-03T09:46:57+00:00" }, { "name": "laravel/tinker", - "version": "v2.9.0", + "version": "v2.10.0", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "502e0fe3f0415d06d5db1f83a472f0f3b754bafe" + "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/502e0fe3f0415d06d5db1f83a472f0f3b754bafe", - "reference": "502e0fe3f0415d06d5db1f83a472f0f3b754bafe", + "url": "https://api.github.com/repos/laravel/tinker/zipball/ba4d51eb56de7711b3a37d63aa0643e99a339ae5", + "reference": "ba4d51eb56de7711b3a37d63aa0643e99a339ae5", "shasum": "" }, "require": { @@ -2442,9 +2446,9 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.9.0" + "source": "https://github.com/laravel/tinker/tree/v2.10.0" }, - "time": "2024-01-04T16:10:04+00:00" + "time": "2024-09-23T13:32:56+00:00" }, { "name": "league/commonmark", @@ -2912,16 +2916,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.15.0", + "version": "1.16.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301" + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", "shasum": "" }, "require": { @@ -2952,7 +2956,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.15.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" }, "funding": [ { @@ -2964,7 +2968,7 @@ "type": "tidelift" } ], - "time": "2024-01-28T23:22:08+00:00" + "time": "2024-09-21T08:32:55+00:00" }, { "name": "league/oauth1-client", @@ -3282,16 +3286,16 @@ }, { "name": "mtdowling/jmespath.php", - "version": "2.7.0", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", - "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", "shasum": "" }, "require": { @@ -3308,7 +3312,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.7-dev" + "dev-master": "2.8-dev" } }, "autoload": { @@ -3342,9 +3346,9 @@ ], "support": { "issues": "https://github.com/jmespath/jmespath.php/issues", - "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" }, - "time": "2023-08-25T10:54:48+00:00" + "time": "2024-09-04T18:46:31+00:00" }, { "name": "nesbot/carbon", @@ -3603,16 +3607,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.1.0", + "version": "v5.2.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1" + "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/683130c2ff8c2739f4822ff7ac5c873ec529abd1", - "reference": "683130c2ff8c2739f4822ff7ac5c873ec529abd1", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", + "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", "shasum": "" }, "require": { @@ -3655,9 +3659,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.1.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0" }, - "time": "2024-07-01T20:03:41+00:00" + "time": "2024-09-15T16:40:33+00:00" }, { "name": "nunomaduro/termwind", @@ -3811,24 +3815,24 @@ }, { "name": "paragonie/constant_time_encoding", - "version": "v2.7.0", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105" + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/52a0d99e69f56b9ec27ace92ba56897fe6993105", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", "shasum": "" }, "require": { - "php": "^7|^8" + "php": "^8" }, "require-dev": { - "phpunit/phpunit": "^6|^7|^8|^9", - "vimeo/psalm": "^1|^2|^3|^4" + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" }, "type": "library", "autoload": { @@ -3874,7 +3878,7 @@ "issues": "https://github.com/paragonie/constant_time_encoding/issues", "source": "https://github.com/paragonie/constant_time_encoding" }, - "time": "2024-05-08T12:18:48+00:00" + "time": "2024-05-08T12:36:18+00:00" }, { "name": "paragonie/random_compat", @@ -4093,16 +4097,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.41", + "version": "3.0.42", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "621c73f7dcb310b61de34d1da4c4204e8ace6ceb" + "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/621c73f7dcb310b61de34d1da4c4204e8ace6ceb", - "reference": "621c73f7dcb310b61de34d1da4c4204e8ace6ceb", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98", "shasum": "" }, "require": { @@ -4183,7 +4187,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.41" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.42" }, "funding": [ { @@ -4199,28 +4203,28 @@ "type": "tidelift" } ], - "time": "2024-08-12T00:13:54+00:00" + "time": "2024-09-16T03:06:04+00:00" }, { "name": "pragmarx/google2fa", - "version": "v8.0.1", + "version": "v8.0.3", "source": { "type": "git", "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "80c3d801b31fe165f8fe99ea085e0a37834e1be3" + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/80c3d801b31fe165f8fe99ea085e0a37834e1be3", - "reference": "80c3d801b31fe165f8fe99ea085e0a37834e1be3", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^1.0|^2.0", + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", "php": "^7.1|^8.0" }, "require-dev": { - "phpstan/phpstan": "^0.12.18", + "phpstan/phpstan": "^1.9", "phpunit/phpunit": "^7.5.15|^8.5|^9.0" }, "type": "library", @@ -4249,9 +4253,9 @@ ], "support": { "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.1" + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" }, - "time": "2022-06-13T21:57:56+00:00" + "time": "2024-09-05T11:56:40+00:00" }, { "name": "predis/predis", @@ -4676,16 +4680,16 @@ }, { "name": "psr/log", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "79dff0b268932c640297f5208d6298f71855c03e" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", - "reference": "79dff0b268932c640297f5208d6298f71855c03e", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -4720,9 +4724,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.1" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2024-08-21T13:31:24+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "psr/simple-cache", @@ -5631,16 +5635,16 @@ }, { "name": "symfony/console", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "504974cbe43d05f83b201d6498c206f16fc0cdbc" + "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/504974cbe43d05f83b201d6498c206f16fc0cdbc", - "reference": "504974cbe43d05f83b201d6498c206f16fc0cdbc", + "url": "https://api.github.com/repos/symfony/console/zipball/72d080eb9edf80e36c19be61f72c98ed8273b765", + "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765", "shasum": "" }, "require": { @@ -5705,7 +5709,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.10" + "source": "https://github.com/symfony/console/tree/v6.4.12" }, "funding": [ { @@ -5721,7 +5725,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:30:32+00:00" + "time": "2024-09-20T08:15:52+00:00" }, { "name": "symfony/css-selector", @@ -6088,16 +6092,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.10", + "version": "v6.4.11", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "af29198d87112bebdd397bd7735fbd115997824c" + "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/af29198d87112bebdd397bd7735fbd115997824c", - "reference": "af29198d87112bebdd397bd7735fbd115997824c", + "url": "https://api.github.com/repos/symfony/finder/zipball/d7eb6daf8cd7e9ac4976e9576b32042ef7253453", + "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453", "shasum": "" }, "require": { @@ -6132,7 +6136,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.10" + "source": "https://github.com/symfony/finder/tree/v6.4.11" }, "funding": [ { @@ -6148,20 +6152,20 @@ "type": "tidelift" } ], - "time": "2024-07-24T07:06:38+00:00" + "time": "2024-08-13T14:27:37+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "117f1f20a7ade7bcea28b861fb79160a21a1e37b" + "reference": "133ac043875f59c26c55e79cf074562127cce4d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/117f1f20a7ade7bcea28b861fb79160a21a1e37b", - "reference": "117f1f20a7ade7bcea28b861fb79160a21a1e37b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/133ac043875f59c26c55e79cf074562127cce4d2", + "reference": "133ac043875f59c26c55e79cf074562127cce4d2", "shasum": "" }, "require": { @@ -6209,7 +6213,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.10" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.12" }, "funding": [ { @@ -6225,20 +6229,20 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:36:27+00:00" + "time": "2024-09-20T08:18:25+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "147e0daf618d7575b5007055340d09aece5cf068" + "reference": "96df83d51b5f78804f70c093b97310794fd6257b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/147e0daf618d7575b5007055340d09aece5cf068", - "reference": "147e0daf618d7575b5007055340d09aece5cf068", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/96df83d51b5f78804f70c093b97310794fd6257b", + "reference": "96df83d51b5f78804f70c093b97310794fd6257b", "shasum": "" }, "require": { @@ -6323,7 +6327,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.10" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.12" }, "funding": [ { @@ -6339,20 +6343,20 @@ "type": "tidelift" } ], - "time": "2024-07-26T14:52:04+00:00" + "time": "2024-09-21T06:02:57+00:00" }, { "name": "symfony/mime", - "version": "v6.4.9", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "7d048964877324debdcb4e0549becfa064a20d43" + "reference": "abe16ee7790b16aa525877419deb0f113953f0e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/7d048964877324debdcb4e0549becfa064a20d43", - "reference": "7d048964877324debdcb4e0549becfa064a20d43", + "url": "https://api.github.com/repos/symfony/mime/zipball/abe16ee7790b16aa525877419deb0f113953f0e1", + "reference": "abe16ee7790b16aa525877419deb0f113953f0e1", "shasum": "" }, "require": { @@ -6408,7 +6412,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.9" + "source": "https://github.com/symfony/mime/tree/v6.4.12" }, "funding": [ { @@ -6424,24 +6428,24 @@ "type": "tidelift" } ], - "time": "2024-06-28T09:49:33+00:00" + "time": "2024-09-20T08:18:25+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-ctype": "*" @@ -6487,7 +6491,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" }, "funding": [ { @@ -6503,24 +6507,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a" + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/64647a7c30b2283f5d49b874d84a18fc22054b7a", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -6565,7 +6569,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" }, "funding": [ { @@ -6581,26 +6585,25 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "a6e83bdeb3c84391d1dfe16f42e40727ce524a5c" + "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a6e83bdeb3c84391d1dfe16f42e40727ce524a5c", - "reference": "a6e83bdeb3c84391d1dfe16f42e40727ce524a5c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", + "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", "shasum": "" }, "require": { - "php": ">=7.1", - "symfony/polyfill-intl-normalizer": "^1.10", - "symfony/polyfill-php72": "^1.10" + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" }, "suggest": { "ext-intl": "For best performance" @@ -6649,7 +6652,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" }, "funding": [ { @@ -6665,24 +6668,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -6730,7 +6733,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" }, "funding": [ { @@ -6746,24 +6749,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -6810,7 +6813,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -6826,97 +6829,24 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" - }, - { - "name": "symfony/polyfill-php72", - "version": "v1.30.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "10112722600777e02d2745716b70c5db4ca70442" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/10112722600777e02d2745716b70c5db4ca70442", - "reference": "10112722600777e02d2745716b70c5db4ca70442", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.30.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -6963,7 +6893,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { @@ -6979,24 +6909,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9" + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9", - "reference": "dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -7039,7 +6969,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" }, "funding": [ { @@ -7055,24 +6985,24 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:35:24+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "2ba1f33797470debcda07fe9dce20a0003df18e9" + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/2ba1f33797470debcda07fe9dce20a0003df18e9", - "reference": "2ba1f33797470debcda07fe9dce20a0003df18e9", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-uuid": "*" @@ -7118,7 +7048,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" }, "funding": [ { @@ -7134,20 +7064,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", - "version": "v6.4.8", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8d92dd79149f29e89ee0f480254db595f6a6a2c5" + "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8d92dd79149f29e89ee0f480254db595f6a6a2c5", - "reference": "8d92dd79149f29e89ee0f480254db595f6a6a2c5", + "url": "https://api.github.com/repos/symfony/process/zipball/3f94e5f13ff58df371a7ead461b6e8068900fbb3", + "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3", "shasum": "" }, "require": { @@ -7179,7 +7109,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.8" + "source": "https://github.com/symfony/process/tree/v6.4.12" }, "funding": [ { @@ -7195,20 +7125,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-17T12:47:12+00:00" }, { "name": "symfony/routing", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "aad19fe10753ba842f0d653a8db819c4b3affa87" + "reference": "a7c8036bd159486228dc9be3e846a00a0dda9f9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/aad19fe10753ba842f0d653a8db819c4b3affa87", - "reference": "aad19fe10753ba842f0d653a8db819c4b3affa87", + "url": "https://api.github.com/repos/symfony/routing/zipball/a7c8036bd159486228dc9be3e846a00a0dda9f9f", + "reference": "a7c8036bd159486228dc9be3e846a00a0dda9f9f", "shasum": "" }, "require": { @@ -7262,7 +7192,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.10" + "source": "https://github.com/symfony/routing/tree/v6.4.12" }, "funding": [ { @@ -7278,7 +7208,7 @@ "type": "tidelift" } ], - "time": "2024-07-15T09:26:24+00:00" + "time": "2024-09-20T08:32:26+00:00" }, { "name": "symfony/service-contracts", @@ -7365,16 +7295,16 @@ }, { "name": "symfony/string", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ccf9b30251719567bfd46494138327522b9a9446" + "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ccf9b30251719567bfd46494138327522b9a9446", - "reference": "ccf9b30251719567bfd46494138327522b9a9446", + "url": "https://api.github.com/repos/symfony/string/zipball/f8a1ccebd0997e16112dfecfd74220b78e5b284b", + "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b", "shasum": "" }, "require": { @@ -7431,7 +7361,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.10" + "source": "https://github.com/symfony/string/tree/v6.4.12" }, "funding": [ { @@ -7447,20 +7377,20 @@ "type": "tidelift" } ], - "time": "2024-07-22T10:21:14+00:00" + "time": "2024-09-20T08:15:52+00:00" }, { "name": "symfony/translation", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "94041203f8ac200ae9e7c6a18fa6137814ccecc9" + "reference": "cf8360b8352b086be620fae8342c4d96e391a489" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/94041203f8ac200ae9e7c6a18fa6137814ccecc9", - "reference": "94041203f8ac200ae9e7c6a18fa6137814ccecc9", + "url": "https://api.github.com/repos/symfony/translation/zipball/cf8360b8352b086be620fae8342c4d96e391a489", + "reference": "cf8360b8352b086be620fae8342c4d96e391a489", "shasum": "" }, "require": { @@ -7526,7 +7456,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.10" + "source": "https://github.com/symfony/translation/tree/v6.4.12" }, "funding": [ { @@ -7542,7 +7472,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:30:32+00:00" + "time": "2024-09-16T06:02:54+00:00" }, { "name": "symfony/translation-contracts", @@ -7624,16 +7554,16 @@ }, { "name": "symfony/uid", - "version": "v6.4.8", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "35904eca37a84bb764c560cbfcac9f0ac2bcdbdf" + "reference": "2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/35904eca37a84bb764c560cbfcac9f0ac2bcdbdf", - "reference": "35904eca37a84bb764c560cbfcac9f0ac2bcdbdf", + "url": "https://api.github.com/repos/symfony/uid/zipball/2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d", + "reference": "2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d", "shasum": "" }, "require": { @@ -7678,7 +7608,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.8" + "source": "https://github.com/symfony/uid/tree/v6.4.12" }, "funding": [ { @@ -7694,20 +7624,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-20T08:32:26+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.10", + "version": "v6.4.11", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "a71cc3374f5fb9759da1961d28c452373b343dd4" + "reference": "ee14c8254a480913268b1e3b1cba8045ed122694" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/a71cc3374f5fb9759da1961d28c452373b343dd4", - "reference": "a71cc3374f5fb9759da1961d28c452373b343dd4", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ee14c8254a480913268b1e3b1cba8045ed122694", + "reference": "ee14c8254a480913268b1e3b1cba8045ed122694", "shasum": "" }, "require": { @@ -7763,7 +7693,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.10" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.11" }, "funding": [ { @@ -7779,7 +7709,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:30:32+00:00" + "time": "2024-08-30T16:03:21+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8117,26 +8047,26 @@ }, { "name": "filp/whoops", - "version": "2.15.4", + "version": "2.16.0", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "a139776fa3f5985a50b509f2a02ff0f709d2a546" + "reference": "befcdc0e5dce67252aa6322d82424be928214fa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/a139776fa3f5985a50b509f2a02ff0f709d2a546", - "reference": "a139776fa3f5985a50b509f2a02ff0f709d2a546", + "url": "https://api.github.com/repos/filp/whoops/zipball/befcdc0e5dce67252aa6322d82424be928214fa2", + "reference": "befcdc0e5dce67252aa6322d82424be928214fa2", "shasum": "" }, "require": { - "php": "^5.5.9 || ^7.0 || ^8.0", + "php": "^7.1 || ^8.0", "psr/log": "^1.0.1 || ^2.0 || ^3.0" }, "require-dev": { - "mockery/mockery": "^0.9 || ^1.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" }, "suggest": { "symfony/var-dumper": "Pretty print complex values better with var-dumper available", @@ -8176,7 +8106,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.15.4" + "source": "https://github.com/filp/whoops/tree/2.16.0" }, "funding": [ { @@ -8184,7 +8114,7 @@ "type": "github" } ], - "time": "2023-11-03T12:00:00+00:00" + "time": "2024-09-25T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -8773,16 +8703,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.9.1", + "version": "5.10.0", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "169a9f11f1957ea36607c9b29eac1b48679f1ecc" + "reference": "91d980ab76c3f152481e367f62b921adc38af451" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/169a9f11f1957ea36607c9b29eac1b48679f1ecc", - "reference": "169a9f11f1957ea36607c9b29eac1b48679f1ecc", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/91d980ab76c3f152481e367f62b921adc38af451", + "reference": "91d980ab76c3f152481e367f62b921adc38af451", "shasum": "" }, "require": { @@ -8856,20 +8786,20 @@ "type": "other" } ], - "time": "2024-08-13T19:01:01+00:00" + "time": "2024-08-29T20:56:34+00:00" }, { "name": "phpstan/phpstan", - "version": "1.11.11", + "version": "1.12.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3" + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3", - "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", "shasum": "" }, "require": { @@ -8914,7 +8844,7 @@ "type": "github" } ], - "time": "2024-08-19T14:37:29+00:00" + "time": "2024-09-26T12:45:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9239,16 +9169,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.30", + "version": "10.5.35", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b15524febac0153876b4ba9aab3326c2ee94c897" + "reference": "7ac8b4e63f456046dcb4c9787da9382831a1874b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b15524febac0153876b4ba9aab3326c2ee94c897", - "reference": "b15524febac0153876b4ba9aab3326c2ee94c897", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7ac8b4e63f456046dcb4c9787da9382831a1874b", + "reference": "7ac8b4e63f456046dcb4c9787da9382831a1874b", "shasum": "" }, "require": { @@ -9262,7 +9192,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", - "phpunit/php-code-coverage": "^10.1.15", + "phpunit/php-code-coverage": "^10.1.16", "phpunit/php-file-iterator": "^4.1.0", "phpunit/php-invoker": "^4.0.0", "phpunit/php-text-template": "^3.0.1", @@ -9320,7 +9250,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.30" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.35" }, "funding": [ { @@ -9336,7 +9266,7 @@ "type": "tidelift" } ], - "time": "2024-08-13T06:09:37+00:00" + "time": "2024-09-19T10:52:21+00:00" }, { "name": "sebastian/cli-parser", @@ -10256,16 +10186,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.2", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" + "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", + "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", "shasum": "" }, "require": { @@ -10332,7 +10262,7 @@ "type": "open_collective" } ], - "time": "2024-07-21T23:26:44+00:00" + "time": "2024-09-18T10:38:58+00:00" }, { "name": "ssddanbrown/asserthtml", @@ -10392,16 +10322,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v6.4.8", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "105b56a0305d219349edeb60a800082eca864e4b" + "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/105b56a0305d219349edeb60a800082eca864e4b", - "reference": "105b56a0305d219349edeb60a800082eca864e4b", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/9d307ecbcb917001692be333cdc58f474fdb37f0", + "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0", "shasum": "" }, "require": { @@ -10439,7 +10369,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.8" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.12" }, "funding": [ { @@ -10455,7 +10385,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-15T06:35:36+00:00" }, { "name": "theseer/tokenizer", diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index 0383c2fad35..a6a01cdcc4f 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -1,3 +1,16 @@ +abab +License: BSD-3-Clause +License File: node_modules/abab/LICENSE.md +Source: git+https://github.com/jsdom/abab.git +Link: https://github.com/jsdom/abab#readme +----------- +acorn-globals +License: MIT +License File: node_modules/acorn-globals/LICENSE +Copyright: Copyright (c) 2014 Forbes Lindesay +Source: https://github.com/ForbesLindesay/acorn-globals.git +Link: https://github.com/ForbesLindesay/acorn-globals.git +----------- acorn-jsx License: MIT License File: node_modules/acorn-jsx/LICENSE @@ -5,6 +18,13 @@ Copyright: Copyright (C) 2012-2017 by Ingvar Stepanyan Source: https://github.com/acornjs/acorn-jsx Link: https://github.com/acornjs/acorn-jsx ----------- +acorn-walk +License: MIT +License File: node_modules/acorn-walk/LICENSE +Copyright: Copyright (C) 2012-2020 by various contributors (see AUTHORS) +Source: https://github.com/acornjs/acorn.git +Link: https://github.com/acornjs/acorn +----------- acorn License: MIT License File: node_modules/acorn/LICENSE @@ -12,6 +32,11 @@ Copyright: Copyright (C) 2012-2022 by various contributors (see AUTHORS) Source: git+https://github.com/acornjs/acorn.git Link: https://github.com/acornjs/acorn ----------- +agent-base +License: MIT +Source: git://github.com/TooTallNate/node-agent-base.git +Link: git://github.com/TooTallNate/node-agent-base.git +----------- ajv License: MIT License File: node_modules/ajv/LICENSE @@ -19,6 +44,13 @@ Copyright: Copyright (c) 2015-2017 Evgeny Poberezkin Source: https://github.com/ajv-validator/ajv.git Link: https://github.com/ajv-validator/ajv ----------- +ansi-escapes +License: MIT +License File: node_modules/ansi-escapes/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/ansi-escapes +Link: sindresorhus/ansi-escapes +----------- ansi-regex License: MIT License File: node_modules/ansi-regex/license @@ -40,6 +72,13 @@ Copyright: Copyright (c) 2019 Elan Shanker, Paul Miller (https://paulmillr.com) Source: https://github.com/micromatch/anymatch Link: https://github.com/micromatch/anymatch ----------- +arg +License: MIT +License File: node_modules/arg/LICENSE.md +Copyright: Copyright (c) 2017-2019 Zeit, Inc. +Source: zeit/arg +Link: zeit/arg +----------- argparse License: Python-2.0 License File: node_modules/argparse/LICENSE @@ -88,6 +127,20 @@ Copyright: Copyright (c) 2023 ECMAScript Shims Source: git+https://github.com/es-shims/ArrayBuffer.prototype.slice.git Link: https://github.com/es-shims/ArrayBuffer.prototype.slice#readme ----------- +async +License: MIT +License File: node_modules/async/LICENSE +Copyright: Copyright (c) 2010-2018 Caolan McMahon +Source: https://github.com/caolan/async.git +Link: https://caolan.github.io/async/ +----------- +asynckit +License: MIT +License File: node_modules/asynckit/LICENSE +Copyright: Copyright (c) 2016 Alex Indigo +Source: git+https://github.com/alexindigo/asynckit.git +Link: https://github.com/alexindigo/asynckit#readme +----------- available-typed-arrays License: MIT License File: node_modules/available-typed-arrays/LICENSE @@ -95,6 +148,42 @@ Copyright: Copyright (c) 2020 Inspect JS Source: git+https://github.com/inspect-js/available-typed-arrays.git Link: https://github.com/inspect-js/available-typed-arrays#readme ----------- +babel-jest +License: MIT +License File: node_modules/babel-jest/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +babel-plugin-istanbul +License: BSD-3-Clause +License File: node_modules/babel-plugin-istanbul/LICENSE +Copyright: Copyright (c) 2016, Istanbul Code Coverage +All rights reserved. +Source: git+https://github.com/istanbuljs/babel-plugin-istanbul.git +Link: https://github.com/istanbuljs/babel-plugin-istanbul#readme +----------- +babel-plugin-jest-hoist +License: MIT +License File: node_modules/babel-plugin-jest-hoist/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +babel-preset-current-node-syntax +License: MIT +License File: node_modules/babel-preset-current-node-syntax/LICENSE +Copyright: Copyright (c) 2020 Nicolò Ribaudo and other contributors +Source: https://github.com/nicolo-ribaudo/babel-preset-current-node-syntax.git +Link: https://github.com/nicolo-ribaudo/babel-preset-current-node-syntax.git +----------- +babel-preset-jest +License: MIT +License File: node_modules/babel-preset-jest/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- balanced-match License: MIT License File: node_modules/balanced-match/LICENSE.md @@ -124,6 +213,32 @@ Copyright: Copyright (c) 2014-present, Jon Schlinkert. Source: micromatch/braces Link: https://github.com/micromatch/braces ----------- +browserslist +License: MIT +License File: node_modules/browserslist/LICENSE +Copyright: Copyright 2014 Andrey Sitnik <******@******.**> and other contributors +Source: browserslist/browserslist +Link: browserslist/browserslist +----------- +bs-logger +License: MIT +License File: node_modules/bs-logger/LICENSE +Copyright: Copyright (c) 2018 Huafu Gandon +Source: git+https://github.com/huafu/bs-logger.git +Link: git+https://github.com/huafu/bs-logger.git +----------- +bser +License: Apache-2.0 +Source: https://github.com/facebook/watchman +Link: https://facebook.github.io/watchman/docs/bser.html +----------- +buffer-from +License: MIT +License File: node_modules/buffer-from/LICENSE +Copyright: Copyright (c) 2016, 2018 Linus Unnebäck +Source: LinusU/buffer-from +Link: LinusU/buffer-from +----------- call-bind License: MIT License File: node_modules/call-bind/LICENSE @@ -145,6 +260,12 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/camelcase Link: sindresorhus/camelcase ----------- +caniuse-lite +License: CC-BY-4.0 +License File: node_modules/caniuse-lite/LICENSE +Source: browserslist/caniuse-lite +Link: browserslist/caniuse-lite +----------- chalk License: MIT License File: node_modules/chalk/license @@ -152,6 +273,12 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: chalk/chalk Link: chalk/chalk ----------- +char-regex +License: MIT +License File: node_modules/char-regex/LICENSE +Source: https://github.com/Richienb/char-regex.git +Link: https://github.com/Richienb/char-regex.git +----------- chokidar-cli License: MIT License File: node_modules/chokidar-cli/LICENSE @@ -166,6 +293,19 @@ Copyright: Copyright (c) 2012-2019 Paul Miller (https://paulmillr.com), Elan Sha Source: git+https://github.com/paulmillr/chokidar.git Link: https://github.com/paulmillr/chokidar ----------- +ci-info +License: MIT +License File: node_modules/ci-info/LICENSE +Copyright: Copyright (c) 2016 Thomas Watson Steen +Source: https://github.com/watson/ci-info.git +Link: https://github.com/watson/ci-info +----------- +cjs-module-lexer +License: MIT +License File: node_modules/cjs-module-lexer/LICENSE +Source: git+https://github.com/nodejs/cjs-module-lexer.git +Link: https://github.com/nodejs/cjs-module-lexer#readme +----------- cliui License: ISC License File: node_modules/cliui/LICENSE.txt @@ -173,6 +313,13 @@ Copyright: Copyright (c) 2015, Contributors Source: http://github.com/yargs/cliui.git Link: http://github.com/yargs/cliui.git ----------- +co +License: MIT +License File: node_modules/co/LICENSE +Copyright: Copyright (c) 2014 TJ Holowaychuk <**@************.**> +Source: tj/co +Link: tj/co +----------- codemirror License: MIT License File: node_modules/codemirror/LICENSE @@ -180,6 +327,13 @@ Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <*******@*****.***> and o Source: https://github.com/codemirror/basic-setup.git Link: https://github.com/codemirror/basic-setup.git ----------- +collect-v8-coverage +License: MIT +License File: node_modules/collect-v8-coverage/LICENSE +Copyright: Copyright (c) 2019 Simen Bekkhus +Source: SimenB/collect-v8-coverage +Link: SimenB/collect-v8-coverage +----------- color-convert License: MIT License File: node_modules/color-convert/LICENSE @@ -193,6 +347,13 @@ License File: node_modules/color-name/LICENSE Source: git@github.com:colorjs/color-name.git Link: https://github.com/colorjs/color-name ----------- +combined-stream +License: MIT +License File: node_modules/combined-stream/License +Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***> +Source: git://github.com/felixge/node-combined-stream.git +Link: https://github.com/felixge/node-combined-stream +----------- concat-map License: MIT License File: node_modules/concat-map/LICENSE @@ -206,6 +367,28 @@ Copyright: Copyright (c) 2013-present, Facebook, Inc. Source: https://github.com/facebook/create-react-app.git Link: https://github.com/facebook/create-react-app.git ----------- +convert-source-map +License: MIT +License File: node_modules/convert-source-map/LICENSE +Copyright: Copyright 2013 Thorsten Lorenz. +All rights reserved. +Source: git://github.com/thlorenz/convert-source-map.git +Link: https://github.com/thlorenz/convert-source-map +----------- +create-jest +License: MIT +License File: node_modules/create-jest/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +create-require +License: MIT +License File: node_modules/create-require/LICENSE +Copyright: Copyright (c) 2020 +Source: nuxt-contrib/create-require +Link: nuxt-contrib/create-require +----------- crelt License: MIT License File: node_modules/crelt/LICENSE @@ -220,6 +403,26 @@ Copyright: Copyright (c) 2018 Made With MOXY Lda <*****@****.******> Source: git@github.com:moxystudio/node-cross-spawn.git Link: https://github.com/moxystudio/node-cross-spawn ----------- +cssom +License: MIT +License File: node_modules/cssom/LICENSE.txt +Copyright: Copyright (c) Nikita Vasilyev +Source: NV/CSSOM +Link: NV/CSSOM +----------- +cssstyle +License: MIT +License File: node_modules/cssstyle/LICENSE +Copyright: Copyright (c) Chad Walker +Source: jsdom/cssstyle +Link: https://github.com/jsdom/cssstyle +----------- +data-urls +License: MIT +License File: node_modules/data-urls/LICENSE.txt +Source: jsdom/data-urls +Link: jsdom/data-urls +----------- data-view-buffer License: MIT License File: node_modules/data-view-buffer/LICENSE @@ -256,6 +459,17 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/decamelize Link: sindresorhus/decamelize ----------- +decimal.js +License: MIT +Source: https://github.com/MikeMcl/decimal.js.git +Link: https://github.com/MikeMcl/decimal.js.git +----------- +dedent +License: MIT +License File: node_modules/dedent/LICENSE.md +Source: https://github.com/dmnd/dedent +Link: https://github.com/dmnd/dedent +----------- deep-is License: MIT License File: node_modules/deep-is/LICENSE @@ -265,6 +479,13 @@ Copyright (c) 2009 Thomas Robinson <280north.com> Source: http://github.com/thlorenz/deep-is.git Link: http://github.com/thlorenz/deep-is.git ----------- +deepmerge +License: MIT +License File: node_modules/deepmerge/license.txt +Copyright: Copyright (c) 2012 James Halliday, Josh Duff, and other contributors +Source: git://github.com/TehShrike/deepmerge.git +Link: https://github.com/TehShrike/deepmerge +----------- define-data-property License: MIT License File: node_modules/define-data-property/LICENSE @@ -279,12 +500,66 @@ Copyright: Copyright (C) 2015 Jordan Harband Source: git://github.com/ljharb/define-properties.git Link: git://github.com/ljharb/define-properties.git ----------- +delayed-stream +License: MIT +License File: node_modules/delayed-stream/License +Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***> +Source: git://github.com/felixge/node-delayed-stream.git +Link: https://github.com/felixge/node-delayed-stream +----------- +detect-newline +License: MIT +License File: node_modules/detect-newline/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/detect-newline +Link: sindresorhus/detect-newline +----------- +diff-sequences +License: MIT +License File: node_modules/diff-sequences/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +diff +License: BSD-3-Clause +License File: node_modules/diff/LICENSE +Copyright: Copyright (c) 2009-2015, Kevin Decker <********@*****.***> +Source: git://github.com/kpdecker/jsdiff.git +Link: git://github.com/kpdecker/jsdiff.git +----------- doctrine License: Apache-2.0 License File: node_modules/doctrine/LICENSE Source: eslint/doctrine Link: https://github.com/eslint/doctrine ----------- +domexception +License: MIT +License File: node_modules/domexception/LICENSE.txt +Source: jsdom/domexception +Link: jsdom/domexception +----------- +ejs +License: Apache-2.0 +License File: node_modules/ejs/LICENSE +Source: git://github.com/mde/ejs.git +Link: https://github.com/mde/ejs +----------- +electron-to-chromium +License: ISC +License File: node_modules/electron-to-chromium/LICENSE +Copyright: Copyright 2018 Kilian Valkhof +Source: https://github.com/kilian/electron-to-chromium/ +Link: https://github.com/kilian/electron-to-chromium/ +----------- +emittery +License: MIT +License File: node_modules/emittery/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/emittery +Link: sindresorhus/emittery +----------- emoji-regex License: MIT License File: node_modules/emoji-regex/LICENSE-MIT.txt @@ -362,6 +637,13 @@ Copyright: Copyright (c) 2020 Evan Wallace Source: git+https://github.com/evanw/esbuild.git Link: git+https://github.com/evanw/esbuild.git ----------- +escalade +License: MIT +License File: node_modules/escalade/license +Copyright: Copyright (c) Luke Edwards <****.*********@*****.***> (lukeed.com) +Source: lukeed/escalade +Link: lukeed/escalade +----------- escape-string-regexp License: MIT License File: node_modules/escape-string-regexp/license @@ -369,6 +651,13 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/escape-string-regexp Link: sindresorhus/escape-string-regexp ----------- +escodegen +License: BSD-2-Clause +License File: node_modules/escodegen/LICENSE.BSD +Copyright: Copyright (C) 2012 Yusuke Suzuki (twitter: @Constellation) and other contributors. +Source: http://github.com/estools/escodegen.git +Link: http://github.com/estools/escodegen +----------- eslint-config-airbnb-base License: MIT License File: node_modules/eslint-config-airbnb-base/LICENSE.md @@ -424,6 +713,12 @@ All rights reserved. Source: eslint/espree Link: https://github.com/eslint/espree ----------- +esprima +License: BSD-2-Clause +License File: node_modules/esprima/LICENSE.BSD +Source: https://github.com/jquery/esprima.git +Link: http://esprima.org +----------- esquery License: BSD-3-Clause License File: node_modules/esquery/license.txt @@ -449,6 +744,24 @@ License File: node_modules/esutils/LICENSE.BSD Source: http://github.com/estools/esutils.git Link: https://github.com/estools/esutils ----------- +execa +License: MIT +License File: node_modules/execa/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/execa +Link: sindresorhus/execa +----------- +exit +Source: git://github.com/cowboy/node-exit.git +Link: https://github.com/cowboy/node-exit +----------- +expect +License: MIT +License File: node_modules/expect/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- fast-deep-equal License: MIT License File: node_modules/fast-deep-equal/LICENSE @@ -478,6 +791,11 @@ Copyright: Copyright (c) 2015-2020, Matteo Collina <******.*******@*****.***> Source: git+https://github.com/mcollina/fastq.git Link: https://github.com/mcollina/fastq#readme ----------- +fb-watchman +License: Apache-2.0 +Source: git@github.com:facebook/watchman.git +Link: https://facebook.github.io/watchman/ +----------- file-entry-cache License: MIT License File: node_modules/file-entry-cache/LICENSE @@ -485,6 +803,11 @@ Copyright: Copyright (c) 2015 Roy Riojas Source: royriojas/file-entry-cache Link: royriojas/file-entry-cache ----------- +filelist +License: Apache-2.0 +Source: git://github.com/mde/filelist.git +Link: https://github.com/mde/filelist +----------- fill-range License: MIT License File: node_modules/fill-range/LICENSE @@ -520,6 +843,13 @@ Copyright: Copyright (c) 2012 Raynos. Source: git://github.com/Raynos/for-each.git Link: https://github.com/Raynos/for-each ----------- +form-data +License: MIT +License File: node_modules/form-data/License +Copyright: Copyright (c) 2012 Felix Geisendörfer (*****@**********.***) and contributors +Source: git://github.com/form-data/form-data.git +Link: git://github.com/form-data/form-data.git +----------- fs.realpath License: ISC License File: node_modules/fs.realpath/LICENSE @@ -548,6 +878,13 @@ Copyright: Copyright (c) 2019 Jordan Harband Source: git+https://github.com/inspect-js/functions-have-names.git Link: https://github.com/inspect-js/functions-have-names#readme ----------- +gensync +License: MIT +License File: node_modules/gensync/LICENSE +Copyright: Copyright 2018 Logan Smyth <***********@*****.***> +Source: https://github.com/loganfsmyth/gensync.git +Link: https://github.com/loganfsmyth/gensync +----------- get-caller-file License: ISC License File: node_modules/get-caller-file/LICENSE.md @@ -562,6 +899,20 @@ Copyright: Copyright (c) 2020 Jordan Harband Source: git+https://github.com/ljharb/get-intrinsic.git Link: https://github.com/ljharb/get-intrinsic#readme ----------- +get-package-type +License: MIT +License File: node_modules/get-package-type/LICENSE +Copyright: Copyright (c) 2020 CFWare, LLC +Source: git+https://github.com/cfware/get-package-type.git +Link: https://github.com/cfware/get-package-type#readme +----------- +get-stream +License: MIT +License File: node_modules/get-stream/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/get-stream +Link: sindresorhus/get-stream +----------- get-symbol-description License: MIT License File: node_modules/get-symbol-description/LICENSE @@ -674,6 +1025,43 @@ Copyright: Copyright (c) 2015, Rebecca Turner Source: git+https://github.com/npm/hosted-git-info.git Link: https://github.com/npm/hosted-git-info ----------- +html-encoding-sniffer +License: MIT +License File: node_modules/html-encoding-sniffer/LICENSE.txt +Source: jsdom/html-encoding-sniffer +Link: jsdom/html-encoding-sniffer +----------- +html-escaper +License: MIT +License File: node_modules/html-escaper/LICENSE.txt +Copyright: Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection +Source: https://github.com/WebReflection/html-escaper.git +Link: https://github.com/WebReflection/html-escaper +----------- +http-proxy-agent +License: MIT +Source: git://github.com/TooTallNate/node-http-proxy-agent.git +Link: git://github.com/TooTallNate/node-http-proxy-agent.git +----------- +https-proxy-agent +License: MIT +Source: git://github.com/TooTallNate/node-https-proxy-agent.git +Link: git://github.com/TooTallNate/node-https-proxy-agent.git +----------- +human-signals +License: Apache-2.0 +License File: node_modules/human-signals/LICENSE +Copyright: Copyright 2019 ehmicky <*******@*****.***> +Source: ehmicky/human-signals +Link: https://git.io/JeluP +----------- +iconv-lite +License: MIT +License File: node_modules/iconv-lite/LICENSE +Copyright: Copyright (c) 2011 Alexander Shtuchkin +Source: git://github.com/ashtuchkin/iconv-lite.git +Link: https://github.com/ashtuchkin/iconv-lite +----------- idb-keyval License: Apache-2.0 Source: git+https://github.com/jakearchibald/idb-keyval.git @@ -698,6 +1086,13 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/import-fresh Link: sindresorhus/import-fresh ----------- +import-local +License: MIT +License File: node_modules/import-local/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/import-local +Link: sindresorhus/import-local +----------- imurmurhash License: MIT Source: https://github.com/jensyt/imurmurhash-js @@ -801,6 +1196,13 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/is-fullwidth-code-point Link: sindresorhus/is-fullwidth-code-point ----------- +is-generator-fn +License: MIT +License File: node_modules/is-generator-fn/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/is-generator-fn +Link: sindresorhus/is-generator-fn +----------- is-glob License: MIT License File: node_modules/is-glob/LICENSE @@ -836,6 +1238,12 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/is-path-inside Link: sindresorhus/is-path-inside ----------- +is-potential-custom-element-name +License: MIT +License File: node_modules/is-potential-custom-element-name/LICENSE-MIT.txt +Source: https://github.com/mathiasbynens/is-potential-custom-element-name.git +Link: https://github.com/mathiasbynens/is-potential-custom-element-name +----------- is-regex License: MIT License File: node_modules/is-regex/LICENSE @@ -850,6 +1258,13 @@ Copyright: Copyright (c) 2021 Inspect JS Source: git+https://github.com/inspect-js/is-shared-array-buffer.git Link: https://github.com/inspect-js/is-shared-array-buffer#readme ----------- +is-stream +License: MIT +License File: node_modules/is-stream/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/is-stream +Link: sindresorhus/is-stream +----------- is-string License: MIT License File: node_modules/is-string/LICENSE @@ -892,6 +1307,240 @@ Copyright: Copyright (c) Isaac Z. Schlueter and Contributors Source: git+https://github.com/isaacs/isexe.git Link: https://github.com/isaacs/isexe#readme ----------- +istanbul-lib-coverage +License: BSD-3-Clause +License File: node_modules/istanbul-lib-coverage/LICENSE +Copyright: Copyright 2012-2015 Yahoo! Inc. +Source: git+ssh://git@github.com/istanbuljs/istanbuljs.git +Link: https://istanbul.js.org/ +----------- +istanbul-lib-instrument +License: BSD-3-Clause +License File: node_modules/istanbul-lib-instrument/LICENSE +Copyright: Copyright 2012-2015 Yahoo! Inc. +Source: git+ssh://git@github.com/istanbuljs/istanbuljs.git +Link: https://istanbul.js.org/ +----------- +istanbul-lib-report +License: BSD-3-Clause +License File: node_modules/istanbul-lib-report/LICENSE +Copyright: Copyright 2012-2015 Yahoo! Inc. +Source: git+ssh://git@github.com/istanbuljs/istanbuljs.git +Link: https://istanbul.js.org/ +----------- +istanbul-lib-source-maps +License: BSD-3-Clause +License File: node_modules/istanbul-lib-source-maps/LICENSE +Copyright: Copyright 2015 Yahoo! Inc. +Source: git+ssh://git@github.com/istanbuljs/istanbuljs.git +Link: https://istanbul.js.org/ +----------- +istanbul-reports +License: BSD-3-Clause +License File: node_modules/istanbul-reports/LICENSE +Copyright: Copyright 2012-2015 Yahoo! Inc. +Source: git+ssh://git@github.com/istanbuljs/istanbuljs.git +Link: https://istanbul.js.org/ +----------- +jake +License: Apache-2.0 +Source: git://github.com/jakejs/jake.git +Link: git://github.com/jakejs/jake.git +----------- +jest-changed-files +License: MIT +License File: node_modules/jest-changed-files/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-circus +License: MIT +License File: node_modules/jest-circus/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-cli +License: MIT +License File: node_modules/jest-cli/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://jestjs.io/ +----------- +jest-config +License: MIT +License File: node_modules/jest-config/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-diff +License: MIT +License File: node_modules/jest-diff/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-docblock +License: MIT +License File: node_modules/jest-docblock/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-each +License: MIT +License File: node_modules/jest-each/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-environment-jsdom +License: MIT +License File: node_modules/jest-environment-jsdom/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-environment-node +License: MIT +License File: node_modules/jest-environment-node/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-get-type +License: MIT +License File: node_modules/jest-get-type/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-haste-map +License: MIT +License File: node_modules/jest-haste-map/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-leak-detector +License: MIT +License File: node_modules/jest-leak-detector/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-matcher-utils +License: MIT +License File: node_modules/jest-matcher-utils/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-message-util +License: MIT +License File: node_modules/jest-message-util/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-mock +License: MIT +License File: node_modules/jest-mock/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-pnp-resolver +License: MIT +Source: https://github.com/arcanis/jest-pnp-resolver.git +Link: https://github.com/arcanis/jest-pnp-resolver +----------- +jest-regex-util +License: MIT +License File: node_modules/jest-regex-util/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-resolve-dependencies +License: MIT +License File: node_modules/jest-resolve-dependencies/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-resolve +License: MIT +License File: node_modules/jest-resolve/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-runner +License: MIT +License File: node_modules/jest-runner/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-runtime +License: MIT +License File: node_modules/jest-runtime/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-snapshot +License: MIT +License File: node_modules/jest-snapshot/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-util +License: MIT +License File: node_modules/jest-util/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-validate +License: MIT +License File: node_modules/jest-validate/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest-watcher +License: MIT +License File: node_modules/jest-watcher/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://jestjs.io/ +----------- +jest-worker +License: MIT +License File: node_modules/jest-worker/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +jest +License: MIT +License File: node_modules/jest/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://jestjs.io/ +----------- +js-tokens +License: MIT +License File: node_modules/js-tokens/LICENSE +Copyright: Copyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell +Source: lydell/js-tokens +Link: lydell/js-tokens +----------- js-yaml License: MIT License File: node_modules/js-yaml/LICENSE @@ -899,6 +1548,19 @@ Copyright: Copyright (C) 2011-2015 by Vitaly Puzrin Source: nodeca/js-yaml Link: nodeca/js-yaml ----------- +jsdom +License: MIT +License File: node_modules/jsdom/LICENSE.txt +Copyright: Copyright (c) 2010 Elijah Insua +Source: jsdom/jsdom +Link: jsdom/jsdom +----------- +jsesc +License: MIT +License File: node_modules/jsesc/LICENSE-MIT.txt +Source: https://github.com/mathiasbynens/jsesc.git +Link: https://mths.be/jsesc +----------- json-buffer License: MIT License File: node_modules/json-buffer/LICENSE @@ -913,6 +1575,14 @@ Copyright: Copyright 2017 Kat Marchán Source: https://github.com/zkat/json-parse-better-errors Link: https://github.com/zkat/json-parse-better-errors ----------- +json-parse-even-better-errors +License: MIT +License File: node_modules/json-parse-even-better-errors/LICENSE.md +Copyright: Copyright 2017 Kat Marchán +Copyright npm, Inc. +Source: https://github.com/npm/json-parse-even-better-errors +Link: https://github.com/npm/json-parse-even-better-errors +----------- json-schema-traverse License: MIT License File: node_modules/json-schema-traverse/LICENSE @@ -938,6 +1608,20 @@ License: MIT Source: git+https://github.com/jaredwray/keyv.git Link: https://github.com/jaredwray/keyv ----------- +kleur +License: MIT +License File: node_modules/kleur/license +Copyright: Copyright (c) Luke Edwards <****.*********@*****.***> (lukeed.com) +Source: lukeed/kleur +Link: lukeed/kleur +----------- +leven +License: MIT +License File: node_modules/leven/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/leven +Link: sindresorhus/leven +----------- levn License: MIT License File: node_modules/levn/LICENSE @@ -945,6 +1629,13 @@ Copyright: Copyright (c) George Zahariev Source: git://github.com/gkz/levn.git Link: https://github.com/gkz/levn ----------- +lines-and-columns +License: MIT +License File: node_modules/lines-and-columns/LICENSE +Copyright: Copyright (c) 2015 Brian Donovan +Source: https://github.com/eventualbuddha/lines-and-columns.git +Link: https://github.com/eventualbuddha/lines-and-columns#readme +----------- linkify-it License: MIT License File: node_modules/linkify-it/LICENSE @@ -986,6 +1677,12 @@ License File: node_modules/lodash.debounce/LICENSE Source: lodash/lodash Link: https://lodash.com/ ----------- +lodash.memoize +License: MIT +License File: node_modules/lodash.memoize/LICENSE +Source: lodash/lodash +Link: https://lodash.com/ +----------- lodash.merge License: MIT License File: node_modules/lodash.merge/LICENSE @@ -998,6 +1695,34 @@ License File: node_modules/lodash.throttle/LICENSE Source: lodash/lodash Link: https://lodash.com/ ----------- +lru-cache +License: ISC +License File: node_modules/lru-cache/LICENSE +Copyright: Copyright (c) Isaac Z. Schlueter and Contributors +Source: git://github.com/isaacs/node-lru-cache.git +Link: git://github.com/isaacs/node-lru-cache.git +----------- +make-dir +License: MIT +License File: node_modules/make-dir/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/make-dir +Link: sindresorhus/make-dir +----------- +make-error +License: ISC +License File: node_modules/make-error/LICENSE +Copyright: Copyright 2014 Julien Fontanet +Source: git://github.com/JsCommunity/make-error.git +Link: https://github.com/JsCommunity/make-error +----------- +makeerror +License: BSD-3-Clause +License File: node_modules/makeerror/license +Copyright: Copyright (c) 2014, Naitik Shah. All rights reserved. +Source: https://github.com/daaku/nodejs-makeerror +Link: https://github.com/daaku/nodejs-makeerror +----------- markdown-it-task-lists License: ISC License File: node_modules/markdown-it-task-lists/LICENSE @@ -1025,6 +1750,43 @@ Copyright: Copyright (C) 2011 Dmitry Nizovtsev Source: https://github.com/JSBizon/node-memorystream.git Link: https://github.com/JSBizon/node-memorystream ----------- +merge-stream +License: MIT +License File: node_modules/merge-stream/LICENSE +Copyright: Copyright (c) Stephen Sugden <**@*************.***> (stephensugden.com) +Source: grncdr/merge-stream +Link: grncdr/merge-stream +----------- +micromatch +License: MIT +License File: node_modules/micromatch/LICENSE +Copyright: Copyright (c) 2014-present, Jon Schlinkert. +Source: micromatch/micromatch +Link: https://github.com/micromatch/micromatch +----------- +mime-db +License: MIT +License File: node_modules/mime-db/LICENSE +Copyright: Copyright (c) 2014 Jonathan Ong <**@***********.***> +Copyright (c) 2015-2022 Douglas Christopher Wilson <****@*************.***> +Source: jshttp/mime-db +Link: jshttp/mime-db +----------- +mime-types +License: MIT +License File: node_modules/mime-types/LICENSE +Copyright: Copyright (c) 2014 Jonathan Ong <**@***********.***> +Copyright (c) 2015 Douglas Christopher Wilson <****@*************.***> +Source: jshttp/mime-types +Link: jshttp/mime-types +----------- +mimic-fn +License: MIT +License File: node_modules/mimic-fn/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/mimic-fn +Link: sindresorhus/mimic-fn +----------- minimatch License: ISC License File: node_modules/minimatch/LICENSE @@ -1041,9 +1803,9 @@ Link: https://github.com/minimistjs/minimist ms License: MIT License File: node_modules/ms/license.md -Copyright: Copyright (c) 2016 Zeit, Inc. -Source: zeit/ms -Link: zeit/ms +Copyright: Copyright (c) 2020 Vercel, Inc. +Source: vercel/ms +Link: vercel/ms ----------- natural-compare License: MIT @@ -1057,6 +1819,20 @@ Copyright: Copyright (c) 2018 Tobias Reich Source: https://github.com/electerious/nice-try.git Link: https://github.com/electerious/nice-try ----------- +node-int64 +License: MIT +License File: node_modules/node-int64/LICENSE +Copyright: Copyright (c) 2014 Robert Kieffer +Source: https://github.com/broofa/node-int64 +Link: https://github.com/broofa/node-int64 +----------- +node-releases +License: MIT +License File: node_modules/node-releases/LICENSE +Copyright: Copyright (c) 2017 Sergey Rubanov (https://github.com/chicoxyzzy) +Source: git+https://github.com/chicoxyzzy/node-releases.git +Link: git+https://github.com/chicoxyzzy/node-releases.git +----------- normalize-package-data License: BSD-2-Clause License File: node_modules/normalize-package-data/LICENSE @@ -1079,6 +1855,20 @@ Copyright: Copyright (c) 2015 Toru Nagashima Source: mysticatea/npm-run-all Link: https://github.com/mysticatea/npm-run-all ----------- +npm-run-path +License: MIT +License File: node_modules/npm-run-path/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/npm-run-path +Link: sindresorhus/npm-run-path +----------- +nwsapi +License: MIT +License File: node_modules/nwsapi/LICENSE +Copyright: Copyright (c) 2007-2024 Diego Perini (http://www.iport.it/) +Source: git://github.com/dperini/nwsapi.git +Link: http://javascript.nwbox.com/nwsapi/ +----------- object-inspect License: MIT License File: node_modules/object-inspect/LICENSE @@ -1135,6 +1925,13 @@ Copyright: Copyright (c) Isaac Z. Schlueter and Contributors Source: git://github.com/isaacs/once Link: git://github.com/isaacs/once ----------- +onetime +License: MIT +License File: node_modules/onetime/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/onetime +Link: sindresorhus/onetime +----------- optionator License: MIT License File: node_modules/optionator/LICENSE @@ -1181,10 +1978,17 @@ Link: sindresorhus/parent-module parse-json License: MIT License File: node_modules/parse-json/license -Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) Source: sindresorhus/parse-json Link: sindresorhus/parse-json ----------- +parse5 +License: MIT +License File: node_modules/parse5/LICENSE +Copyright: Copyright (c) 2013-2019 Ivan Nikulin (******@*****.***, https://github.com/inikulin) +Source: git://github.com/inikulin/parse5.git +Link: https://github.com/inikulin/parse5 +----------- path-exists License: MIT License File: node_modules/path-exists/license @@ -1220,6 +2024,13 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/path-type Link: sindresorhus/path-type ----------- +picocolors +License: ISC +License File: node_modules/picocolors/LICENSE +Copyright: Copyright (c) 2021-2024 Oleksii Raspopov, Kostiantyn Denysov, Anton Verinov +Source: alexeyraspopov/picocolors +Link: alexeyraspopov/picocolors +----------- picomatch License: MIT License File: node_modules/picomatch/LICENSE @@ -1241,6 +2052,20 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/pify Link: sindresorhus/pify ----------- +pirates +License: MIT +License File: node_modules/pirates/LICENSE +Copyright: Copyright (c) 2016-2018 Ari Porad +Source: https://github.com/danez/pirates.git +Link: https://github.com/danez/pirates#readme +----------- +pkg-dir +License: MIT +License File: node_modules/pkg-dir/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/pkg-dir +Link: sindresorhus/pkg-dir +----------- possible-typed-array-names License: MIT License File: node_modules/possible-typed-array-names/LICENSE @@ -1255,6 +2080,27 @@ Copyright: Copyright (c) George Zahariev Source: git://github.com/gkz/prelude-ls.git Link: http://preludels.com ----------- +pretty-format +License: MIT +License File: node_modules/pretty-format/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +prompts +License: MIT +License File: node_modules/prompts/license +Copyright: Copyright (c) 2018 Terkel Gjervig Nielsen +Source: terkelg/prompts +Link: terkelg/prompts +----------- +psl +License: MIT +License File: node_modules/psl/LICENSE +Copyright: Copyright (c) 2017 Lupo Montero ***********@*****.*** +Source: git@github.com:lupomontero/psl.git +Link: git@github.com:lupomontero/psl.git +----------- punycode.js License: MIT License File: node_modules/punycode.js/LICENSE-MIT.txt @@ -1267,6 +2113,20 @@ License File: node_modules/punycode/LICENSE-MIT.txt Source: https://github.com/mathiasbynens/punycode.js.git Link: https://mths.be/punycode ----------- +pure-rand +License: MIT +License File: node_modules/pure-rand/LICENSE +Copyright: Copyright (c) 2018 Nicolas DUBIEN +Source: git+https://github.com/dubzzz/pure-rand.git +Link: https://github.com/dubzzz/pure-rand#readme +----------- +querystringify +License: MIT +License File: node_modules/querystringify/LICENSE +Copyright: Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. +Source: https://github.com/unshiftio/querystringify +Link: https://github.com/unshiftio/querystringify +----------- queue-microtask License: MIT License File: node_modules/queue-microtask/LICENSE @@ -1274,6 +2134,13 @@ Copyright: Copyright (c) Feross Aboukhadijeh Source: git://github.com/feross/queue-microtask.git Link: https://github.com/feross/queue-microtask ----------- +react-is +License: MIT +License File: node_modules/react-is/LICENSE +Copyright: Copyright (c) Facebook, Inc. and its affiliates. +Source: https://github.com/facebook/react.git +Link: https://reactjs.org/ +----------- read-pkg License: MIT License File: node_modules/read-pkg/license @@ -1309,6 +2176,20 @@ Copyright: Copyright (c) 2016, Contributors Source: git+ssh://git@github.com/yargs/require-main-filename.git Link: https://github.com/yargs/require-main-filename#readme ----------- +requires-port +License: MIT +License File: node_modules/requires-port/LICENSE +Copyright: Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. +Source: https://github.com/unshiftio/requires-port +Link: https://github.com/unshiftio/requires-port +----------- +resolve-cwd +License: MIT +License File: node_modules/resolve-cwd/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/resolve-cwd +Link: sindresorhus/resolve-cwd +----------- resolve-from License: MIT License File: node_modules/resolve-from/license @@ -1316,6 +2197,13 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/resolve-from Link: sindresorhus/resolve-from ----------- +resolve.exports +License: MIT +License File: node_modules/resolve.exports/license +Copyright: Copyright (c) Luke Edwards <****.*********@*****.***> (lukeed.com) +Source: lukeed/resolve.exports +Link: lukeed/resolve.exports +----------- resolve License: MIT License File: node_modules/resolve/LICENSE @@ -1358,6 +2246,13 @@ Copyright: Copyright (c) 2022 Jordan Harband Source: git+https://github.com/ljharb/safe-regex-test.git Link: https://github.com/ljharb/safe-regex-test#readme ----------- +safer-buffer +License: MIT +License File: node_modules/safer-buffer/LICENSE +Copyright: Copyright (c) 2018 Nikita Skovoroda <********@*****.***> +Source: git+https://github.com/ChALkeR/safer-buffer.git +Link: git+https://github.com/ChALkeR/safer-buffer.git +----------- sass License: MIT License File: node_modules/sass/LICENSE @@ -1365,6 +2260,11 @@ Copyright: Copyright (c) 2016, Google Inc. Source: https://github.com/sass/dart-sass Link: https://github.com/sass/dart-sass ----------- +saxes +License: ISC +Source: https://github.com/lddubeau/saxes.git +Link: https://github.com/lddubeau/saxes.git +----------- semver License: ISC License File: node_modules/semver/LICENSE @@ -1421,6 +2321,27 @@ Copyright: Copyright (c) 2019 Jordan Harband Source: git+https://github.com/ljharb/side-channel.git Link: https://github.com/ljharb/side-channel#readme ----------- +signal-exit +License: ISC +License File: node_modules/signal-exit/LICENSE.txt +Copyright: Copyright (c) 2015, Contributors +Source: https://github.com/tapjs/signal-exit.git +Link: https://github.com/tapjs/signal-exit +----------- +sisteransi +License: MIT +License File: node_modules/sisteransi/license +Copyright: Copyright (c) 2018 Terkel Gjervig Nielsen +Source: https://github.com/terkelg/sisteransi +Link: https://github.com/terkelg/sisteransi +----------- +slash +License: MIT +License File: node_modules/slash/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/slash +Link: sindresorhus/slash +----------- snabbdom License: MIT License File: node_modules/snabbdom/LICENSE @@ -1442,6 +2363,21 @@ All rights reserved. Source: 7rulnik/source-map-js Link: https://github.com/7rulnik/source-map-js ----------- +source-map-support +License: MIT +License File: node_modules/source-map-support/LICENSE.md +Copyright: Copyright (c) 2014 Evan Wallace +Source: https://github.com/evanw/node-source-map-support +Link: https://github.com/evanw/node-source-map-support +----------- +source-map +License: BSD-3-Clause +License File: node_modules/source-map/LICENSE +Copyright: Copyright (c) 2009-2011, Mozilla Foundation and contributors +All rights reserved. +Source: http://github.com/mozilla/source-map.git +Link: https://github.com/mozilla/source-map +----------- spdx-correct License: Apache-2.0 License File: node_modules/spdx-correct/LICENSE @@ -1465,6 +2401,28 @@ License: CC0-1.0 Source: jslicense/spdx-license-ids Link: jslicense/spdx-license-ids ----------- +sprintf-js +License: BSD-3-Clause +License File: node_modules/sprintf-js/LICENSE +Copyright: Copyright (c) 2007-2014, Alexandru Marasteanu +All rights reserved. +Source: https://github.com/alexei/sprintf.js.git +Link: https://github.com/alexei/sprintf.js.git +----------- +stack-utils +License: MIT +License File: node_modules/stack-utils/LICENSE.md +Copyright: Copyright (c) 2016-2022 Isaac Z. Schlueter <*@***.**>, James Talmage <*****@*******.**> (github.com/jamestalmage), and Contributors +Source: tapjs/stack-utils +Link: tapjs/stack-utils +----------- +string-length +License: MIT +License File: node_modules/string-length/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindresorhus.com) +Source: sindresorhus/string-length +Link: sindresorhus/string-length +----------- string-width License: MIT License File: node_modules/string-width/license @@ -1514,6 +2472,13 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.co Source: sindresorhus/strip-bom Link: sindresorhus/strip-bom ----------- +strip-final-newline +License: MIT +License File: node_modules/strip-final-newline/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/strip-final-newline +Link: sindresorhus/strip-final-newline +----------- strip-json-comments License: MIT License File: node_modules/strip-json-comments/license @@ -1542,12 +2507,41 @@ Copyright: Copyright (c) 2022 Inspect JS Source: git+https://github.com/inspect-js/node-supports-preserve-symlinks-flag.git Link: https://github.com/inspect-js/node-supports-preserve-symlinks-flag#readme ----------- +symbol-tree +License: MIT +License File: node_modules/symbol-tree/LICENSE +Copyright: Copyright (c) 2015 Joris van der Wel +Source: https://github.com/jsdom/js-symbol-tree.git +Link: https://github.com/jsdom/js-symbol-tree#symbol-tree +----------- +test-exclude +License: ISC +License File: node_modules/test-exclude/LICENSE.txt +Copyright: Copyright (c) 2016, Contributors +Source: git+https://github.com/istanbuljs/test-exclude.git +Link: https://istanbul.js.org/ +----------- text-table License: MIT License File: node_modules/text-table/LICENSE Source: git://github.com/substack/text-table.git Link: https://github.com/substack/text-table ----------- +tmpl +License: BSD-3-Clause +License File: node_modules/tmpl/license +Copyright: Copyright (c) 2014, Naitik Shah. All rights reserved. +Source: https://github.com/daaku/nodejs-tmpl +Link: https://github.com/daaku/nodejs-tmpl +----------- +to-fast-properties +License: MIT +License File: node_modules/to-fast-properties/license +Copyright: Copyright (c) 2014 Petka Antonov + 2015 Sindre Sorhus +Source: sindresorhus/to-fast-properties +Link: sindresorhus/to-fast-properties +----------- to-regex-range License: MIT License File: node_modules/to-regex-range/LICENSE @@ -1555,6 +2549,34 @@ Copyright: Copyright (c) 2015-present, Jon Schlinkert. Source: micromatch/to-regex-range Link: https://github.com/micromatch/to-regex-range ----------- +tough-cookie +License: BSD-3-Clause +License File: node_modules/tough-cookie/LICENSE +Copyright: Copyright (c) 2015, Salesforce.com, Inc. +Source: git://github.com/salesforce/tough-cookie.git +Link: https://github.com/salesforce/tough-cookie +----------- +tr46 +License: MIT +License File: node_modules/tr46/LICENSE.md +Copyright: Copyright (c) Sebastian Mayr +Source: https://github.com/jsdom/tr46 +Link: https://github.com/jsdom/tr46 +----------- +ts-jest +License: MIT +License File: node_modules/ts-jest/LICENSE.md +Copyright: Copyright (c) 2016-2018 +Source: git+https://github.com/kulshekhar/ts-jest.git +Link: https://kulshekhar.github.io/ts-jest +----------- +ts-node +License: MIT +License File: node_modules/ts-node/LICENSE +Copyright: Copyright (c) 2014 Blake Embrey (*****@***********.***) +Source: git://github.com/TypeStrong/ts-node.git +Link: https://typestrong.org/ts-node +----------- tsconfig-paths License: MIT License File: node_modules/tsconfig-paths/LICENSE @@ -1569,6 +2591,13 @@ Copyright: Copyright (c) George Zahariev Source: git://github.com/gkz/type-check.git Link: https://github.com/gkz/type-check ----------- +type-detect +License: MIT +License File: node_modules/type-detect/LICENSE +Copyright: Copyright (c) 2013 Jake Luer <****@***************.***> (http://alogicalparadox.com) +Source: git+ssh://git@github.com/chaijs/type-detect.git +Link: git+ssh://git@github.com/chaijs/type-detect.git +----------- type-fest License: (MIT OR CC0-1.0) License File: node_modules/type-fest/license @@ -1604,6 +2633,12 @@ Copyright: Copyright (c) 2020 Inspect JS Source: git+https://github.com/inspect-js/typed-array-length.git Link: https://github.com/inspect-js/typed-array-length#readme ----------- +typescript +License: Apache-2.0 +License File: node_modules/typescript/LICENSE.txt +Source: https://github.com/microsoft/TypeScript.git +Link: https://www.typescriptlang.org/ +----------- uc.micro License: MIT License File: node_modules/uc.micro/LICENSE.txt @@ -1617,6 +2652,27 @@ Copyright: Copyright (c) 2019 Jordan Harband Source: git+https://github.com/ljharb/unbox-primitive.git Link: https://github.com/ljharb/unbox-primitive#readme ----------- +undici-types +License: MIT +License File: node_modules/undici-types/LICENSE +Copyright: Copyright (c) Matteo Collina and Undici contributors +Source: git+https://github.com/nodejs/undici.git +Link: https://undici.nodejs.org +----------- +universalify +License: MIT +License File: node_modules/universalify/LICENSE +Copyright: Copyright (c) 2017, Ryan Zimmerman <*******@*******.***> +Source: git+https://github.com/RyanZim/universalify.git +Link: https://github.com/RyanZim/universalify#readme +----------- +update-browserslist-db +License: MIT +License File: node_modules/update-browserslist-db/LICENSE +Copyright: Copyright 2022 Andrey Sitnik <******@******.**> and other contributors +Source: browserslist/update-db +Link: browserslist/update-db +----------- uri-js License: BSD-2-Clause License File: node_modules/uri-js/LICENSE @@ -1624,6 +2680,27 @@ Copyright: Copyright 2011 Gary Court. All rights reserved. Source: http://github.com/garycourt/uri-js Link: https://github.com/garycourt/uri-js ----------- +url-parse +License: MIT +License File: node_modules/url-parse/LICENSE +Copyright: Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors. +Source: https://github.com/unshiftio/url-parse.git +Link: https://github.com/unshiftio/url-parse.git +----------- +v8-compile-cache-lib +License: MIT +License File: node_modules/v8-compile-cache-lib/LICENSE +Copyright: Copyright (c) 2019 Andres Suarez +Source: https://github.com/cspotcode/v8-compile-cache-lib.git +Link: https://github.com/cspotcode/v8-compile-cache-lib.git +----------- +v8-to-istanbul +License: ISC +License File: node_modules/v8-to-istanbul/LICENSE.txt +Copyright: Copyright (c) 2017, Contributors +Source: istanbuljs/v8-to-istanbul +Link: istanbuljs/v8-to-istanbul +----------- validate-npm-package-license License: Apache-2.0 License File: node_modules/validate-npm-package-license/LICENSE @@ -1637,6 +2714,46 @@ Copyright: Copyright (C) 2016 by Marijn Haverbeke <******@*********.******> and Source: git+https://github.com/marijnh/w3c-keyname.git Link: https://github.com/marijnh/w3c-keyname#readme ----------- +w3c-xmlserializer +License: MIT +License File: node_modules/w3c-xmlserializer/LICENSE.md +Source: jsdom/w3c-xmlserializer +Link: jsdom/w3c-xmlserializer +----------- +walker +License: Apache-2.0 +License File: node_modules/walker/LICENSE +Copyright: Copyright 2013 Naitik Shah +Source: https://github.com/daaku/nodejs-walker +Link: https://github.com/daaku/nodejs-walker +----------- +webidl-conversions +License: BSD-2-Clause +License File: node_modules/webidl-conversions/LICENSE.md +Copyright: Copyright (c) 2014, Domenic Denicola +All rights reserved. +Source: jsdom/webidl-conversions +Link: jsdom/webidl-conversions +----------- +whatwg-encoding +License: MIT +License File: node_modules/whatwg-encoding/LICENSE.txt +Source: jsdom/whatwg-encoding +Link: jsdom/whatwg-encoding +----------- +whatwg-mimetype +License: MIT +License File: node_modules/whatwg-mimetype/LICENSE.txt +Source: jsdom/whatwg-mimetype +Link: jsdom/whatwg-mimetype +----------- +whatwg-url +License: MIT +License File: node_modules/whatwg-url/LICENSE.txt +Copyright: Copyright (c) Sebastian Mayr +Source: jsdom/whatwg-url +Link: jsdom/whatwg-url +----------- which-boxed-primitive License: MIT License File: node_modules/which-boxed-primitive/LICENSE @@ -1686,13 +2803,34 @@ Copyright: Copyright (c) Isaac Z. Schlueter and Contributors Source: https://github.com/npm/wrappy Link: https://github.com/npm/wrappy ----------- +write-file-atomic +License: ISC +License File: node_modules/write-file-atomic/LICENSE.md +Copyright: Copyright (c) 2015, Rebecca Turner +Source: https://github.com/npm/write-file-atomic.git +Link: https://github.com/npm/write-file-atomic +----------- ws License: MIT License File: node_modules/ws/LICENSE Copyright: Copyright (c) 2011 Einar Otto Stangvik <*******@*****.***> -Source: websockets/ws +Copyright (c) 2013 Arnout Kazemier and contributors +Copyright (c) 2016 Luigi Pinca and contributors +Source: git+https://github.com/websockets/ws.git Link: https://github.com/websockets/ws ----------- +xml-name-validator +License: Apache-2.0 +License File: node_modules/xml-name-validator/LICENSE.txt +Source: jsdom/xml-name-validator +Link: jsdom/xml-name-validator +----------- +xmlchars +License: MIT +License File: node_modules/xmlchars/LICENSE +Source: https://github.com/lddubeau/xmlchars.git +Link: https://github.com/lddubeau/xmlchars.git +----------- y18n License: ISC License File: node_modules/y18n/LICENSE @@ -1700,12 +2838,19 @@ Copyright: Copyright (c) 2015, Contributors Source: git@github.com:yargs/y18n.git Link: https://github.com/yargs/y18n ----------- +yallist +License: ISC +License File: node_modules/yallist/LICENSE +Copyright: Copyright (c) Isaac Z. Schlueter and Contributors +Source: git+https://github.com/isaacs/yallist.git +Link: git+https://github.com/isaacs/yallist.git +----------- yargs-parser License: ISC License File: node_modules/yargs-parser/LICENSE.txt Copyright: Copyright (c) 2016, Contributors -Source: git@github.com:yargs/yargs-parser.git -Link: git@github.com:yargs/yargs-parser.git +Source: https://github.com/yargs/yargs-parser.git +Link: https://github.com/yargs/yargs-parser.git ----------- yargs License: MIT @@ -1715,6 +2860,13 @@ Modified work Copyright 2014 Contributors (***@*****.***) Source: https://github.com/yargs/yargs.git Link: https://yargs.js.org/ ----------- +yn +License: MIT +License File: node_modules/yn/license +Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (sindresorhus.com) +Source: sindresorhus/yn +Link: sindresorhus/yn +----------- yocto-queue License: MIT License File: node_modules/yocto-queue/license @@ -1722,6 +2874,263 @@ Copyright: Copyright (c) Sindre Sorhus <************@*****.***> (https://sindres Source: sindresorhus/yocto-queue Link: sindresorhus/yocto-queue ----------- +@ampproject/remapping +License: Apache-2.0 +License File: node_modules/@ampproject/remapping/LICENSE +Source: git+https://github.com/ampproject/remapping.git +Link: git+https://github.com/ampproject/remapping.git +----------- +@babel/code-frame +License: MIT +License File: node_modules/@babel/code-frame/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-code-frame +----------- +@babel/compat-data +License: MIT +License File: node_modules/@babel/compat-data/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/core +License: MIT +License File: node_modules/@babel/core/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-core +----------- +@babel/generator +License: MIT +License File: node_modules/@babel/generator/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-generator +----------- +@babel/helper-compilation-targets +License: MIT +License File: node_modules/@babel/helper-compilation-targets/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/helper-module-imports +License: MIT +License File: node_modules/@babel/helper-module-imports/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-helper-module-imports +----------- +@babel/helper-module-transforms +License: MIT +License File: node_modules/@babel/helper-module-transforms/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-helper-module-transforms +----------- +@babel/helper-plugin-utils +License: MIT +License File: node_modules/@babel/helper-plugin-utils/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-helper-plugin-utils +----------- +@babel/helper-simple-access +License: MIT +License File: node_modules/@babel/helper-simple-access/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-helper-simple-access +----------- +@babel/helper-string-parser +License: MIT +License File: node_modules/@babel/helper-string-parser/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-helper-string-parser +----------- +@babel/helper-validator-identifier +License: MIT +License File: node_modules/@babel/helper-validator-identifier/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/helper-validator-option +License: MIT +License File: node_modules/@babel/helper-validator-option/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/helpers +License: MIT +License File: node_modules/@babel/helpers/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-helpers +----------- +@babel/highlight +License: MIT +License File: node_modules/@babel/highlight/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-highlight +----------- +@babel/parser +License: MIT +License File: node_modules/@babel/parser/LICENSE +Copyright: Copyright (C) 2012-2014 by various contributors (see AUTHORS) +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-parser +----------- +@babel/plugin-syntax-async-generators +License: MIT +License File: node_modules/@babel/plugin-syntax-async-generators/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-async-generators +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-async-generators +----------- +@babel/plugin-syntax-bigint +License: MIT +License File: node_modules/@babel/plugin-syntax-bigint/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-bigint +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-bigint +----------- +@babel/plugin-syntax-class-properties +License: MIT +License File: node_modules/@babel/plugin-syntax-class-properties/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-plugin-syntax-class-properties +----------- +@babel/plugin-syntax-class-static-block +License: MIT +License File: node_modules/@babel/plugin-syntax-class-static-block/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-plugin-syntax-class-static-block +----------- +@babel/plugin-syntax-import-attributes +License: MIT +License File: node_modules/@babel/plugin-syntax-import-attributes/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/plugin-syntax-import-meta +License: MIT +License File: node_modules/@babel/plugin-syntax-import-meta/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/plugin-syntax-json-strings +License: MIT +License File: node_modules/@babel/plugin-syntax-json-strings/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-json-strings +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-json-strings +----------- +@babel/plugin-syntax-jsx +License: MIT +License File: node_modules/@babel/plugin-syntax-jsx/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-plugin-syntax-jsx +----------- +@babel/plugin-syntax-logical-assignment-operators +License: MIT +License File: node_modules/@babel/plugin-syntax-logical-assignment-operators/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/plugin-syntax-nullish-coalescing-operator +License: MIT +License File: node_modules/@babel/plugin-syntax-nullish-coalescing-operator/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-nullish-coalescing-operator +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-nullish-coalescing-operator +----------- +@babel/plugin-syntax-numeric-separator +License: MIT +License File: node_modules/@babel/plugin-syntax-numeric-separator/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://github.com/babel/babel.git +----------- +@babel/plugin-syntax-object-rest-spread +License: MIT +License File: node_modules/@babel/plugin-syntax-object-rest-spread/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-object-rest-spread +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-object-rest-spread +----------- +@babel/plugin-syntax-optional-catch-binding +License: MIT +License File: node_modules/@babel/plugin-syntax-optional-catch-binding/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-optional-catch-binding +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-optional-catch-binding +----------- +@babel/plugin-syntax-optional-chaining +License: MIT +License File: node_modules/@babel/plugin-syntax-optional-chaining/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-optional-chaining +Link: https://github.com/babel/babel/tree/master/packages/babel-plugin-syntax-optional-chaining +----------- +@babel/plugin-syntax-private-property-in-object +License: MIT +License File: node_modules/@babel/plugin-syntax-private-property-in-object/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-plugin-syntax-private-property-in-object +----------- +@babel/plugin-syntax-top-level-await +License: MIT +License File: node_modules/@babel/plugin-syntax-top-level-await/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-plugin-syntax-top-level-await +----------- +@babel/plugin-syntax-typescript +License: MIT +License File: node_modules/@babel/plugin-syntax-typescript/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-plugin-syntax-typescript +----------- +@babel/template +License: MIT +License File: node_modules/@babel/template/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-template +----------- +@babel/traverse +License: MIT +License File: node_modules/@babel/traverse/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-traverse +----------- +@babel/types +License: MIT +License File: node_modules/@babel/types/LICENSE +Copyright: Copyright (c) 2014-present Sebastian McKenzie and other contributors +Source: https://github.com/babel/babel.git +Link: https://babel.dev/docs/en/next/babel-types +----------- +@bcoe/v8-coverage +License: MIT +License File: node_modules/@bcoe/v8-coverage/LICENSE.md +Source: git://github.com/demurgos/v8-coverage.git +Link: https://demurgos.github.io/v8-coverage +----------- @codemirror/autocomplete License: MIT License File: node_modules/@codemirror/autocomplete/LICENSE @@ -1834,6 +3243,13 @@ Copyright: Copyright (C) 2018-2021 by Marijn Haverbeke <******@*********.******> Source: https://github.com/codemirror/view.git Link: https://github.com/codemirror/view.git ----------- +@cspotcode/source-map-support +License: MIT +License File: node_modules/@cspotcode/source-map-support/LICENSE.md +Copyright: Copyright (c) 2014 Evan Wallace +Source: https://github.com/cspotcode/node-source-map-support +Link: https://github.com/cspotcode/node-source-map-support +----------- @esbuild/linux-x64 License: MIT Source: git+https://github.com/evanw/esbuild.git @@ -1885,6 +3301,153 @@ All rights reserved. Source: git+https://github.com/humanwhocodes/object-schema.git Link: https://github.com/humanwhocodes/object-schema#readme ----------- +@istanbuljs/load-nyc-config +License: ISC +License File: node_modules/@istanbuljs/load-nyc-config/LICENSE +Copyright: Copyright (c) 2019, Contributors +Source: git+https://github.com/istanbuljs/load-nyc-config.git +Link: https://github.com/istanbuljs/load-nyc-config#readme +----------- +@istanbuljs/schema +License: MIT +License File: node_modules/@istanbuljs/schema/LICENSE +Copyright: Copyright (c) 2019 CFWare, LLC +Source: git+https://github.com/istanbuljs/schema.git +Link: https://github.com/istanbuljs/schema#readme +----------- +@jest/console +License: MIT +License File: node_modules/@jest/console/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/core +License: MIT +License File: node_modules/@jest/core/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://jestjs.io/ +----------- +@jest/environment +License: MIT +License File: node_modules/@jest/environment/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/expect-utils +License: MIT +License File: node_modules/@jest/expect-utils/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/expect +License: MIT +License File: node_modules/@jest/expect/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/fake-timers +License: MIT +License File: node_modules/@jest/fake-timers/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/globals +License: MIT +License File: node_modules/@jest/globals/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/reporters +License: MIT +License File: node_modules/@jest/reporters/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://jestjs.io/ +----------- +@jest/schemas +License: MIT +License File: node_modules/@jest/schemas/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/source-map +License: MIT +License File: node_modules/@jest/source-map/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/test-result +License: MIT +License File: node_modules/@jest/test-result/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/test-sequencer +License: MIT +License File: node_modules/@jest/test-sequencer/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/transform +License: MIT +License File: node_modules/@jest/transform/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jest/types +License: MIT +License File: node_modules/@jest/types/LICENSE +Copyright: Copyright (c) Meta Platforms, Inc. and affiliates. +Source: https://github.com/jestjs/jest.git +Link: https://github.com/jestjs/jest.git +----------- +@jridgewell/gen-mapping +License: MIT +License File: node_modules/@jridgewell/gen-mapping/LICENSE +Copyright: Copyright 2022 Justin Ridgewell <**********@******.***> +Source: https://github.com/jridgewell/gen-mapping +Link: https://github.com/jridgewell/gen-mapping +----------- +@jridgewell/resolve-uri +License: MIT +License File: node_modules/@jridgewell/resolve-uri/LICENSE +Copyright: Copyright 2019 Justin Ridgewell <**********@******.***> +Source: https://github.com/jridgewell/resolve-uri +Link: https://github.com/jridgewell/resolve-uri +----------- +@jridgewell/set-array +License: MIT +License File: node_modules/@jridgewell/set-array/LICENSE +Copyright: Copyright 2022 Justin Ridgewell <**********@******.***> +Source: https://github.com/jridgewell/set-array +Link: https://github.com/jridgewell/set-array +----------- +@jridgewell/sourcemap-codec +License: MIT +License File: node_modules/@jridgewell/sourcemap-codec/LICENSE +Copyright: Copyright (c) 2015 Rich Harris +Source: git+https://github.com/jridgewell/sourcemap-codec.git +Link: git+https://github.com/jridgewell/sourcemap-codec.git +----------- +@jridgewell/trace-mapping +License: MIT +License File: node_modules/@jridgewell/trace-mapping/LICENSE +Copyright: Copyright 2022 Justin Ridgewell <******@*********.****> +Source: git+https://github.com/jridgewell/trace-mapping.git +Link: git+https://github.com/jridgewell/trace-mapping.git +----------- @lezer/common License: MIT License File: node_modules/@lezer/common/LICENSE @@ -1983,6 +3546,34 @@ Copyright: Copyright (c) Denis Malinochkin Source: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk Link: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk ----------- +@rtsao/scc +License: MIT +License File: node_modules/@rtsao/scc/LICENSE +Copyright: Copyright (c) 2019 Ryan Tsao +Source: rtsao/scc +Link: rtsao/scc +----------- +@sinclair/typebox +License: MIT +License File: node_modules/@sinclair/typebox/license +Source: https://github.com/sinclairzx81/typebox +Link: https://github.com/sinclairzx81/typebox +----------- +@sinonjs/commons +License: BSD-3-Clause +License File: node_modules/@sinonjs/commons/LICENSE +Copyright: Copyright (c) 2018, Sinon.JS +All rights reserved. +Source: git+https://github.com/sinonjs/commons.git +Link: https://github.com/sinonjs/commons#readme +----------- +@sinonjs/fake-timers +License: BSD-3-Clause +License File: node_modules/@sinonjs/fake-timers/LICENSE +Copyright: Copyright (c) 2010-2014, Christian Johansen, *********@*********.**. All rights reserved. +Source: https://github.com/sinonjs/fake-timers.git +Link: https://github.com/sinonjs/fake-timers +----------- @ssddanbrown/codemirror-lang-smarty License: MIT License File: node_modules/@ssddanbrown/codemirror-lang-smarty/LICENSE @@ -1993,11 +3584,151 @@ License: MIT License File: node_modules/@ssddanbrown/codemirror-lang-twig/LICENSE Copyright: Copyright (C) 2023 by Dan Brown, Marijn Haverbeke and others ----------- +@tootallnate/once +License: MIT +License File: node_modules/@tootallnate/once/LICENSE +Copyright: Copyright (c) 2020 Nathan Rajlich +Source: git://github.com/TooTallNate/once.git +Link: git://github.com/TooTallNate/once.git +----------- +@tsconfig/node10 +License: MIT +License File: node_modules/@tsconfig/node10/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/tsconfig/bases.git +Link: https://github.com/tsconfig/bases.git +----------- +@tsconfig/node12 +License: MIT +License File: node_modules/@tsconfig/node12/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/tsconfig/bases.git +Link: https://github.com/tsconfig/bases.git +----------- +@tsconfig/node14 +License: MIT +License File: node_modules/@tsconfig/node14/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/tsconfig/bases.git +Link: https://github.com/tsconfig/bases.git +----------- +@tsconfig/node16 +License: MIT +License File: node_modules/@tsconfig/node16/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/tsconfig/bases.git +Link: https://github.com/tsconfig/bases.git +----------- +@types/babel__core +License: MIT +License File: node_modules/@types/babel__core/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/babel__core +----------- +@types/babel__generator +License: MIT +License File: node_modules/@types/babel__generator/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/babel__generator +----------- +@types/babel__template +License: MIT +License File: node_modules/@types/babel__template/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/babel__template +----------- +@types/babel__traverse +License: MIT +License File: node_modules/@types/babel__traverse/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/babel__traverse +----------- +@types/graceful-fs +License: MIT +License File: node_modules/@types/graceful-fs/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/graceful-fs +----------- +@types/istanbul-lib-coverage +License: MIT +License File: node_modules/@types/istanbul-lib-coverage/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/istanbul-lib-coverage +----------- +@types/istanbul-lib-report +License: MIT +License File: node_modules/@types/istanbul-lib-report/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/istanbul-lib-report +----------- +@types/istanbul-reports +License: MIT +License File: node_modules/@types/istanbul-reports/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/istanbul-reports +----------- +@types/jest +License: MIT +License File: node_modules/@types/jest/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jest +----------- +@types/jsdom +License: MIT +License File: node_modules/@types/jsdom/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jsdom +----------- @types/json5 License: MIT Source: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git Link: https://www.github.com/DefinitelyTyped/DefinitelyTyped.git ----------- +@types/node +License: MIT +License File: node_modules/@types/node/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node +----------- +@types/stack-utils +License: MIT +License File: node_modules/@types/stack-utils/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/stack-utils +----------- +@types/tough-cookie +License: MIT +License File: node_modules/@types/tough-cookie/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/tough-cookie +----------- +@types/yargs-parser +License: MIT +License File: node_modules/@types/yargs-parser/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/yargs-parser +----------- +@types/yargs +License: MIT +License File: node_modules/@types/yargs/LICENSE +Copyright: Copyright (c) Microsoft Corporation. +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git +Link: https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/yargs +----------- @ungap/structured-clone License: ISC License File: node_modules/@ungap/structured-clone/LICENSE diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index a2d172e12c5..0a0321e2cac 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -128,7 +128,7 @@ Link: https://github.com/fruitcake/php-cors graham-campbell/result-type License: MIT License File: vendor/graham-campbell/result-type/LICENSE -Copyright: Copyright (c) 2020-2023 Graham Campbell <*****@**********.**.**> +Copyright: Copyright (c) 2020-2024 Graham Campbell <*****@**********.**.**> Source: https://github.com/GrahamCampbell/Result-Type.git Link: https://github.com/GrahamCampbell/Result-Type.git ----------- @@ -676,13 +676,6 @@ Copyright: Copyright (c) 2015-present Fabien Potencier Source: https://github.com/symfony/polyfill-mbstring.git Link: https://symfony.com ----------- -symfony/polyfill-php72 -License: MIT -License File: vendor/symfony/polyfill-php72/LICENSE -Copyright: Copyright (c) 2015-present Fabien Potencier -Source: https://github.com/symfony/polyfill-php72.git -Link: https://symfony.com ------------ symfony/polyfill-php80 License: MIT License File: vendor/symfony/polyfill-php80/LICENSE diff --git a/package-lock.json b/package-lock.json index cdc1f3ee0d5..68ec85fff8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ }, "devDependencies": { "@lezer/generator": "^1.5.1", - "babel-jest": "^29.7.0", "chokidar-cli": "^3.0", "esbuild": "^0.23.0", "eslint": "^8.55.0", @@ -44,7 +43,7 @@ "sass": "^1.69.5", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "5.6.*" } }, "node_modules/@ampproject/remapping": { @@ -111,18 +110,6 @@ "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", @@ -623,9 +610,9 @@ "dev": true }, "node_modules/@codemirror/autocomplete": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", - "integrity": "sha512-5DbOvBbY4qW5l57cjDsmmpDh3/TeK1vXfTHa+BUMrRzdWdcxKZ4U4V7vQaTtOpApNU4kLS4FQ6cINtLg245LXA==", + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.1.tgz", + "integrity": "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -640,9 +627,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.1.tgz", - "integrity": "sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.2.tgz", + "integrity": "sha512-Fq7eWOl1Rcbrfn6jD8FPCj9Auaxdm5nIK5RYOeW7ughnd/rY5AmPg6b+CfsG39ZHdwiwe8lde3q8uR7CF5S0yQ==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -741,9 +728,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz", - "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==", + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", + "integrity": "sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -762,9 +749,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz", - "integrity": "sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz", + "integrity": "sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -798,9 +785,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.33.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.33.0.tgz", - "integrity": "sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==", + "version": "6.34.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.34.1.tgz", + "integrity": "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -829,6 +816,262 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", @@ -845,6 +1088,118 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -861,9 +1216,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -893,22 +1248,22 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -1373,9 +1728,9 @@ "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" }, "node_modules/@lezer/css": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz", - "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.9.tgz", + "integrity": "sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -1414,9 +1769,9 @@ } }, "node_modules/@lezer/javascript": { - "version": "1.4.17", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz", - "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==", + "version": "1.4.18", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.18.tgz", + "integrity": "sha512-Y8BeHOt4LtcxJgXwadtfSeWPrh0XzklcCHnCVT+vOsxqH4gWmunP2ykX+VVOlM/dusyVyiNfG3lv0f10UK+mgA==", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -1680,9 +2035,9 @@ "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==", + "version": "22.7.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.3.tgz", + "integrity": "sha512-qXKfhXXqGTyBskvWEzJZPUxSslAiLaB6JGP1ic/XTH9ctGgzdgYguuLP1C601aRTSDNlLb0jbKqXjZ48GNraSA==", "dependencies": { "undici-types": "~6.19.2" } @@ -2160,9 +2515,9 @@ } }, "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==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", "dev": true, "funding": [ { @@ -2179,8 +2534,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", + "caniuse-lite": "^1.0.30001663", + "electron-to-chromium": "^1.5.28", "node-releases": "^2.0.18", "update-browserslist-db": "^1.1.0" }, @@ -2256,9 +2611,9 @@ } }, "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==", + "version": "1.0.30001664", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz", + "integrity": "sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g==", "dev": true, "funding": [ { @@ -2775,9 +3130,9 @@ } }, "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==", + "version": "1.5.29", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.29.tgz", + "integrity": "sha512-PF8n2AlIhCKXQ+gTpiJi0VhcHDb69kYX4MtCiivctc2QD3XuNZ/XIOlbGzt7WAjjEev0TtaH6Cu3arZExm5DOw==", "dev": true }, "node_modules/emittery": { @@ -3033,16 +3388,16 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -3127,9 +3482,9 @@ } }, "node_modules/eslint-module-utils": { - "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==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", "dev": true, "dependencies": { "debug": "^3.2.7" @@ -3518,6 +3873,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4570,15 +4939,6 @@ "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", @@ -4624,24 +4984,6 @@ } } }, - "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", @@ -4945,15 +5287,6 @@ "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", @@ -5153,27 +5486,6 @@ } } }, - "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", @@ -5217,15 +5529,15 @@ "dev": true }, "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, "bin": { "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, "node_modules/keyv": { @@ -5306,6 +5618,27 @@ "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", "dev": true }, + "node_modules/livereload/node_modules/ws": { + "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" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -5321,6 +5654,28 @@ "node": ">=4" } }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5957,16 +6312,21 @@ } }, "node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "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-better-errors": "^1.0.1" + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse5": { @@ -6473,12 +6833,12 @@ "dev": true }, "node_modules/sass": { - "version": "1.78.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz", - "integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==", + "version": "1.79.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.3.tgz", + "integrity": "sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==", "dev": true, "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", + "chokidar": "^4.0.0", "immutable": "^4.0.0", "source-map-js": ">=0.6.2 <2.0.0" }, @@ -6489,6 +6849,34 @@ "node": ">=14.0.0" } }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz", + "integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==", + "dev": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -6639,9 +7027,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -6842,12 +7230,12 @@ } }, "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "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": ">=4" + "node": ">=8" } }, "node_modules/strip-final-newline": { @@ -7026,18 +7414,6 @@ } } }, - "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", @@ -7050,15 +7426,6 @@ "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", @@ -7114,6 +7481,27 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7221,9 +7609,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -7268,9 +7656,9 @@ } }, "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==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "dev": true, "funding": [ { @@ -7287,8 +7675,8 @@ } ], "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -7562,16 +7950,16 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -7628,13 +8016,12 @@ } }, "node_modules/yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "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, - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "engines": { + "node": ">=12" } }, "node_modules/yargs/node_modules/find-up": { @@ -7698,6 +8085,16 @@ "node": ">=4" } }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 46a41218351..42531194ec3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ }, "devDependencies": { "@lezer/generator": "^1.5.1", - "babel-jest": "^29.7.0", "chokidar-cli": "^3.0", "esbuild": "^0.23.0", "eslint": "^8.55.0", @@ -33,7 +32,7 @@ "sass": "^1.69.5", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "5.6.*" }, "dependencies": { "@codemirror/commands": "^6.3.2", diff --git a/resources/js/wysiwyg/lexical/selection/lexical-node.ts b/resources/js/wysiwyg/lexical/selection/lexical-node.ts index 82f7d330ed1..1f7e4c6dad6 100644 --- a/resources/js/wysiwyg/lexical/selection/lexical-node.ts +++ b/resources/js/wysiwyg/lexical/selection/lexical-node.ts @@ -262,7 +262,7 @@ function $patchStyle( } return styles; }, - {...prevStyles} || {}, + {...prevStyles}, ); const newCSSText = getCSSFromStyleObject(newStyles); target.setStyle(newCSSText); diff --git a/version b/version index 522220af0c3..baf91f15e00 100644 --- a/version +++ b/version @@ -1 +1 @@ -v24.03-dev +v24.10-dev From eec639d84ea4396d91a0291c72f78e4c877a956b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Sep 2024 13:57:39 +0100 Subject: [PATCH 137/800] Maintenance: Fixed js lint and SCSS build warnings --- resources/js/markdown/actions.js | 2 +- resources/sass/_content.scss | 16 ++++++++-------- resources/sass/_text.scss | 7 +------ 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/resources/js/markdown/actions.js b/resources/js/markdown/actions.js index 73040a57b1e..86e25532802 100644 --- a/resources/js/markdown/actions.js +++ b/resources/js/markdown/actions.js @@ -1,4 +1,4 @@ -import * as DrawIO from '../services/drawio'; +import * as DrawIO from '../services/drawio.ts'; export class Actions { diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 0a1a49151a9..7d130bb0c24 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -163,29 +163,29 @@ body .page-content img, } &.success { @include lightDark(border-color, $positive, $positive-dark); - @include lightDark(background-color, lighten($positive, 68%), darken($positive-dark, 36%)); - @include lightDark(color, darken($positive, 16%), $positive-dark); + @include lightDark(background-color, #eafdeb, #122913); + @include lightDark(color, #063409, $positive-dark); } &.success:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMzNzZjMzkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0yIDE1bC01LTUgMS40MS0xLjQxTDEwIDE0LjE3bDcuNTktNy41OUwxOSA4bC05IDl6Ii8+PC9zdmc+"); } &.danger { @include lightDark(border-color, $negative, $negative-dark); - @include lightDark(background-color, lighten($negative, 56%), darken($negative-dark, 55%)); - @include lightDark(color, darken($negative, 20%), $negative-dark); + @include lightDark(background-color, #fcdbdb, #250505); + @include lightDark(color, #4d0706, $negative-dark); } &.danger:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiOTE4MTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0xNS43MyAzSDguMjdMMyA4LjI3djcuNDZMOC4yNyAyMWg3LjQ2TDIxIDE1LjczVjguMjdMMTUuNzMgM3pNMTIgMTcuM2MtLjcyIDAtMS4zLS41OC0xLjMtMS4zIDAtLjcyLjU4LTEuMyAxLjMtMS4zLjcyIDAgMS4zLjU4IDEuMyAxLjMgMCAuNzItLjU4IDEuMy0xLjMgMS4zem0xLTQuM2gtMlY3aDJ2NnoiLz4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg=="); } &.info { @include lightDark(border-color, $info, $info-dark); - @include lightDark(color, darken($info, 20%), $info-dark); - @include lightDark(background-color, lighten($info, 50%), darken($info-dark, 34%)); + @include lightDark(background-color, #d3efff, #001825); + @include lightDark(color, #01466c, $info-dark); } &.warning { @include lightDark(border-color, $warning, $warning-dark); - @include lightDark(background-color, lighten($warning, 50%), darken($warning-dark, 50%)); - @include lightDark(color, darken($warning, 20%), $warning-dark); + @include lightDark(background-color, #fee3d3, #30170a); + @include lightDark(color, #6a2802, $warning-dark); } &.warning:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiNjUzMWMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEgMjFoMjJMMTIgMiAxIDIxem0xMi0zaC0ydi0yaDJ2MnptMC00aC0ydi00aDJ2NHoiLz48L3N2Zz4="); diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 9acfe2ceed3..56e66195c38 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -44,11 +44,6 @@ h1, h2, h3, h4, h5, h6 { display: block; font-family: var(--font-heading, var(--font-body)); @include lightDark(color, #222, #BBB); - .subheader { - font-size: 0.5em; - line-height: 1em; - color: lighten($text-dark, 32%); - } } h5 { @@ -223,7 +218,7 @@ blockquote { position: absolute; top: $-s; left: $-s; - color: lighten($text-dark, 20%); + color: #777; } } From abda9bc00a6d64a352ff8896887d95b1768c6961 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Sep 2024 14:21:12 +0100 Subject: [PATCH 138/800] PHP Dependancies: Updated packages pending major version changes Closes #5222 --- composer.json | 4 +- composer.lock | 227 +++++++++++++++++++++++++------------------------- 2 files changed, 117 insertions(+), 114 deletions(-) diff --git a/composer.json b/composer.json index b90ab224eaa..5c54774f1e2 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,9 @@ "ext-json": "*", "ext-mbstring": "*", "ext-xml": "*", - "bacon/bacon-qr-code": "^2.0", + "bacon/bacon-qr-code": "^3.0", "doctrine/dbal": "^3.5", - "dompdf/dompdf": "^2.0", + "dompdf/dompdf": "^3.0", "guzzlehttp/guzzle": "^7.4", "intervention/image": "^3.5", "knplabs/knp-snappy": "^1.5", diff --git a/composer.lock b/composer.lock index 1abbf7e9483..d86ec0b33d6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "97259e40ffe5518cfcdf1e32eacbb175", + "content-hash": "b24a1daf815b6910b51a2acc5e2d38e7", "packages": [ { "name": "aws/aws-crt-php", @@ -160,28 +160,28 @@ }, { "name": "bacon/bacon-qr-code", - "version": "2.0.8", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + "reference": "510de6eca6248d77d31b339d62437cc995e2fb41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/510de6eca6248d77d31b339d62437cc995e2fb41", + "reference": "510de6eca6248d77d31b339d62437cc995e2fb41", "shasum": "" }, "require": { "dasprid/enum": "^1.0.3", "ext-iconv": "*", - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "phly/keep-a-changelog": "^2.1", - "phpunit/phpunit": "^7 | ^8 | ^9", - "spatie/phpunit-snapshot-assertions": "^4.2.9", - "squizlabs/php_codesniffer": "^3.4" + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || 11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "squizlabs/php_codesniffer": "^3.9" }, "suggest": { "ext-imagick": "to generate QR code images" @@ -208,9 +208,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.0" }, - "time": "2022-12-07T17:46:57+00:00" + "time": "2024-04-18T11:16:25+00:00" }, { "name": "brick/math", @@ -980,32 +980,34 @@ }, { "name": "dompdf/dompdf", - "version": "v2.0.8", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "c20247574601700e1f7c8dab39310fca1964dc52" + "reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/c20247574601700e1f7c8dab39310fca1964dc52", - "reference": "c20247574601700e1f7c8dab39310fca1964dc52", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/fbc7c5ee5d94f7a910b78b43feb7931b7f971b59", + "reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59", "shasum": "" }, "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", "ext-dom": "*", "ext-mbstring": "*", "masterminds/html5": "^2.0", - "phenx/php-font-lib": ">=0.5.4 <1.0.0", - "phenx/php-svg-lib": ">=0.5.2 <1.0.0", "php": "^7.1 || ^8.0" }, "require-dev": { + "ext-gd": "*", "ext-json": "*", "ext-zip": "*", "mockery/mockery": "^1.3", - "phpunit/phpunit": "^7.5 || ^8 || ^9", - "squizlabs/php_codesniffer": "^3.5" + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" }, "suggest": { "ext-gd": "Needed to process images", @@ -1036,9 +1038,100 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v2.0.8" + "source": "https://github.com/dompdf/dompdf/tree/v3.0.0" + }, + "time": "2024-04-29T14:01:28+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "991d6a954f6bbd7e41022198f00586b230731441" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/991d6a954f6bbd7e41022198f00586b230731441", + "reference": "991d6a954f6bbd7e41022198f00586b230731441", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:40:38+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" }, - "time": "2024-04-29T13:06:17+00:00" + "time": "2024-04-29T13:26:35+00:00" }, { "name": "dragonmantank/cron-expression", @@ -3930,96 +4023,6 @@ }, "time": "2020-10-15T08:29:30+00:00" }, - { - "name": "phenx/php-font-lib", - "version": "0.5.6", - "source": { - "type": "git", - "url": "https://github.com/dompdf/php-font-lib.git", - "reference": "a1681e9793040740a405ac5b189275059e2a9863" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a1681e9793040740a405ac5b189275059e2a9863", - "reference": "a1681e9793040740a405ac5b189275059e2a9863", - "shasum": "" - }, - "require": { - "ext-mbstring": "*" - }, - "require-dev": { - "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "FontLib\\": "src/FontLib" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-2.1-or-later" - ], - "authors": [ - { - "name": "Fabien Ménager", - "email": "fabien.menager@gmail.com" - } - ], - "description": "A library to read, parse, export and make subsets of different types of font files.", - "homepage": "https://github.com/PhenX/php-font-lib", - "support": { - "issues": "https://github.com/dompdf/php-font-lib/issues", - "source": "https://github.com/dompdf/php-font-lib/tree/0.5.6" - }, - "time": "2024-01-29T14:45:26+00:00" - }, - { - "name": "phenx/php-svg-lib", - "version": "0.5.4", - "source": { - "type": "git", - "url": "https://github.com/dompdf/php-svg-lib.git", - "reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/46b25da81613a9cf43c83b2a8c2c1bdab27df691", - "reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": "^7.1 || ^8.0", - "sabberworm/php-css-parser": "^8.4" - }, - "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Svg\\": "src/Svg" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0-or-later" - ], - "authors": [ - { - "name": "Fabien Ménager", - "email": "fabien.menager@gmail.com" - } - ], - "description": "A library to read, parse and export to PDF SVG files.", - "homepage": "https://github.com/PhenX/php-svg-lib", - "support": { - "issues": "https://github.com/dompdf/php-svg-lib/issues", - "source": "https://github.com/dompdf/php-svg-lib/tree/0.5.4" - }, - "time": "2024-04-08T12:52:34+00:00" - }, { "name": "phpoption/phpoption", "version": "1.9.3", From 42264f402d2733fdf7e1260c6731455d85da12b1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Sep 2024 16:01:01 +0100 Subject: [PATCH 139/800] CSS: Fixed floating search icon on mobile Also updated styles to use logical elements instead of conditional rules for altered search boxes. Related to #2504 --- resources/sass/_forms.scss | 8 ++------ resources/views/search/all.blade.php | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index a6f890e3cc4..67df4171499 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -441,12 +441,8 @@ input[type=color] { padding: 0; cursor: pointer; position: absolute; - left: 8px; - top: 9px; - @include rtl { - right: 8px; - left: auto; - } + inset-inline-start: 8px; + top: 10px; } input { display: block; diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php index 64325d4c1bf..2d410dbd1b0 100644 --- a/resources/views/search/all.blade.php +++ b/resources/views/search/all.blade.php @@ -79,7 +79,9 @@
                                  {{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}
                                  From 6103a22febd839fa76a3f9a54058b66a14906b35 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 27 Sep 2024 16:33:58 +0100 Subject: [PATCH 140/800] Exports: Made pdf command timeout configurable Added test to cover. For #5119 --- .env.example.complete | 5 +++++ app/Config/exports.php | 4 ++++ app/Entities/Tools/PdfGenerator.php | 11 +++++++++-- tests/Entity/ExportTest.php | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index 1a4f421f024..6b4936648f0 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -334,6 +334,11 @@ EXPORT_PAGE_SIZE=a4 # Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}" EXPORT_PDF_COMMAND=false +# Export PDF Command Timeout +# The number of seconds that the export PDF command will run before a timeout occurs. +# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf. +EXPORT_PDF_COMMAND_TIMEOUT=15 + # Set path to wkhtmltopdf binary for PDF generation. # Can be 'false' or a path path like: '/home/bins/wkhtmltopdf' # When false, BookStack will attempt to find a wkhtmltopdf in the application diff --git a/app/Config/exports.php b/app/Config/exports.php index 88dc08cba35..ba535ca7e4e 100644 --- a/app/Config/exports.php +++ b/app/Config/exports.php @@ -29,6 +29,10 @@ // Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}" 'pdf_command' => env('EXPORT_PDF_COMMAND', false), + // The amount of time allowed for PDF generation command to run + // before the process times out and is stopped. + 'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15), + // 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support. 'snappy' => [ 'pdf_binary' => env('WKHTMLTOPDF', false), diff --git a/app/Entities/Tools/PdfGenerator.php b/app/Entities/Tools/PdfGenerator.php index 7c6dfaa6e8b..79cd1b02f7f 100644 --- a/app/Entities/Tools/PdfGenerator.php +++ b/app/Entities/Tools/PdfGenerator.php @@ -5,6 +5,7 @@ use BookStack\Exceptions\PdfExportException; use Knp\Snappy\Pdf as SnappyPdf; use Dompdf\Dompdf; +use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; class PdfGenerator @@ -85,9 +86,15 @@ protected function renderUsingCommand(string $html): string file_put_contents($inputHtml, $html); + $timeout = intval(config('exports.pdf_command_timeout')); $process = Process::fromShellCommandline($command); - $process->setTimeout(15); - $process->run(); + $process->setTimeout($timeout); + + try { + $process->run(); + } catch (ProcessTimedOutException $e) { + throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)"); + } if (!$process->isSuccessful()) { throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}"); diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 040f69013a5..7aafa3b7927 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -529,6 +529,22 @@ public function test_pdf_command_option_errors_if_command_returns_error_status() }, PdfExportException::class); } + public function test_pdf_command_timout_option_limits_export_time() + { + $page = $this->entities->page(); + $command = 'php -r \'sleep(4);\''; + config()->set('exports.pdf_command', $command); + config()->set('exports.pdf_command_timeout', 1); + + $this->assertThrows(function () use ($page) { + $start = time(); + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + + $this->assertTrue(time() < ($start + 3)); + }, PdfExportException::class, + "PDF Export via command failed due to timeout at 1 second(s)"); + } + public function test_html_exports_contain_csp_meta_tag() { $entities = [ From 89f84c9a95babfa5bf8b3d38809375e59cab80ce Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 29 Sep 2024 14:36:41 +0100 Subject: [PATCH 141/800] Pages: Updated editor field to always be set - Migration for setting on existing pages - Added test to cover simple new page scenario For #5117 --- app/Entities/Repos/PageRepo.php | 6 ++- ...4_09_29_140340_ensure_editor_value_set.php | 48 +++++++++++++++++++ tests/Entity/PageEditorTest.php | 15 ++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2024_09_29_140340_ensure_editor_value_set.php diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index ce7e34ae1ec..1bc15392cec 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -11,7 +11,6 @@ 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; @@ -44,6 +43,7 @@ public function getNewDraftPage(Entity $parent) 'owned_by' => user()->id, 'updated_by' => user()->id, 'draft' => true, + 'editor' => PageEditorType::getSystemDefault()->value, ]); if ($parent instanceof Chapter) { @@ -146,8 +146,10 @@ protected function updateTemplateStatusAndContentFromInput(Page $page, array $in $pageContent->setNewHTML($input['html'], user()); } - if ($newEditor !== $currentEditor && userCan('editor-change')) { + if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) { $page->editor = $newEditor->value; + } elseif (empty($page->editor)) { + $page->editor = $defaultEditor->value; } } diff --git a/database/migrations/2024_09_29_140340_ensure_editor_value_set.php b/database/migrations/2024_09_29_140340_ensure_editor_value_set.php new file mode 100644 index 00000000000..786322b3c63 --- /dev/null +++ b/database/migrations/2024_09_29_140340_ensure_editor_value_set.php @@ -0,0 +1,48 @@ +where('setting_key', '=', 'app-editor') + ->first() + ->value ?? 'wysiwyg'; + $default = ($default === 'markdown') ? 'markdown' : 'wysiwyg'; + + // We set it to 'markdown' for pages currently with markdown content + DB::table('pages') + ->where('editor', '=', '') + ->where('markdown', '!=', '') + ->update(['editor' => 'markdown']); + + // We set it to 'wysiwyg' where we have HTML but no markdown + DB::table('pages') + ->where('editor', '=', '') + ->where('markdown', '=', '') + ->where('html', '!=', '') + ->update(['editor' => 'wysiwyg']); + + // Otherwise, where still empty, set to the current default + DB::table('pages') + ->where('editor', '=', '') + ->update(['editor' => $default]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Can't reverse due to not knowing what would have been empty before + } +}; diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index 9340249568c..21abd4ba1e6 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -24,6 +24,21 @@ public function test_default_editor_is_wysiwyg_for_new_pages() $this->withHtml($this->followRedirects($resp))->assertElementExists('#html-editor'); } + public function test_editor_set_for_new_pages() + { + $book = $this->page->book; + + $this->asEditor()->get($book->getUrl('/create-page')); + $newPage = $book->pages()->orderBy('id', 'desc')->first(); + $this->assertEquals('wysiwyg', $newPage->editor); + + $this->setSettings(['app-editor' => PageEditorType::Markdown->value]); + + $this->asEditor()->get($book->getUrl('/create-page')); + $newPage = $book->pages()->orderBy('id', 'desc')->first(); + $this->assertEquals('markdown', $newPage->editor); + } + public function test_markdown_setting_shows_markdown_editor_for_new_pages() { $this->setSettings(['app-editor' => PageEditorType::Markdown->value]); From d12e8ec92369959ce2f3f43c4e047ab28e735538 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 29 Sep 2024 16:41:18 +0100 Subject: [PATCH 142/800] Users: Improved user response for failed invite sending Added specific handling to show relevant error message when user creation fails due to invite sending errors, while also returning user to the form with previous input. Includes test to cover. For #5195 --- app/Access/UserInviteException.php | 10 ++++++++ app/Access/UserInviteService.php | 8 ++++++- app/Users/Controllers/UserController.php | 15 +++++++++--- app/Users/UserRepo.php | 2 ++ lang/en/errors.php | 1 + tests/User/UserManagementTest.php | 29 ++++++++++++++++++++---- 6 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 app/Access/UserInviteException.php diff --git a/app/Access/UserInviteException.php b/app/Access/UserInviteException.php new file mode 100644 index 00000000000..70e7a787241 --- /dev/null +++ b/app/Access/UserInviteException.php @@ -0,0 +1,10 @@ +deleteByUser($user); $token = $this->createTokenForUser($user); - $user->notify(new UserInviteNotification($token)); + + try { + $user->notify(new UserInviteNotification($token)); + } catch (\Exception $exception) { + throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception); + } } } diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 185d6101c61..00bbe61186e 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -3,6 +3,7 @@ namespace BookStack\Users\Controllers; use BookStack\Access\SocialDriverManager; +use BookStack\Access\UserInviteException; use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\UserUpdateException; use BookStack\Http\Controller; @@ -14,6 +15,7 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rules\Password; use Illuminate\Validation\ValidationException; @@ -91,9 +93,16 @@ public function store(Request $request) $validated = $this->validate($request, array_filter($validationRules)); - DB::transaction(function () use ($validated, $sendInvite) { - $this->userRepo->create($validated, $sendInvite); - }); + try { + DB::transaction(function () use ($validated, $sendInvite) { + $this->userRepo->create($validated, $sendInvite); + dd('post-create'); + }); + } catch (UserInviteException $e) { + Log::error("Failed to send user invite with error: {$e->getMessage()}"); + $this->showErrorNotification(trans('errors.users_could_not_send_invite')); + return redirect('/settings/users/create')->withInput(); + } return redirect('/settings/users'); } diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 32e23ecdec1..5c8ace8faca 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -2,6 +2,7 @@ namespace BookStack\Users; +use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; use BookStack\Entities\EntityProvider; @@ -83,6 +84,7 @@ public function createWithoutActivity(array $data, bool $emailConfirmed = false) * As per "createWithoutActivity" but records a "create" activity. * * @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data + * @throws UserInviteException */ public function create(array $data, bool $sendInvite = false): User { diff --git a/lang/en/errors.php b/lang/en/errors.php index 752eb5672f1..9c40aa9ed33 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -78,6 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'You cannot delete the only admin', 'users_cannot_delete_guest' => 'You cannot delete the guest user', + 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', // Roles 'role_cannot_be_edited' => 'This role cannot be edited', diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index 93d35f5d047..8fe855afa77 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -2,6 +2,7 @@ namespace Tests\User; +use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; use BookStack\Uploads\Image; @@ -229,7 +230,7 @@ public function test_user_creation_is_not_performed_if_the_invitation_sending_fa // Simulate an invitation sending failure $this->mock(UserInviteService::class, function (MockInterface $mock) { - $mock->shouldReceive('sendInvitation')->once()->andThrow(RuntimeException::class); + $mock->shouldReceive('sendInvitation')->once()->andThrow(UserInviteException::class); }); $this->asAdmin()->post('/settings/users/create', [ @@ -247,22 +248,42 @@ public function test_user_create_activity_is_not_persisted_if_the_invitation_sen { /** @var User $user */ $user = User::factory()->make(); - $adminRole = Role::getRole('admin'); $this->mock(UserInviteService::class, function (MockInterface $mock) { - $mock->shouldReceive('sendInvitation')->once()->andThrow(RuntimeException::class); + $mock->shouldReceive('sendInvitation')->once()->andThrow(UserInviteException::class); }); $this->asAdmin()->post('/settings/users/create', [ 'name' => $user->name, 'email' => $user->email, 'send_invite' => 'true', - 'roles[' . $adminRole->id . ']' => 'true', ]); $this->assertDatabaseMissing('activities', ['type' => 'USER_CREATE']); } + public function test_return_to_form_with_warning_if_the_invitation_sending_fails() + { + $logger = $this->withTestLogger(); + /** @var User $user */ + $user = User::factory()->make(); + + $this->mock(UserInviteService::class, function (MockInterface $mock) { + $mock->shouldReceive('sendInvitation')->once()->andThrow(UserInviteException::class); + }); + + $resp = $this->asAdmin()->post('/settings/users/create', [ + 'name' => $user->name, + 'email' => $user->email, + 'send_invite' => 'true', + ]); + + $resp->assertRedirect('/settings/users/create'); + $this->assertSessionError('Could not create user since invite email failed to send'); + $this->assertEquals($user->email, session()->getOldInput('email')); + $this->assertTrue($logger->hasErrorThatContains('Failed to send user invite with error:')); + } + public function test_user_create_update_fails_if_locale_is_invalid() { $user = $this->users->editor(); From f58335474846aeb06922560bab7303d14298d1cf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 29 Sep 2024 16:50:48 +0100 Subject: [PATCH 143/800] Maintenance: Removed stray dd from last commit --- app/Users/Controllers/UserController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 00bbe61186e..b5dbaa83286 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -96,7 +96,6 @@ public function store(Request $request) try { DB::transaction(function () use ($validated, $sendInvite) { $this->userRepo->create($validated, $sendInvite); - dd('post-create'); }); } catch (UserInviteException $e) { Log::error("Failed to send user invite with error: {$e->getMessage()}"); From a5b031f9060141364ed9152eacdb9b494cfa76a7 Mon Sep 17 00:00:00 2001 From: Maximilian Walter Date: Sat, 14 Sep 2024 15:43:46 +0200 Subject: [PATCH 144/800] Translatable description for OpenSearch XML --- lang/en/common.php | 3 +++ resources/views/misc/opensearch.blade.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lang/en/common.php b/lang/en/common.php index 266174eed9d..b05169bb2c4 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -107,4 +107,7 @@ // Not directly used but available for convenience to users. 'privacy_policy' => 'Privacy Policy', 'terms_of_service' => 'Terms of Service', + + // OpenSearch + 'opensearch_description' => 'Search :appName', ]; diff --git a/resources/views/misc/opensearch.blade.php b/resources/views/misc/opensearch.blade.php index e83e83c12fe..366c237b22a 100644 --- a/resources/views/misc/opensearch.blade.php +++ b/resources/views/misc/opensearch.blade.php @@ -1,6 +1,6 @@ {{ mb_strimwidth(setting('app-name'), 0, 16) }} - Search {{ setting('app-name') }} + {{ trans('common.opensearch_description', ['appName' => setting('app-name')]) }} {{ setting('app-icon') ?: url('/icon.png') }} {{ setting('app-icon-180') ?: url('/icon-180.png') }} {{ setting('app-icon-128') ?: url('/icon-128.png') }} From 1302e3c959158be0e5a2a3b34343f9cd828544a3 Mon Sep 17 00:00:00 2001 From: Maximilian Walter Date: Sat, 14 Sep 2024 16:13:17 +0200 Subject: [PATCH 145/800] Add missing XML declaration to OpenSearch endpoint --- resources/views/misc/opensearch.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/misc/opensearch.blade.php b/resources/views/misc/opensearch.blade.php index 366c237b22a..5c1503fc487 100644 --- a/resources/views/misc/opensearch.blade.php +++ b/resources/views/misc/opensearch.blade.php @@ -1,3 +1,4 @@ + {{ mb_strimwidth(setting('app-name'), 0, 16) }} {{ trans('common.opensearch_description', ['appName' => setting('app-name')]) }} From 2f74cfb42c90785f9f1735319af28727cdec407a Mon Sep 17 00:00:00 2001 From: Maximilian Walter Date: Sat, 14 Sep 2024 16:21:01 +0200 Subject: [PATCH 146/800] Add test for OpenSearch endpoint --- tests/OpensearchTest.php | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/OpensearchTest.php diff --git a/tests/OpensearchTest.php b/tests/OpensearchTest.php new file mode 100644 index 00000000000..a68076b437d --- /dev/null +++ b/tests/OpensearchTest.php @@ -0,0 +1,42 @@ +get('/opensearch.xml'); + $resp->assertOk(); + + $html = $this->withHtml($resp); + + $html->assertElementExists('OpenSearchDescription > ShortName'); + $html->assertElementContains('OpenSearchDescription > ShortName', mb_strimwidth($appName, 0, 16)); + + $html->assertElementExists('OpenSearchDescription > Description'); + $html->assertElementContains('OpenSearchDescription > Description', trans('common.opensearch_description', [ + 'appName' => $appName, + ])); + + $html->assertElementExists('OpenSearchDescription > Image'); + + $html->assertElementExists('OpenSearchDescription > Url[rel="results"][template="' . htmlspecialchars($resultUrl) . '"]'); + $html->assertElementExists('OpenSearchDescription > Url[rel="self"][template="' . htmlspecialchars($selfUrl) . '"]'); + } + + public function test_opensearch_linked_to_from_home() + { + $appName = setting('app-name'); + $endpointUrl = url('/opensearch.xml'); + + $resp = $this->asViewer()->get('/'); + $html = $this->withHtml($resp); + + $html->assertElementExists('head > link[rel="search"][type="application/opensearchdescription+xml"][title="' . htmlspecialchars($appName) . '"][href="' . htmlspecialchars($endpointUrl) . '"]'); + } +} From 8bc6e75319583298e1f19df5441dc820772f3666 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 30 Sep 2024 16:47:55 +0100 Subject: [PATCH 147/800] Code Blocks: Added SAS and R language options For #5206 --- resources/js/code/languages.js | 2 ++ resources/js/code/legacy-modes.mjs | 2 ++ resources/views/pages/parts/code-editor.blade.php | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/js/code/languages.js b/resources/js/code/languages.js index ef317334c2b..da9631a138a 100644 --- a/resources/js/code/languages.js +++ b/resources/js/code/languages.js @@ -71,10 +71,12 @@ const modeMap = { ocaml: () => legacyLoad('oCaml'), py: () => legacyLoad('python'), python: () => legacyLoad('python'), + r: () => legacyLoad('r'), rb: () => legacyLoad('ruby'), rs: () => legacyLoad('rust'), ruby: () => legacyLoad('ruby'), rust: () => legacyLoad('rust'), + sas: () => legacyLoad('sas'), scala: () => legacyLoad('scala'), scheme: () => legacyLoad('scheme'), shell: () => legacyLoad('shell'), diff --git a/resources/js/code/legacy-modes.mjs b/resources/js/code/legacy-modes.mjs index 55a804bf97e..7b70bec10a1 100644 --- a/resources/js/code/legacy-modes.mjs +++ b/resources/js/code/legacy-modes.mjs @@ -16,8 +16,10 @@ export {pascal} from '@codemirror/legacy-modes/mode/pascal'; export {powerShell} from '@codemirror/legacy-modes/mode/powershell'; export {properties} from '@codemirror/legacy-modes/mode/properties'; export {python} from '@codemirror/legacy-modes/mode/python'; +export {r} from '@codemirror/legacy-modes/mode/r'; export {ruby} from '@codemirror/legacy-modes/mode/ruby'; export {rust} from '@codemirror/legacy-modes/mode/rust'; +export {sas} from '@codemirror/legacy-modes/mode/sas'; export {scheme} from '@codemirror/legacy-modes/mode/scheme'; export {shell} from '@codemirror/legacy-modes/mode/shell'; export { diff --git a/resources/views/pages/parts/code-editor.blade.php b/resources/views/pages/parts/code-editor.blade.php index 688fe67ef67..fc8852a32b8 100644 --- a/resources/views/pages/parts/code-editor.blade.php +++ b/resources/views/pages/parts/code-editor.blade.php @@ -26,7 +26,7 @@ class="popup-background code-editor"> 'Bash', 'CSS', 'C', 'C++', 'C#', 'Clojure', 'Dart', 'Diff', 'Fortran', 'F#', 'Go', 'Haskell', 'HTML', 'INI', 'Java', 'JavaScript', 'JSON', 'Julia', 'Kotlin', 'LaTeX', 'Lua', 'MarkDown', 'MATLAB', 'MSSQL', 'MySQL', 'Nginx', 'OCaml', 'Octave', 'Pascal', 'Perl', 'PHP', 'PL/SQL', 'PostgreSQL', 'Powershell', 'Python', - 'Ruby', 'Rust', 'Scala', 'Scheme', 'Shell', 'Smarty', 'SQL', 'SQLite', 'Swift', + 'R', 'Ruby', 'Rust', 'SAS', 'Scala', 'Scheme', 'Shell', 'Smarty', 'SQL', 'SQLite', 'Swift', 'Twig', 'TypeScript', 'VBScript', 'VB.NET', 'XML', 'YAML', ]; @endphp From 514db60617780de889645513ad613d8ff0ba3108 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 30 Sep 2024 17:07:53 +0100 Subject: [PATCH 148/800] Tests: Categorised up meta tests Extracted robots.txt tests into its own file to fit into new folder. Also tweaked open search tests a tad to specifically check long app names. --- tests/{ => Meta}/HelpTest.php | 4 +++- tests/{ => Meta}/LicensesTest.php | 4 +++- tests/{ => Meta}/OpenGraphTest.php | 3 ++- tests/{ => Meta}/OpensearchTest.php | 14 +++++------ tests/{ => Meta}/PwaManifestTest.php | 4 +++- tests/Meta/RobotsTest.php | 35 ++++++++++++++++++++++++++++ tests/PublicActionTest.php | 27 --------------------- 7 files changed, 53 insertions(+), 38 deletions(-) rename tests/{ => Meta}/HelpTest.php (94%) rename tests/{ => Meta}/LicensesTest.php (94%) rename tests/{ => Meta}/OpenGraphTest.php (98%) rename tests/{ => Meta}/OpensearchTest.php (84%) rename tests/{ => Meta}/PwaManifestTest.php (98%) create mode 100644 tests/Meta/RobotsTest.php diff --git a/tests/HelpTest.php b/tests/Meta/HelpTest.php similarity index 94% rename from tests/HelpTest.php rename to tests/Meta/HelpTest.php index 9cf80717fe0..e1de96bc8df 100644 --- a/tests/HelpTest.php +++ b/tests/Meta/HelpTest.php @@ -1,6 +1,8 @@ put('app-name', $appName); $resultUrl = url('/search') . '?term={searchTerms}'; $selfUrl = url('/opensearch.xml'); @@ -17,14 +20,11 @@ public function test_opensearch_endpoint() $html->assertElementExists('OpenSearchDescription > ShortName'); $html->assertElementContains('OpenSearchDescription > ShortName', mb_strimwidth($appName, 0, 16)); + $html->assertElementNotContains('OpenSearchDescription > ShortName', $appName); $html->assertElementExists('OpenSearchDescription > Description'); - $html->assertElementContains('OpenSearchDescription > Description', trans('common.opensearch_description', [ - 'appName' => $appName, - ])); - + $html->assertElementContains('OpenSearchDescription > Description', "Search {$appName}"); $html->assertElementExists('OpenSearchDescription > Image'); - $html->assertElementExists('OpenSearchDescription > Url[rel="results"][template="' . htmlspecialchars($resultUrl) . '"]'); $html->assertElementExists('OpenSearchDescription > Url[rel="self"][template="' . htmlspecialchars($selfUrl) . '"]'); } diff --git a/tests/PwaManifestTest.php b/tests/Meta/PwaManifestTest.php similarity index 98% rename from tests/PwaManifestTest.php rename to tests/Meta/PwaManifestTest.php index c66f8b36052..fc6d19b5459 100644 --- a/tests/PwaManifestTest.php +++ b/tests/Meta/PwaManifestTest.php @@ -1,6 +1,8 @@ get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); + + $this->setSettings(['app-public' => 'true']); + + $resp = $this->get('/robots.txt'); + $resp->assertSee("User-agent: *\nDisallow:"); + $resp->assertDontSee('Disallow: /'); + } + + public function test_robots_effected_by_setting() + { + $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); + + config()->set('app.allow_robots', true); + + $resp = $this->get('/robots.txt'); + $resp->assertSee("User-agent: *\nDisallow:"); + $resp->assertDontSee('Disallow: /'); + + // Check config overrides app-public setting + config()->set('app.allow_robots', false); + $this->setSettings(['app-public' => 'true']); + $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); + } +} diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 875b279a81c..76745aaac72 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -128,33 +128,6 @@ public function test_content_not_listed_on_404_for_public_users() $resp->assertDontSee($page->name); } - public function test_robots_effected_by_public_status() - { - $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); - - $this->setSettings(['app-public' => 'true']); - - $resp = $this->get('/robots.txt'); - $resp->assertSee("User-agent: *\nDisallow:"); - $resp->assertDontSee('Disallow: /'); - } - - public function test_robots_effected_by_setting() - { - $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); - - config()->set('app.allow_robots', true); - - $resp = $this->get('/robots.txt'); - $resp->assertSee("User-agent: *\nDisallow:"); - $resp->assertDontSee('Disallow: /'); - - // Check config overrides app-public setting - config()->set('app.allow_robots', false); - $this->setSettings(['app-public' => 'true']); - $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /"); - } - public function test_default_favicon_file_created_upon_access() { $faviconPath = public_path('favicon.ico'); From 34ade5018186b0acc01205dd1ed3c6b1043f835e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 1 Oct 2024 10:37:31 +0100 Subject: [PATCH 149/800] Base layout: Changed main app script to be module loaded Prevents polluting global scope with variables since we're using the module format bundler in esbuild. Also cleaned up unused yields. Fixed bad reference in our tinymce fixes. For #5232 --- resources/js/wysiwyg-tinymce/fixes.js | 8 +++++--- resources/views/comments/comments.blade.php | 6 ++---- resources/views/layouts/base.blade.php | 10 ++-------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/resources/js/wysiwyg-tinymce/fixes.js b/resources/js/wysiwyg-tinymce/fixes.js index b6d627ff796..9e5cce43c50 100644 --- a/resources/js/wysiwyg-tinymce/fixes.js +++ b/resources/js/wysiwyg-tinymce/fixes.js @@ -144,11 +144,13 @@ export function handleTableCellRangeEvents(editor) { cell.removeAttribute('align'); cleanChildAlignment(cell); }, - JustifyRight: this.JustifyLeft, - JustifyCenter: this.JustifyLeft, - JustifyFull: this.JustifyLeft, }; + // Copy justify left action to other alignment actions + actionByCommand.JustifyRight = actionByCommand.JustifyLeft; + actionByCommand.JustifyCenter = actionByCommand.JustifyLeft; + actionByCommand.JustifyFull = actionByCommand.JustifyLeft; + editor.on('ExecCommand', event => { const action = actionByCommand[event.command]; if (action) { diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 37329c42cf8..48bf885fff5 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -36,11 +36,9 @@ class="button outline">{{ trans('entities.comment_add') }} @endif @if(userCan('comment-create-all') || $commentTree->canUpdateAny()) - @push('post-app-scripts') - + @push('body-end') + @include('form.editor-translations') - @endpush - @push('post-app-html') @include('entities.selector-popup') @endpush @endif diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index ddecb8e76e1..a6d908fc06c 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -35,8 +35,6 @@ class="{{ setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '' - @yield('head') - @include('layouts.parts.custom-styles') @include('layouts.parts.custom-head') @@ -70,14 +68,10 @@ class="@stack('body-class')">
                                  - @yield('bottom') - @stack('post-app-html') - @if($cspNonce ?? false) - + @endif - @yield('scripts') - @stack('post-app-scripts') + @stack('body-end') @include('layouts.parts.base-body-end') From 177cfd72bf22c78823cc46dc5c44df542f5f1fd2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 2 Oct 2024 17:31:45 +0100 Subject: [PATCH 150/800] Search: Added structure for search term inputs Sets things up to allow more complex terms ready to handle negation. --- app/Search/SearchOption.php | 12 +++ app/Search/SearchOptionSet.php | 56 +++++++++++++ app/Search/SearchOptions.php | 114 +++++++++++++++++--------- app/Search/SearchResultsFormatter.php | 5 +- app/Search/SearchRunner.php | 22 ++--- resources/views/search/all.blade.php | 42 +++++----- tests/Entity/SearchOptionsTest.php | 31 +++---- 7 files changed, 195 insertions(+), 87 deletions(-) create mode 100644 app/Search/SearchOption.php create mode 100644 app/Search/SearchOptionSet.php diff --git a/app/Search/SearchOption.php b/app/Search/SearchOption.php new file mode 100644 index 00000000000..74fc7be3859 --- /dev/null +++ b/app/Search/SearchOption.php @@ -0,0 +1,12 @@ +options = $options; + } + + public function toValueArray(): array + { + return array_map(fn(SearchOption $option) => $option->value, $this->options); + } + + public function toValueMap(): array + { + $map = []; + foreach ($this->options as $key => $option) { + $map[$key] = $option->value; + } + return $map; + } + + public function merge(SearchOptionSet $set): self + { + return new self(array_merge($this->options, $set->options)); + } + + public function filterEmpty(): self + { + $filteredOptions = array_filter($this->options, fn (SearchOption $option) => !empty($option->value)); + return new self($filteredOptions); + } + + public static function fromValueArray(array $values): self + { + $options = array_map(fn($val) => new SearchOption($val), $values); + return new self($options); + } + + public static function fromMapArray(array $values): self + { + $options = []; + foreach ($values as $key => $value) { + $options[$key] = new SearchOption($value); + } + return new self($options); + } +} diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index fffa03db094..09981c75dff 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -6,22 +6,26 @@ class SearchOptions { - public array $searches = []; - public array $exacts = []; - public array $tags = []; - public array $filters = []; + public SearchOptionSet $searches; + public SearchOptionSet $exacts; + public SearchOptionSet $tags; + public SearchOptionSet $filters; + + public function __construct() + { + $this->searches = new SearchOptionSet(); + $this->exacts = new SearchOptionSet(); + $this->tags = new SearchOptionSet(); + $this->filters = new SearchOptionSet(); + } /** * Create a new instance from a search string. */ public static function fromString(string $search): self { - $decoded = static::decode($search); - $instance = new SearchOptions(); - foreach ($decoded as $type => $value) { - $instance->$type = $value; - } - + $instance = new self(); + $instance->addOptionsFromString($search); return $instance; } @@ -44,34 +48,37 @@ public static function fromRequest(Request $request): self $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? ''); - $instance->searches = array_filter($parsedStandardTerms['terms']); - $instance->exacts = array_filter($parsedStandardTerms['exacts']); - - array_push($instance->exacts, ...array_filter($inputs['exact'] ?? [])); - - $instance->tags = array_filter($inputs['tags'] ?? []); + $inputExacts = array_filter($inputs['exact'] ?? []); + $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms'])); + $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts'])); + $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts)); + $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? [])); + $keyedFilters = []; foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) { if (empty($filterVal)) { continue; } - $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal; + $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal; + $keyedFilters[$filterKey] = new SearchOption($cleanedFilterVal); } if (isset($inputs['types']) && count($inputs['types']) < 4) { - $instance->filters['type'] = implode('|', $inputs['types']); + $keyedFilters['type'] = new SearchOption(implode('|', $inputs['types'])); } + $instance->filters = new SearchOptionSet($keyedFilters); + return $instance; } /** - * Decode a search string into an array of terms. + * Decode a search string and add its contents to this instance. */ - protected static function decode(string $searchString): array + protected function addOptionsFromString(string $searchString): void { + /** @var array $terms */ $terms = [ - 'searches' => [], 'exacts' => [], 'tags' => [], 'filters' => [], @@ -94,28 +101,30 @@ protected static function decode(string $searchString): array } // Unescape exacts and backslash escapes - foreach ($terms['exacts'] as $index => $exact) { - $terms['exacts'][$index] = static::decodeEscapes($exact); - } + $escapedExacts = array_map(fn(string $term) => static::decodeEscapes($term), $terms['exacts']); // Parse standard terms $parsedStandardTerms = static::parseStandardTermString($searchString); - array_push($terms['searches'], ...$parsedStandardTerms['terms']); - array_push($terms['exacts'], ...$parsedStandardTerms['exacts']); + $this->searches = $this->searches + ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'])) + ->filterEmpty(); + $this->exacts = $this->exacts + ->merge(SearchOptionSet::fromValueArray($escapedExacts)) + ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'])) + ->filterEmpty(); + + // Add tags + $this->tags = $this->tags->merge(SearchOptionSet::fromValueArray($terms['tags'])); // Split filter values out + /** @var array $splitFilters */ $splitFilters = []; foreach ($terms['filters'] as $filter) { $explodedFilter = explode(':', $filter, 2); - $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; + $filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; + $splitFilters[$explodedFilter[0]] = new SearchOption($filterValue); } - $terms['filters'] = $splitFilters; - - // Filter down terms where required - $terms['exacts'] = array_filter($terms['exacts']); - $terms['searches'] = array_filter($terms['searches']); - - return $terms; + $this->filters = $this->filters->merge(new SearchOptionSet($splitFilters)); } /** @@ -175,7 +184,9 @@ protected static function parseStandardTermString(string $termString): array */ public function setFilter(string $filterName, string $filterValue = ''): void { - $this->filters[$filterName] = $filterValue; + $this->filters = $this->filters->merge( + new SearchOptionSet([$filterName => new SearchOption($filterValue)]) + ); } /** @@ -183,22 +194,47 @@ public function setFilter(string $filterName, string $filterValue = ''): void */ public function toString(): string { - $parts = $this->searches; + $parts = $this->searches->toValueArray(); - foreach ($this->exacts as $term) { + foreach ($this->exacts->toValueArray() as $term) { $escaped = str_replace('\\', '\\\\', $term); $escaped = str_replace('"', '\"', $escaped); $parts[] = '"' . $escaped . '"'; } - foreach ($this->tags as $term) { + foreach ($this->tags->toValueArray() as $term) { $parts[] = "[{$term}]"; } - foreach ($this->filters as $filterName => $filterVal) { + foreach ($this->filters->toValueMap() as $filterName => $filterVal) { $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}'; } return implode(' ', $parts); } + + /** + * Get the search options that don't have UI controls provided for. + * Provided back as a key => value array with the keys being expected + * input names for a search form, and values being the option value. + * + * @return array + */ + public function getHiddenInputValuesByFieldName(): array + { + $options = []; + + // Non-[created/updated]-by-me options + $filterMap = $this->filters->toValueMap(); + foreach (['updated_by', 'created_by', 'owned_by'] as $filter) { + $value = $filterMap[$filter] ?? null; + if ($value !== null && $value !== 'me') { + $options["filters[$filter]"] = $value; + } + } + + // TODO - Negated + + return $options; + } } diff --git a/app/Search/SearchResultsFormatter.php b/app/Search/SearchResultsFormatter.php index 02a40632e22..b06f81e0e05 100644 --- a/app/Search/SearchResultsFormatter.php +++ b/app/Search/SearchResultsFormatter.php @@ -25,11 +25,12 @@ public function format(array $results, SearchOptions $options): void * Update the given entity model to set attributes used for previews of the item * primarily within search result lists. */ - protected function setSearchPreview(Entity $entity, SearchOptions $options) + protected function setSearchPreview(Entity $entity, SearchOptions $options): void { $textProperty = $entity->textField; $textContent = $entity->$textProperty; - $terms = array_merge($options->exacts, $options->searches); + $relevantSearchOptions = $options->exacts->merge($options->searches); + $terms = $relevantSearchOptions->toValueArray(); $originalContentByNewAttribute = [ 'preview_name' => $entity->name, diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 94518dbf7b2..855140508f8 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -55,10 +55,11 @@ public function searchEntities(SearchOptions $searchOpts, string $entityType = ' $entityTypes = array_keys($this->entityProvider->all()); $entityTypesToSearch = $entityTypes; + $filterMap = $searchOpts->filters->toValueMap(); if ($entityType !== 'all') { $entityTypesToSearch = [$entityType]; - } elseif (isset($searchOpts->filters['type'])) { - $entityTypesToSearch = explode('|', $searchOpts->filters['type']); + } elseif (isset($filterMap['type'])) { + $entityTypesToSearch = explode('|', $filterMap['type']); } $results = collect(); @@ -97,7 +98,8 @@ public function searchBook(int $bookId, string $searchString): Collection { $opts = SearchOptions::fromString($searchString); $entityTypes = ['page', 'chapter']; - $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes; + $filterMap = $opts->filters->toValueMap(); + $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes; $results = collect(); foreach ($entityTypesToSearch as $entityType) { @@ -161,7 +163,7 @@ protected function buildQuery(SearchOptions $searchOpts, string $entityType): El $this->applyTermSearch($entityQuery, $searchOpts, $entityType); // Handle exact term matching - foreach ($searchOpts->exacts as $inputTerm) { + foreach ($searchOpts->exacts->toValueArray() as $inputTerm) { $entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) { $inputTerm = str_replace('\\', '\\\\', $inputTerm); $query->where('name', 'like', '%' . $inputTerm . '%') @@ -170,12 +172,12 @@ protected function buildQuery(SearchOptions $searchOpts, string $entityType): El } // Handle tag searches - foreach ($searchOpts->tags as $inputTerm) { + foreach ($searchOpts->tags->toValueArray() as $inputTerm) { $this->applyTagSearch($entityQuery, $inputTerm); } // Handle filters - foreach ($searchOpts->filters as $filterTerm => $filterValue) { + foreach ($searchOpts->filters->toValueMap() as $filterTerm => $filterValue) { $functionName = Str::camel('filter_' . $filterTerm); if (method_exists($this, $functionName)) { $this->$functionName($entityQuery, $entityModelInstance, $filterValue); @@ -190,7 +192,7 @@ protected function buildQuery(SearchOptions $searchOpts, string $entityType): El */ protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void { - $terms = $options->searches; + $terms = $options->searches->toValueArray(); if (count($terms) === 0) { return; } @@ -209,8 +211,8 @@ protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $ $subQuery->where('entity_type', '=', $entityType); $subQuery->where(function (Builder $query) use ($terms) { foreach ($terms as $inputTerm) { - $inputTerm = str_replace('\\', '\\\\', $inputTerm); - $query->orWhere('term', 'like', $inputTerm . '%'); + $escapedTerm = str_replace('\\', '\\\\', $inputTerm); + $query->orWhere('term', 'like', $escapedTerm . '%'); } }); $subQuery->groupBy('entity_type', 'entity_id'); @@ -264,7 +266,7 @@ protected function getTermAdjustments(SearchOptions $options): array $whenStatements = []; $whenBindings = []; - foreach ($options->searches as $term) { + foreach ($options->searches->toValueArray() as $term) { $whenStatements[] = 'WHEN term LIKE ? THEN ?'; $whenBindings[] = $term . '%'; $whenBindings[] = $term; diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php index 2d410dbd1b0..aa7ae0aff81 100644 --- a/resources/views/search/all.blade.php +++ b/resources/views/search/all.blade.php @@ -8,15 +8,18 @@
                                  {{ trans('entities.search_advanced') }}
                                  + @php + $filterMap = $options->filters->toValueMap(); + @endphp
                                  {{ trans('entities.search_terms') }}
                                  - +
                                  {{ trans('entities.search_content_type') }}
                                  filters['type'] ?? ''); + $types = explode('|', $filterMap['type'] ?? ''); $hasTypes = $types[0] !== ''; ?> @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('page', $types), 'entity' => 'page', 'transKey' => 'page']) @@ -27,46 +30,43 @@
                                  {{ trans('entities.search_exact_matches') }}
                                  - @include('search.parts.term-list', ['type' => 'exact', 'currentList' => $options->exacts]) + @include('search.parts.term-list', ['type' => 'exact', 'currentList' => $options->exacts->toValueArray()])
                                  {{ trans('entities.search_tags') }}
                                  - @include('search.parts.term-list', ['type' => 'tags', 'currentList' => $options->tags]) + @include('search.parts.term-list', ['type' => 'tags', 'currentList' => $options->tags->toValueArray()]) @if(!user()->isGuest())
                                  {{ trans('entities.search_options') }}
                                  - @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'viewed_by_me', 'value' => null]) + @component('search.parts.boolean-filter', ['filters' => $filterMap, 'name' => 'viewed_by_me', 'value' => null]) {{ trans('entities.search_viewed_by_me') }} @endcomponent - @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'not_viewed_by_me', 'value' => null]) + @component('search.parts.boolean-filter', ['filters' => $filterMap, 'name' => 'not_viewed_by_me', 'value' => null]) {{ trans('entities.search_not_viewed_by_me') }} @endcomponent - @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'is_restricted', 'value' => null]) + @component('search.parts.boolean-filter', ['filters' => $filterMap, 'name' => 'is_restricted', 'value' => null]) {{ trans('entities.search_permissions_set') }} @endcomponent - @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'created_by', 'value' => 'me']) + @component('search.parts.boolean-filter', ['filters' => $filterMap, 'name' => 'created_by', 'value' => 'me']) {{ trans('entities.search_created_by_me') }} @endcomponent - @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'updated_by', 'value' => 'me']) + @component('search.parts.boolean-filter', ['filters' => $filterMap, 'name' => 'updated_by', 'value' => 'me']) {{ trans('entities.search_updated_by_me') }} @endcomponent - @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'owned_by', 'value' => 'me']) + @component('search.parts.boolean-filter', ['filters' => $filterMap, 'name' => 'owned_by', 'value' => 'me']) {{ trans('entities.search_owned_by_me') }} @endcomponent @endif
                                  {{ trans('entities.search_date_options') }}
                                  - @include('search.parts.date-filter', ['name' => 'updated_after', 'filters' => $options->filters]) - @include('search.parts.date-filter', ['name' => 'updated_before', 'filters' => $options->filters]) - @include('search.parts.date-filter', ['name' => 'created_after', 'filters' => $options->filters]) - @include('search.parts.date-filter', ['name' => 'created_before', 'filters' => $options->filters]) - - @if(isset($options->filters['created_by']) && $options->filters['created_by'] !== "me") - - @endif - @if(isset($options->filters['updated_by']) && $options->filters['updated_by'] !== "me") - - @endif + @include('search.parts.date-filter', ['name' => 'updated_after', 'filters' => $filterMap]) + @include('search.parts.date-filter', ['name' => 'updated_before', 'filters' => $filterMap]) + @include('search.parts.date-filter', ['name' => 'created_after', 'filters' => $filterMap]) + @include('search.parts.date-filter', ['name' => 'created_before', 'filters' => $filterMap]) + + @foreach($options->getHiddenInputValuesByFieldName() as $fieldName => $value) + + @endforeach
                                  diff --git a/tests/Entity/SearchOptionsTest.php b/tests/Entity/SearchOptionsTest.php index ea4d727a428..7ab150e914b 100644 --- a/tests/Entity/SearchOptionsTest.php +++ b/tests/Entity/SearchOptionsTest.php @@ -3,6 +3,7 @@ namespace Tests\Entity; use BookStack\Search\SearchOptions; +use BookStack\Search\SearchOptionSet; use Illuminate\Http\Request; use Tests\TestCase; @@ -12,27 +13,27 @@ public function test_from_string_parses_a_search_string_properly() { $options = SearchOptions::fromString('cat "dog" [tag=good] {is_tree}'); - $this->assertEquals(['cat'], $options->searches); - $this->assertEquals(['dog'], $options->exacts); - $this->assertEquals(['tag=good'], $options->tags); - $this->assertEquals(['is_tree' => ''], $options->filters); + $this->assertEquals(['cat'], $options->searches->toValueArray()); + $this->assertEquals(['dog'], $options->exacts->toValueArray()); + $this->assertEquals(['tag=good'], $options->tags->toValueArray()); + $this->assertEquals(['is_tree' => ''], $options->filters->toValueMap()); } public function test_from_string_properly_parses_escaped_quotes() { $options = SearchOptions::fromString('"\"cat\"" surprise "\"\"" "\"donkey" "\"" "\\\\"'); - $this->assertEquals(['"cat"', '""', '"donkey', '"', '\\'], $options->exacts); + $this->assertEquals(['"cat"', '""', '"donkey', '"', '\\'], $options->exacts->toValueArray()); } public function test_to_string_includes_all_items_in_the_correct_format() { $expected = 'cat "dog" [tag=good] {is_tree}'; $options = new SearchOptions(); - $options->searches = ['cat']; - $options->exacts = ['dog']; - $options->tags = ['tag=good']; - $options->filters = ['is_tree' => '']; + $options->searches = SearchOptionSet::fromValueArray(['cat']); + $options->exacts = SearchOptionSet::fromValueArray(['dog']); + $options->tags = SearchOptionSet::fromValueArray(['tag=good']); + $options->filters = SearchOptionSet::fromMapArray(['is_tree' => '']); $output = $options->toString(); foreach (explode(' ', $expected) as $term) { @@ -43,7 +44,7 @@ public function test_to_string_includes_all_items_in_the_correct_format() public function test_to_string_escapes_as_expected() { $options = new SearchOptions(); - $options->exacts = ['"cat"', '""', '"donkey', '"', '\\', '\\"']; + $options->exacts = SearchOptionSet::fromValueArray(['"cat"', '""', '"donkey', '"', '\\', '\\"']); $output = $options->toString(); $this->assertEquals('"\"cat\"" "\"\"" "\"donkey" "\"" "\\\\" "\\\\\""', $output); @@ -57,14 +58,14 @@ public function test_correct_filter_values_are_set_from_string() 'is_tree' => '', 'name' => 'dan', 'cat' => 'happy', - ], $opts->filters); + ], $opts->filters->toValueMap()); } public function test_it_cannot_parse_out_empty_exacts() { $options = SearchOptions::fromString('"" test ""'); - $this->assertEmpty($options->exacts); - $this->assertCount(1, $options->searches); + $this->assertEmpty($options->exacts->toValueArray()); + $this->assertCount(1, $options->searches->toValueArray()); } public function test_from_request_properly_parses_exacts_from_search_terms() @@ -74,7 +75,7 @@ public function test_from_request_properly_parses_exacts_from_search_terms() ]); $options = SearchOptions::fromRequest($request); - $this->assertEquals(["biscuits"], $options->searches); - $this->assertEquals(['"cheese"', '""', '"baked', 'beans"'], $options->exacts); + $this->assertEquals(["biscuits"], $options->searches->toValueArray()); + $this->assertEquals(['"cheese"', '""', '"baked', 'beans"'], $options->exacts->toValueArray()); } } From 93c677a6a955c75318c184d167737836c8c36cd5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 3 Oct 2024 15:59:50 +0100 Subject: [PATCH 151/800] Searching: Added negation support to UI and term handling Updated/added tests to cover. Support for actual search queries still remains. --- app/Search/Options/ExactSearchOption.php | 13 +++ app/Search/Options/FilterSearchOption.php | 37 +++++++ app/Search/Options/SearchOption.php | 26 +++++ app/Search/Options/TagSearchOption.php | 11 ++ app/Search/Options/TermSearchOption.php | 11 ++ app/Search/SearchOption.php | 12 --- app/Search/SearchOptionSet.php | 35 +++++-- app/Search/SearchOptions.php | 119 ++++++++++++---------- resources/views/search/all.blade.php | 14 ++- tests/Entity/EntitySearchTest.php | 7 +- tests/Entity/SearchOptionsTest.php | 63 ++++++++++-- 11 files changed, 252 insertions(+), 96 deletions(-) create mode 100644 app/Search/Options/ExactSearchOption.php create mode 100644 app/Search/Options/FilterSearchOption.php create mode 100644 app/Search/Options/SearchOption.php create mode 100644 app/Search/Options/TagSearchOption.php create mode 100644 app/Search/Options/TermSearchOption.php delete mode 100644 app/Search/SearchOption.php diff --git a/app/Search/Options/ExactSearchOption.php b/app/Search/Options/ExactSearchOption.php new file mode 100644 index 00000000000..5651fb99b09 --- /dev/null +++ b/app/Search/Options/ExactSearchOption.php @@ -0,0 +1,13 @@ +value); + $escaped = str_replace('"', '\"', $escaped); + return ($this->negated ? '-' : '') . '"' . $escaped . '"'; + } +} diff --git a/app/Search/Options/FilterSearchOption.php b/app/Search/Options/FilterSearchOption.php new file mode 100644 index 00000000000..1f64f4f9e04 --- /dev/null +++ b/app/Search/Options/FilterSearchOption.php @@ -0,0 +1,37 @@ +name = $name; + } + + public function toString(): string + { + $valueText = ($this->value ? ':' . $this->value : ''); + $filterBrace = '{' . $this->name . $valueText . '}'; + return ($this->negated ? '-' : '') . $filterBrace; + } + + public function getKey(): string + { + return $this->name; + } + + public static function fromContentString(string $value, bool $negated = false): self + { + $explodedFilter = explode(':', $value, 2); + $filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; + $filterName = $explodedFilter[0]; + return new self($filterValue, $filterName, $negated); + } +} diff --git a/app/Search/Options/SearchOption.php b/app/Search/Options/SearchOption.php new file mode 100644 index 00000000000..483f2123fb1 --- /dev/null +++ b/app/Search/Options/SearchOption.php @@ -0,0 +1,26 @@ +negated ? '-' : '') . "[{$this->value}]"; + } +} diff --git a/app/Search/Options/TermSearchOption.php b/app/Search/Options/TermSearchOption.php new file mode 100644 index 00000000000..c78829fc87e --- /dev/null +++ b/app/Search/Options/TermSearchOption.php @@ -0,0 +1,11 @@ +value; + } +} diff --git a/app/Search/SearchOption.php b/app/Search/SearchOption.php deleted file mode 100644 index 74fc7be3859..00000000000 --- a/app/Search/SearchOption.php +++ /dev/null @@ -1,12 +0,0 @@ -options as $key => $option) { + foreach ($this->options as $index => $option) { + $key = $option->getKey() ?? $index; $map[$key] = $option->value; } return $map; @@ -35,22 +38,32 @@ public function merge(SearchOptionSet $set): self public function filterEmpty(): self { - $filteredOptions = array_filter($this->options, fn (SearchOption $option) => !empty($option->value)); + $filteredOptions = array_values(array_filter($this->options, fn (SearchOption $option) => !empty($option->value))); return new self($filteredOptions); } - public static function fromValueArray(array $values): self + /** + * @param class-string $class + */ + public static function fromValueArray(array $values, string $class): self { - $options = array_map(fn($val) => new SearchOption($val), $values); + $options = array_map(fn($val) => new $class($val), $values); return new self($options); } - public static function fromMapArray(array $values): self + /** + * @return SearchOption[] + */ + public function all(): array { - $options = []; - foreach ($values as $key => $value) { - $options[$key] = new SearchOption($value); - } - return new self($options); + return $this->options; + } + + /** + * @return SearchOption[] + */ + public function negated(): array + { + return array_values(array_filter($this->options, fn (SearchOption $option) => $option->negated)); } } diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index 09981c75dff..98f731ee75f 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -2,6 +2,11 @@ namespace BookStack\Search; +use BookStack\Search\Options\ExactSearchOption; +use BookStack\Search\Options\FilterSearchOption; +use BookStack\Search\Options\SearchOption; +use BookStack\Search\Options\TagSearchOption; +use BookStack\Search\Options\TermSearchOption; use Illuminate\Http\Request; class SearchOptions @@ -45,29 +50,38 @@ public static function fromRequest(Request $request): self } $instance = new SearchOptions(); - $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']); + $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? ''); $inputExacts = array_filter($inputs['exact'] ?? []); - $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms'])); - $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts'])); - $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts)); - $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? [])); + $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class); + $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class); + $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class)); + $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class); - $keyedFilters = []; + $cleanedFilters = []; foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) { if (empty($filterVal)) { continue; } $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal; - $keyedFilters[$filterKey] = new SearchOption($cleanedFilterVal); + $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey); } if (isset($inputs['types']) && count($inputs['types']) < 4) { - $keyedFilters['type'] = new SearchOption(implode('|', $inputs['types'])); + $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'types'); } - $instance->filters = new SearchOptionSet($keyedFilters); + $instance->filters = new SearchOptionSet($cleanedFilters); + + // Parse and merge in extras if provided + if (!empty($inputs['extras'])) { + $extras = static::fromString($inputs['extras']); + $instance->searches = $instance->searches->merge($extras->searches); + $instance->exacts = $instance->exacts->merge($extras->exacts); + $instance->tags = $instance->tags->merge($extras->tags); + $instance->filters = $instance->filters->merge($extras->filters); + } return $instance; } @@ -77,7 +91,7 @@ public static function fromRequest(Request $request): self */ protected function addOptionsFromString(string $searchString): void { - /** @var array $terms */ + /** @var array $terms */ $terms = [ 'exacts' => [], 'tags' => [], @@ -85,9 +99,15 @@ protected function addOptionsFromString(string $searchString): void ]; $patterns = [ - 'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/', - 'tags' => '/\[(.*?)\]/', - 'filters' => '/\{(.*?)\}/', + 'exacts' => '/-?"((?:\\\\.|[^"\\\\])*)"/', + 'tags' => '/-?\[(.*?)\]/', + 'filters' => '/-?\{(.*?)\}/', + ]; + + $constructors = [ + 'exacts' => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated), + 'tags' => fn(string $value, bool $negated) => new TagSearchOption($value, $negated), + 'filters' => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated), ]; // Parse special terms @@ -95,36 +115,32 @@ protected function addOptionsFromString(string $searchString): void $matches = []; preg_match_all($pattern, $searchString, $matches); if (count($matches) > 0) { - $terms[$termType] = $matches[1]; + foreach ($matches[1] as $index => $value) { + $negated = str_starts_with($matches[0][$index], '-'); + $terms[$termType][] = $constructors[$termType]($value, $negated); + } $searchString = preg_replace($pattern, '', $searchString); } } // Unescape exacts and backslash escapes - $escapedExacts = array_map(fn(string $term) => static::decodeEscapes($term), $terms['exacts']); + foreach ($terms['exacts'] as $exact) { + $exact->value = static::decodeEscapes($exact->value); + } // Parse standard terms $parsedStandardTerms = static::parseStandardTermString($searchString); $this->searches = $this->searches - ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'])) + ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class)) ->filterEmpty(); $this->exacts = $this->exacts - ->merge(SearchOptionSet::fromValueArray($escapedExacts)) - ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'])) + ->merge(new SearchOptionSet($terms['exacts'])) + ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class)) ->filterEmpty(); - // Add tags - $this->tags = $this->tags->merge(SearchOptionSet::fromValueArray($terms['tags'])); - - // Split filter values out - /** @var array $splitFilters */ - $splitFilters = []; - foreach ($terms['filters'] as $filter) { - $explodedFilter = explode(':', $filter, 2); - $filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; - $splitFilters[$explodedFilter[0]] = new SearchOption($filterValue); - } - $this->filters = $this->filters->merge(new SearchOptionSet($splitFilters)); + // Add tags & filters + $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags'])); + $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters'])); } /** @@ -185,7 +201,7 @@ protected static function parseStandardTermString(string $termString): array public function setFilter(string $filterName, string $filterValue = ''): void { $this->filters = $this->filters->merge( - new SearchOptionSet([$filterName => new SearchOption($filterValue)]) + new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)]) ); } @@ -194,21 +210,14 @@ public function setFilter(string $filterName, string $filterValue = ''): void */ public function toString(): string { - $parts = $this->searches->toValueArray(); - - foreach ($this->exacts->toValueArray() as $term) { - $escaped = str_replace('\\', '\\\\', $term); - $escaped = str_replace('"', '\"', $escaped); - $parts[] = '"' . $escaped . '"'; - } - - foreach ($this->tags->toValueArray() as $term) { - $parts[] = "[{$term}]"; - } + $options = [ + ...$this->searches->all(), + ...$this->exacts->all(), + ...$this->tags->all(), + ...$this->filters->all(), + ]; - foreach ($this->filters->toValueMap() as $filterName => $filterVal) { - $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}'; - } + $parts = array_map(fn(SearchOption $o) => $o->toString(), $options); return implode(' ', $parts); } @@ -217,24 +226,24 @@ public function toString(): string * Get the search options that don't have UI controls provided for. * Provided back as a key => value array with the keys being expected * input names for a search form, and values being the option value. - * - * @return array */ - public function getHiddenInputValuesByFieldName(): array + public function getAdditionalOptionsString(): string { $options = []; // Non-[created/updated]-by-me options - $filterMap = $this->filters->toValueMap(); - foreach (['updated_by', 'created_by', 'owned_by'] as $filter) { - $value = $filterMap[$filter] ?? null; - if ($value !== null && $value !== 'me') { - $options["filters[$filter]"] = $value; + $userFilters = ['updated_by', 'created_by', 'owned_by']; + foreach ($this->filters->all() as $filter) { + if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') { + $options[] = $filter; } } - // TODO - Negated + // Negated items + array_push($options, ...$this->exacts->negated()); + array_push($options, ...$this->tags->negated()); + array_push($options, ...$this->filters->negated()); - return $options; + return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options)); } } diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php index aa7ae0aff81..2a0d63a6ed6 100644 --- a/resources/views/search/all.blade.php +++ b/resources/views/search/all.blade.php @@ -25,8 +25,8 @@ @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('page', $types), 'entity' => 'page', 'transKey' => 'page']) @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('chapter', $types), 'entity' => 'chapter', 'transKey' => 'chapter'])
                                  - @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book']) - @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf']) + @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book']) + @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf'])
                                  {{ trans('entities.search_exact_matches') }}
                                  @@ -64,10 +64,7 @@ @include('search.parts.date-filter', ['name' => 'created_after', 'filters' => $filterMap]) @include('search.parts.date-filter', ['name' => 'created_before', 'filters' => $filterMap]) - @foreach($options->getHiddenInputValuesByFieldName() as $fieldName => $value) - - @endforeach - + @@ -77,8 +74,9 @@

                                  {{ trans('entities.search_results') }}

                                  -
                                  {{ trans('entities.search_exact_matches') }}
                                  - @include('search.parts.term-list', ['type' => 'exact', 'currentList' => $options->exacts->toValueArray()]) + @include('search.parts.term-list', ['type' => 'exact', 'currentList' => $options->exacts->nonNegated()->toValueArray()])
                                  {{ trans('entities.search_tags') }}
                                  - @include('search.parts.term-list', ['type' => 'tags', 'currentList' => $options->tags->toValueArray()]) + @include('search.parts.term-list', ['type' => 'tags', 'currentList' => $options->tags->nonNegated()->toValueArray()]) @if(!user()->isGuest())
                                  {{ trans('entities.search_options') }}
                                  diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index bb1021a6791..3a1a0a66200 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -577,6 +577,14 @@ public function test_searches_with_terms_without_controls_includes_them_in_extra $this->withHtml($resp)->assertFieldHasValue('extras', '{updated_by:dan} {created_by:dan} -"dog" -[a=b] -{viewed_by_me}'); } + public function test_negated_searches_dont_show_in_inputs() + { + $resp = $this->asEditor()->get('/search?term=' . urlencode('-{created_by:me} -[a=b] -"dog"')); + $this->withHtml($resp)->assertElementNotExists('input[name="tags[]"][value="a=b"]'); + $this->withHtml($resp)->assertElementNotExists('input[name="exact[]"][value="dog"]'); + $this->withHtml($resp)->assertElementNotExists('input[name="filters[created_by]"][value="me"][checked="checked"]'); + } + public function test_searches_with_user_filters_using_me_adds_them_into_advanced_search_form() { $resp = $this->asEditor()->get('/search?term=' . urlencode('test {updated_by:me} {created_by:me}')); diff --git a/tests/Entity/SearchOptionsTest.php b/tests/Entity/SearchOptionsTest.php index 543badcef95..ae0f1e56a9e 100644 --- a/tests/Entity/SearchOptionsTest.php +++ b/tests/Entity/SearchOptionsTest.php @@ -123,7 +123,7 @@ public function test_from_request_properly_parses_out_extras_as_string() $options = SearchOptions::fromRequest($request); $this->assertCount(2, $options->tags->all()); - $this->assertEquals('b=c', $options->tags->negated()[0]->value); + $this->assertEquals('b=c', $options->tags->negated()->all()[0]->value); $this->assertEquals('viewed_by_me', $options->filters->all()[0]->getKey()); $this->assertTrue($options->filters->all()[0]->negated); $this->assertEquals('dino', $options->exacts->all()[0]->value); From b1a3ea1aa4bfb0d9d20470858e0f41fcb73a85e1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 4 Oct 2024 10:59:34 +0100 Subject: [PATCH 154/800] Languages: Enabled Welsh option --- lang/en/settings.php | 1 + tests/LanguageTest.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lang/en/settings.php b/lang/en/settings.php index f4c84092c6f..5427cb9419e 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -295,6 +295,7 @@ 'bs' => 'Bosanski', 'ca' => 'Català', 'cs' => 'Česky', + 'cy' => 'Cymraeg', 'da' => 'Dansk', 'de' => 'Deutsch (Sie)', 'de_informal' => 'Deutsch (Du)', diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index 6b6856184c9..abe06407e1e 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -35,7 +35,8 @@ public function do_test_locales_all_have_language_dropdown_entry() $diffs = array_diff($this->langs, $dropdownLocales); if (count($diffs) > 0) { $diffText = implode(',', $diffs); - $this->addWarning("Languages: {$diffText} found in files but not in language select dropdown."); + $warning = "Languages: {$diffText} found in files but not in language select dropdown."; + $this->fail($warning); } $this->assertTrue(true); } From be6529d0a14eeb191da76d2ca019a0b5cb7f546f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 4 Oct 2024 12:41:13 +0100 Subject: [PATCH 155/800] New WYSIWYG: Added mac shortcut support --- resources/js/wysiwyg/services/shortcuts.ts | 66 ++++++++++++++-------- resources/js/wysiwyg/todo.md | 2 +- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index b17ec1bf7a8..05bdb5dccd3 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -32,39 +32,43 @@ function toggleInlineCode(editor: LexicalEditor): boolean { type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boolean; +/** + * List of action functions by their shortcut combo. + * We use "meta" as an abstraction for ctrl/cmd depending on platform. + */ const actionsByKeys: Record = { - 'ctrl+s': () => { + 'meta+s': () => { window.$events.emit('editor-save-draft'); return true; }, - 'ctrl+enter': () => { + 'meta+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), + 'meta+1': (editor) => headerHandler(editor, 'h1'), + 'meta+2': (editor) => headerHandler(editor, 'h2'), + 'meta+3': (editor) => headerHandler(editor, 'h3'), + 'meta+4': (editor) => headerHandler(editor, 'h4'), + 'meta+5': wrapFormatAction(toggleSelectionAsParagraph), + 'meta+d': wrapFormatAction(toggleSelectionAsParagraph), + 'meta+6': wrapFormatAction(toggleSelectionAsBlockquote), + 'meta+q': wrapFormatAction(toggleSelectionAsBlockquote), + 'meta+7': wrapFormatAction(formatCodeBlock), + 'meta+e': wrapFormatAction(formatCodeBlock), + 'meta+8': toggleInlineCode, + 'meta+shift+e': toggleInlineCode, + 'meta+9': wrapFormatAction(cycleSelectionCalloutFormats), - 'ctrl+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), - 'ctrl+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), - 'ctrl+k': (editor, context) => { + 'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), + 'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), + 'meta+k': (editor, context) => { editor.getEditorState().read(() => { const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; $showLinkForm(selectedLink, context); }); return true; }, - 'ctrl+shift+k': (editor, context) => { + 'meta+shift+k': (editor, context) => { showLinkSelector(entity => { insertOrUpdateLink(editor, { text: entity.name, @@ -79,8 +83,7 @@ const actionsByKeys: Record = { 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(); + const combo = keyboardEventToKeyComboString(event); // console.log(`pressed: ${combo}`); if (actionsByKeys[combo]) { const handled = actionsByKeys[combo](context.editor, context); @@ -92,10 +95,29 @@ function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => }; } +function keyboardEventToKeyComboString(event: KeyboardEvent): string { + const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey; + + const parts = [ + metaKeyPressed ? 'meta' : '', + event.shiftKey ? 'shift' : '', + event.key, + ]; + + return parts.filter(Boolean).join('+').toLowerCase(); +} + +function isMac(): boolean { + return window.navigator.userAgent.includes('Mac OS X'); +} + function overrideDefaultCommands(editor: LexicalEditor) { // Prevent default ctrl+enter command editor.registerCommand(KEY_ENTER_COMMAND, (event) => { - return event?.ctrlKey ? true : false + if (isMac()) { + return event?.metaKey || false; + } + return event?.ctrlKey || false; }, COMMAND_PRIORITY_HIGH); } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index bcd4851e805..d2213f76239 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,7 +6,7 @@ ## Main Todo -- Mac: Shortcut support via command. +// ## Secondary Todo From 2766c76491be9d004a2cbd8caa00cfb4d4c36320 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 4 Oct 2024 12:46:22 +0100 Subject: [PATCH 156/800] TinyMCE: Updated version from 6.8.3 to 6.8.4 --- public/libs/tinymce/models/dom/model.min.js | 2 +- public/libs/tinymce/plugins/advlist/plugin.min.js | 2 +- public/libs/tinymce/plugins/anchor/plugin.min.js | 2 +- public/libs/tinymce/plugins/autolink/plugin.min.js | 2 +- public/libs/tinymce/plugins/autoresize/plugin.min.js | 2 +- public/libs/tinymce/plugins/autosave/plugin.min.js | 2 +- public/libs/tinymce/plugins/charmap/plugin.min.js | 2 +- public/libs/tinymce/plugins/code/plugin.min.js | 2 +- public/libs/tinymce/plugins/codesample/plugin.min.js | 2 +- public/libs/tinymce/plugins/directionality/plugin.min.js | 2 +- public/libs/tinymce/plugins/fullscreen/plugin.min.js | 2 +- public/libs/tinymce/plugins/image/plugin.min.js | 2 +- public/libs/tinymce/plugins/importcss/plugin.min.js | 2 +- public/libs/tinymce/plugins/insertdatetime/plugin.min.js | 2 +- public/libs/tinymce/plugins/link/plugin.min.js | 2 +- public/libs/tinymce/plugins/lists/plugin.min.js | 2 +- public/libs/tinymce/plugins/media/plugin.min.js | 2 +- public/libs/tinymce/plugins/nonbreaking/plugin.min.js | 2 +- public/libs/tinymce/plugins/pagebreak/plugin.min.js | 2 +- public/libs/tinymce/plugins/preview/plugin.min.js | 2 +- public/libs/tinymce/plugins/quickbars/plugin.min.js | 2 +- public/libs/tinymce/plugins/save/plugin.min.js | 2 +- public/libs/tinymce/plugins/searchreplace/plugin.min.js | 2 +- public/libs/tinymce/plugins/table/plugin.min.js | 2 +- public/libs/tinymce/plugins/template/plugin.min.js | 2 +- public/libs/tinymce/plugins/visualblocks/plugin.min.js | 2 +- public/libs/tinymce/plugins/visualchars/plugin.min.js | 2 +- public/libs/tinymce/plugins/wordcount/plugin.min.js | 2 +- public/libs/tinymce/themes/silver/theme.min.js | 2 +- public/libs/tinymce/tinymce.min.js | 4 ++-- 30 files changed, 31 insertions(+), 31 deletions(-) diff --git a/public/libs/tinymce/models/dom/model.min.js b/public/libs/tinymce/models/dom/model.min.js index 268a4352bab..cbd12878816 100644 --- a/public/libs/tinymce/models/dom/model.min.js +++ b/public/libs/tinymce/models/dom/model.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.ModelManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(o=n=e,(r=String).prototype.isPrototypeOf(o)||(null===(s=n.constructor)||void 0===s?void 0:s.name)===r.name)?"string":t;var o,n,r,s})(t)===e,o=e=>t=>typeof t===e,n=e=>t=>e===t,r=t("string"),s=t("object"),l=t("array"),a=n(null),c=o("boolean"),i=n(void 0),m=e=>!(e=>null==e)(e),d=o("function"),u=o("number"),f=()=>{},g=e=>()=>e,h=e=>e,p=(e,t)=>e===t;function w(e,...t){return(...o)=>{const n=t.concat(o);return e.apply(null,n)}}const b=e=>t=>!e(t),v=e=>e(),y=g(!1),x=g(!0);class C{constructor(e,t){this.tag=e,this.value=t}static some(e){return new C(!0,e)}static none(){return C.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?C.some(e(this.value)):C.none()}bind(e){return this.tag?e(this.value):C.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:C.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return m(e)?C.some(e):C.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}C.singletonNone=new C(!1);const S=Array.prototype.slice,T=Array.prototype.indexOf,R=Array.prototype.push,D=(e,t)=>{return o=e,n=t,T.call(o,n)>-1;var o,n},O=(e,t)=>{for(let o=0,n=e.length;o{const o=[];for(let n=0;n{const o=e.length,n=new Array(o);for(let r=0;r{for(let o=0,n=e.length;o{const o=[],n=[];for(let r=0,s=e.length;r{const o=[];for(let n=0,r=e.length;n(((e,t)=>{for(let o=e.length-1;o>=0;o--)t(e[o],o)})(e,((e,n)=>{o=t(o,e,n)})),o),A=(e,t,o)=>(N(e,((e,n)=>{o=t(o,e,n)})),o),L=(e,t)=>((e,t,o)=>{for(let n=0,r=e.length;n{for(let o=0,n=e.length;o{const t=[];for(let o=0,n=e.length;oM(E(e,t)),P=(e,t)=>{for(let o=0,n=e.length;o{const o={};for(let n=0,r=e.length;nt>=0&&tF(e,0),$=e=>F(e,e.length-1),V=(e,t)=>{for(let o=0;o{const o=q(e);for(let n=0,r=o.length;nY(e,((e,o)=>({k:o,v:t(e,o)}))),Y=(e,t)=>{const o={};return G(e,((e,n)=>{const r=t(e,n);o[r.k]=r.v})),o},J=(e,t)=>{const o=[];return G(e,((e,n)=>{o.push(t(e,n))})),o},Q=e=>J(e,h),X=(e,t)=>U.call(e,t),Z="undefined"!=typeof window?window:Function("return this;")(),ee=(e,t)=>((e,t)=>{let o=null!=t?t:Z;for(let t=0;t{const t=ee("ownerDocument.defaultView",e);return s(e)&&((e=>((e,t)=>{const o=((e,t)=>ee(e,t))(e,t);if(null==o)throw new Error(e+" not available on this browser");return o})("HTMLElement",e))(t).prototype.isPrototypeOf(e)||/^HTML\w*Element$/.test(te(e).constructor.name))},ne=e=>e.dom.nodeName.toLowerCase(),re=e=>e.dom.nodeType,se=e=>t=>re(t)===e,le=e=>8===re(e)||"#comment"===ne(e),ae=e=>ce(e)&&oe(e.dom),ce=se(1),ie=se(3),me=se(9),de=se(11),ue=e=>t=>ce(t)&&ne(t)===e,fe=(e,t,o)=>{if(!(r(o)||c(o)||u(o)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",o,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,o+"")},ge=(e,t,o)=>{fe(e.dom,t,o)},he=(e,t)=>{const o=e.dom;G(t,((e,t)=>{fe(o,t,e)}))},pe=(e,t)=>{const o=e.dom.getAttribute(t);return null===o?void 0:o},we=(e,t)=>C.from(pe(e,t)),be=(e,t)=>{e.dom.removeAttribute(t)},ve=e=>A(e.dom.attributes,((e,t)=>(e[t.name]=t.value,e)),{}),ye=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},xe={fromHtml:(e,t)=>{const o=(t||document).createElement("div");if(o.innerHTML=e,!o.hasChildNodes()||o.childNodes.length>1){const t="HTML does not have a single root node";throw console.error(t,e),new Error(t)}return ye(o.childNodes[0])},fromTag:(e,t)=>{const o=(t||document).createElement(e);return ye(o)},fromText:(e,t)=>{const o=(t||document).createTextNode(e);return ye(o)},fromDom:ye,fromPoint:(e,t,o)=>C.from(e.dom.elementFromPoint(t,o)).map(ye)},Ce=(e,t)=>{const o=e.dom;if(1!==o.nodeType)return!1;{const e=o;if(void 0!==e.matches)return e.matches(t);if(void 0!==e.msMatchesSelector)return e.msMatchesSelector(t);if(void 0!==e.webkitMatchesSelector)return e.webkitMatchesSelector(t);if(void 0!==e.mozMatchesSelector)return e.mozMatchesSelector(t);throw new Error("Browser lacks native selectors")}},Se=e=>1!==e.nodeType&&9!==e.nodeType&&11!==e.nodeType||0===e.childElementCount,Te=(e,t)=>{const o=void 0===t?document:t.dom;return Se(o)?C.none():C.from(o.querySelector(e)).map(xe.fromDom)},Re=(e,t)=>e.dom===t.dom,De=(e,t)=>{const o=e.dom,n=t.dom;return o!==n&&o.contains(n)},Oe=Ce,ke=e=>xe.fromDom(e.dom.ownerDocument),Ee=e=>me(e)?e:ke(e),Ne=e=>C.from(e.dom.parentNode).map(xe.fromDom),Be=e=>C.from(e.dom.parentElement).map(xe.fromDom),_e=(e,t)=>{const o=d(t)?t:y;let n=e.dom;const r=[];for(;null!==n.parentNode&&void 0!==n.parentNode;){const e=n.parentNode,t=xe.fromDom(e);if(r.push(t),!0===o(t))break;n=e}return r},ze=e=>C.from(e.dom.previousSibling).map(xe.fromDom),Ae=e=>C.from(e.dom.nextSibling).map(xe.fromDom),Le=e=>E(e.dom.childNodes,xe.fromDom),We=(e,t)=>{const o=e.dom.childNodes;return C.from(o[t]).map(xe.fromDom)},Me=(e,t)=>{Ne(e).each((o=>{o.dom.insertBefore(t.dom,e.dom)}))},je=(e,t)=>{Ae(e).fold((()=>{Ne(e).each((e=>{Ie(e,t)}))}),(e=>{Me(e,t)}))},Pe=(e,t)=>{const o=(e=>We(e,0))(e);o.fold((()=>{Ie(e,t)}),(o=>{e.dom.insertBefore(t.dom,o.dom)}))},Ie=(e,t)=>{e.dom.appendChild(t.dom)},Fe=(e,t)=>{Me(e,t),Ie(t,e)},He=(e,t)=>{N(t,((o,n)=>{const r=0===n?e:t[n-1];je(r,o)}))},$e=(e,t)=>{N(t,(t=>{Ie(e,t)}))},Ve=e=>{e.dom.textContent="",N(Le(e),(e=>{qe(e)}))},qe=e=>{const t=e.dom;null!==t.parentNode&&t.parentNode.removeChild(t)},Ue=e=>{const t=Le(e);t.length>0&&He(e,t),qe(e)},Ge=(e,t)=>xe.fromDom(e.dom.cloneNode(t)),Ke=e=>Ge(e,!1),Ye=e=>Ge(e,!0),Je=(e,t)=>{const o=xe.fromTag(t),n=ve(e);return he(o,n),o},Qe=["tfoot","thead","tbody","colgroup"],Xe=(e,t,o)=>({element:e,rowspan:t,colspan:o}),Ze=(e,t,o)=>({element:e,cells:t,section:o}),et=(e,t,o)=>({element:e,isNew:t,isLocked:o}),tt=(e,t,o,n)=>({element:e,cells:t,section:o,isNew:n}),ot=d(Element.prototype.attachShadow)&&d(Node.prototype.getRootNode),nt=g(ot),rt=ot?e=>xe.fromDom(e.dom.getRootNode()):Ee,st=e=>xe.fromDom(e.dom.host),lt=e=>{const t=ie(e)?e.dom.parentNode:e.dom;if(null==t||null===t.ownerDocument)return!1;const o=t.ownerDocument;return(e=>{const t=rt(e);return de(o=t)&&m(o.dom.host)?C.some(t):C.none();var o})(xe.fromDom(t)).fold((()=>o.body.contains(t)),(n=lt,r=st,e=>n(r(e))));var n,r},at=e=>{const t=e.dom.body;if(null==t)throw new Error("Body is not available yet");return xe.fromDom(t)},ct=(e,t)=>{let o=[];return N(Le(e),(e=>{t(e)&&(o=o.concat([e])),o=o.concat(ct(e,t))})),o},it=(e,t,o)=>((e,o,n)=>_(_e(e,n),(e=>Ce(e,t))))(e,0,o),mt=(e,t)=>((e,o)=>_(Le(e),(e=>Ce(e,t))))(e),dt=(e,t)=>((e,t)=>{const o=void 0===t?document:t.dom;return Se(o)?[]:E(o.querySelectorAll(e),xe.fromDom)})(t,e);var ut=(e,t,o,n,r)=>e(o,n)?C.some(o):d(r)&&r(o)?C.none():t(o,n,r);const ft=(e,t,o)=>{let n=e.dom;const r=d(o)?o:y;for(;n.parentNode;){n=n.parentNode;const e=xe.fromDom(n);if(t(e))return C.some(e);if(r(e))break}return C.none()},gt=(e,t,o)=>ut(((e,t)=>t(e)),ft,e,t,o),ht=(e,t,o)=>ft(e,(e=>Ce(e,t)),o),pt=(e,t)=>((e,o)=>L(e.dom.childNodes,(e=>{return o=xe.fromDom(e),Ce(o,t);var o})).map(xe.fromDom))(e),wt=(e,t)=>Te(t,e),bt=(e,t,o)=>ut(((e,t)=>Ce(e,t)),ht,e,t,o),vt=(e,t,o=p)=>e.exists((e=>o(e,t))),yt=e=>{const t=[],o=e=>{t.push(e)};for(let t=0;te?C.some(t):C.none(),Ct=(e,t,o)=>""===t||e.length>=t.length&&e.substr(o,o+t.length)===t,St=(e,t,o=0,n)=>{const r=e.indexOf(t,o);return-1!==r&&(!!i(n)||r+t.length<=n)},Tt=(e,t)=>Ct(e,t,0),Rt=(e,t)=>Ct(e,t,e.length-t.length),Dt=(e=>t=>t.replace(e,""))(/^\s+|\s+$/g),Ot=e=>e.length>0,kt=e=>void 0!==e.style&&d(e.style.getPropertyValue),Et=(e,t,o)=>{if(!r(o))throw console.error("Invalid call to CSS.set. Property ",t,":: Value ",o,":: Element ",e),new Error("CSS value must be a string: "+o);kt(e)&&e.style.setProperty(t,o)},Nt=(e,t,o)=>{const n=e.dom;Et(n,t,o)},Bt=(e,t)=>{const o=e.dom;G(t,((e,t)=>{Et(o,t,e)}))},_t=(e,t)=>{const o=e.dom,n=window.getComputedStyle(o).getPropertyValue(t);return""!==n||lt(e)?n:zt(o,t)},zt=(e,t)=>kt(e)?e.style.getPropertyValue(t):"",At=(e,t)=>{const o=e.dom,n=zt(o,t);return C.from(n).filter((e=>e.length>0))},Lt=(e,t)=>{((e,t)=>{kt(e)&&e.style.removeProperty(t)})(e.dom,t),vt(we(e,"style").map(Dt),"")&&be(e,"style")},Wt=(e,t,o=0)=>we(e,t).map((e=>parseInt(e,10))).getOr(o),Mt=(e,t)=>Wt(e,t,1),jt=e=>ue("col")(e)?Wt(e,"span",1)>1:Mt(e,"colspan")>1,Pt=e=>Mt(e,"rowspan")>1,It=(e,t)=>parseInt(_t(e,t),10),Ft=g(10),Ht=g(10),$t=(e,t)=>Vt(e,t,x),Vt=(e,t,o)=>j(Le(e),(e=>Ce(e,t)?o(e)?[e]:[]:Vt(e,t,o))),qt=(e,t)=>((e,t,o=y)=>o(t)?C.none():D(e,ne(t))?C.some(t):ht(t,e.join(","),(e=>Ce(e,"table")||o(e))))(["td","th"],e,t),Ut=e=>$t(e,"th,td"),Gt=e=>Ce(e,"colgroup")?mt(e,"col"):j(Jt(e),(e=>mt(e,"col"))),Kt=(e,t)=>bt(e,"table",t),Yt=e=>$t(e,"tr"),Jt=e=>Kt(e).fold(g([]),(e=>mt(e,"colgroup"))),Qt=(e,t)=>E(e,(e=>{if("colgroup"===ne(e)){const t=E(Gt(e),(e=>{const t=Wt(e,"span",1);return Xe(e,1,t)}));return Ze(e,t,"colgroup")}{const o=E(Ut(e),(e=>{const t=Wt(e,"rowspan",1),o=Wt(e,"colspan",1);return Xe(e,t,o)}));return Ze(e,o,t(e))}})),Xt=e=>Ne(e).map((e=>{const t=ne(e);return(e=>D(Qe,e))(t)?t:"tbody"})).getOr("tbody"),Zt=e=>{const t=Yt(e),o=[...Jt(e),...t];return Qt(o,Xt)},eo=e=>{let t,o=!1;return(...n)=>(o||(o=!0,t=e.apply(null,n)),t)},to=()=>oo(0,0),oo=(e,t)=>({major:e,minor:t}),no={nu:oo,detect:(e,t)=>{const o=String(t).toLowerCase();return 0===e.length?to():((e,t)=>{const o=((e,t)=>{for(let o=0;oNumber(t.replace(o,"$"+e));return oo(n(1),n(2))})(e,o)},unknown:to},ro=(e,t)=>{const o=String(t).toLowerCase();return L(e,(e=>e.search(o)))},so=/.*?version\/\ ?([0-9]+)\.([0-9]+).*/,lo=e=>t=>St(t,e),ao=[{name:"Edge",versionRegexes:[/.*?edge\/ ?([0-9]+)\.([0-9]+)$/],search:e=>St(e,"edge/")&&St(e,"chrome")&&St(e,"safari")&&St(e,"applewebkit")},{name:"Chromium",brand:"Chromium",versionRegexes:[/.*?chrome\/([0-9]+)\.([0-9]+).*/,so],search:e=>St(e,"chrome")&&!St(e,"chromeframe")},{name:"IE",versionRegexes:[/.*?msie\ ?([0-9]+)\.([0-9]+).*/,/.*?rv:([0-9]+)\.([0-9]+).*/],search:e=>St(e,"msie")||St(e,"trident")},{name:"Opera",versionRegexes:[so,/.*?opera\/([0-9]+)\.([0-9]+).*/],search:lo("opera")},{name:"Firefox",versionRegexes:[/.*?firefox\/\ ?([0-9]+)\.([0-9]+).*/],search:lo("firefox")},{name:"Safari",versionRegexes:[so,/.*?cpu os ([0-9]+)_([0-9]+).*/],search:e=>(St(e,"safari")||St(e,"mobile/"))&&St(e,"applewebkit")}],co=[{name:"Windows",search:lo("win"),versionRegexes:[/.*?windows\ nt\ ?([0-9]+)\.([0-9]+).*/]},{name:"iOS",search:e=>St(e,"iphone")||St(e,"ipad"),versionRegexes:[/.*?version\/\ ?([0-9]+)\.([0-9]+).*/,/.*cpu os ([0-9]+)_([0-9]+).*/,/.*cpu iphone os ([0-9]+)_([0-9]+).*/]},{name:"Android",search:lo("android"),versionRegexes:[/.*?android\ ?([0-9]+)\.([0-9]+).*/]},{name:"macOS",search:lo("mac os x"),versionRegexes:[/.*?mac\ os\ x\ ?([0-9]+)_([0-9]+).*/]},{name:"Linux",search:lo("linux"),versionRegexes:[]},{name:"Solaris",search:lo("sunos"),versionRegexes:[]},{name:"FreeBSD",search:lo("freebsd"),versionRegexes:[]},{name:"ChromeOS",search:lo("cros"),versionRegexes:[/.*?chrome\/([0-9]+)\.([0-9]+).*/]}],io={browsers:g(ao),oses:g(co)},mo="Edge",uo="Chromium",fo="Opera",go="Firefox",ho="Safari",po=e=>{const t=e.current,o=e.version,n=e=>()=>t===e;return{current:t,version:o,isEdge:n(mo),isChromium:n(uo),isIE:n("IE"),isOpera:n(fo),isFirefox:n(go),isSafari:n(ho)}},wo=()=>po({current:void 0,version:no.unknown()}),bo=po,vo=(g(mo),g(uo),g("IE"),g(fo),g(go),g(ho),"Windows"),yo="Android",xo="Linux",Co="macOS",So="Solaris",To="FreeBSD",Ro="ChromeOS",Do=e=>{const t=e.current,o=e.version,n=e=>()=>t===e;return{current:t,version:o,isWindows:n(vo),isiOS:n("iOS"),isAndroid:n(yo),isMacOS:n(Co),isLinux:n(xo),isSolaris:n(So),isFreeBSD:n(To),isChromeOS:n(Ro)}},Oo=()=>Do({current:void 0,version:no.unknown()}),ko=Do,Eo=(g(vo),g("iOS"),g(yo),g(xo),g(Co),g(So),g(To),g(Ro),e=>window.matchMedia(e).matches);let No=eo((()=>((e,t,o)=>{const n=io.browsers(),r=io.oses(),s=t.bind((e=>((e,t)=>V(t.brands,(t=>{const o=t.brand.toLowerCase();return L(e,(e=>{var t;return o===(null===(t=e.brand)||void 0===t?void 0:t.toLowerCase())})).map((e=>({current:e.name,version:no.nu(parseInt(t.version,10),0)})))})))(n,e))).orThunk((()=>((e,t)=>ro(e,t).map((e=>{const o=no.detect(e.versionRegexes,t);return{current:e.name,version:o}})))(n,e))).fold(wo,bo),l=((e,t)=>ro(e,t).map((e=>{const o=no.detect(e.versionRegexes,t);return{current:e.name,version:o}})))(r,e).fold(Oo,ko),a=((e,t,o,n)=>{const r=e.isiOS()&&!0===/ipad/i.test(o),s=e.isiOS()&&!r,l=e.isiOS()||e.isAndroid(),a=l||n("(pointer:coarse)"),c=r||!s&&l&&n("(min-device-width:768px)"),i=s||l&&!c,m=t.isSafari()&&e.isiOS()&&!1===/safari/i.test(o),d=!i&&!c&&!m;return{isiPad:g(r),isiPhone:g(s),isTablet:g(c),isPhone:g(i),isTouch:g(a),isAndroid:e.isAndroid,isiOS:e.isiOS,isWebView:g(m),isDesktop:g(d)}})(l,s,e,o);return{browser:s,os:l,deviceType:a}})(navigator.userAgent,C.from(navigator.userAgentData),Eo)));const Bo=()=>No(),_o=(e,t)=>{const o=o=>{const n=t(o);if(n<=0||null===n){const t=_t(o,e);return parseFloat(t)||0}return n},n=(e,t)=>A(t,((t,o)=>{const n=_t(e,o),r=void 0===n?0:parseInt(n,10);return isNaN(r)?t:t+r}),0);return{set:(t,o)=>{if(!u(o)&&!o.match(/^[0-9]+$/))throw new Error(e+".set accepts only positive integer values. Value was "+o);const n=t.dom;kt(n)&&(n.style[e]=o+"px")},get:o,getOuter:o,aggregate:n,max:(e,t,o)=>{const r=n(e,o);return t>r?t-r:0}}},zo=(e,t,o)=>((e,t)=>(e=>{const t=parseFloat(e);return isNaN(t)?C.none():C.some(t)})(e).getOr(t))(_t(e,t),o),Ao=_o("width",(e=>e.dom.offsetWidth)),Lo=e=>Ao.get(e),Wo=e=>Ao.getOuter(e),Mo=e=>((e,t)=>{const o=e.dom,n=o.getBoundingClientRect().width||o.offsetWidth;return"border-box"===t?n:((e,t,o,n)=>t-zo(e,`padding-${o}`,0)-zo(e,`padding-${n}`,0)-zo(e,`border-${o}-width`,0)-zo(e,`border-${n}-width`,0))(e,n,"left","right")})(e,"content-box"),jo=(e,t,o)=>{const n=e.cells,r=n.slice(0,t),s=n.slice(t),l=r.concat(o).concat(s);return Fo(e,l)},Po=(e,t,o)=>jo(e,t,[o]),Io=(e,t,o)=>{e.cells[t]=o},Fo=(e,t)=>tt(e.element,t,e.section,e.isNew),Ho=(e,t)=>e.cells[t],$o=(e,t)=>Ho(e,t).element,Vo=e=>e.cells.length,qo=e=>{const t=B(e,(e=>"colgroup"===e.section));return{rows:t.fail,cols:t.pass}},Uo=(e,t,o)=>{const n=E(e.cells,o);return tt(t(e.element),n,e.section,!0)},Go="data-snooker-locked-cols",Ko=e=>we(e,Go).bind((e=>C.from(e.match(/\d+/g)))).map((e=>I(e,x))),Yo=e=>{const t=A(qo(e).rows,((e,t)=>(N(t.cells,((t,o)=>{t.isLocked&&(e[o]=!0)})),e)),{}),o=J(t,((e,t)=>parseInt(t,10)));return((e,t)=>{const o=S.call(e,0);return o.sort(void 0),o})(o)},Jo=(e,t)=>e+","+t,Qo=(e,t)=>{const o=j(e.all,(e=>e.cells));return _(o,t)},Xo=e=>{const t={},o=[],n=H(e).map((e=>e.element)).bind(Kt).bind(Ko).getOr({});let r=0,s=0,l=0;const{pass:a,fail:c}=B(e,(e=>"colgroup"===e.section));N(c,(e=>{const a=[];N(e.cells,(e=>{let o=0;for(;void 0!==t[Jo(l,o)];)o++;const r=((e,t)=>X(e,t)&&void 0!==e[t]&&null!==e[t])(n,o.toString()),c=((e,t,o,n,r,s)=>({element:e,rowspan:t,colspan:o,row:n,column:r,isLocked:s}))(e.element,e.rowspan,e.colspan,l,o,r);for(let n=0;n{const t=(e=>{const t={};let o=0;return N(e.cells,(e=>{const n=e.colspan;k(n,(r=>{const s=o+r;t[s]=((e,t,o)=>({element:e,colspan:t,column:o}))(e.element,n,s)})),o+=n})),t})(e),o=((e,t)=>({element:e,columns:t}))(e.element,Q(t));return{colgroups:[o],columns:t}})).getOrThunk((()=>({colgroups:[],columns:{}}))),d=((e,t)=>({rows:e,columns:t}))(r,s);return{grid:d,access:t,all:o,columns:i,colgroups:m}},Zo=e=>{const t=Zt(e);return Xo(t)},en=Xo,tn=(e,t,o)=>C.from(e.access[Jo(t,o)]),on=(e,t,o)=>{const n=Qo(e,(e=>o(t,e.element)));return n.length>0?C.some(n[0]):C.none()},nn=Qo,rn=e=>j(e.all,(e=>e.cells)),sn=e=>Q(e.columns),ln=e=>q(e.columns).length>0,an=(e,t)=>C.from(e.columns[t]),cn=(e,t=x)=>{const o=e.grid,n=k(o.columns,h),r=k(o.rows,h);return E(n,(o=>mn((()=>j(r,(t=>tn(e,t,o).filter((e=>e.column===o)).toArray()))),(e=>1===e.colspan&&t(e.element)),(()=>tn(e,0,o)))))},mn=(e,t,o)=>{const n=e();return L(n,t).orThunk((()=>C.from(n[0]).orThunk(o))).map((e=>e.element))},dn=e=>{const t=e.grid,o=k(t.rows,h),n=k(t.columns,h);return E(o,(t=>mn((()=>j(n,(o=>tn(e,t,o).filter((e=>e.row===t)).fold(g([]),(e=>[e]))))),(e=>1===e.rowspan),(()=>tn(e,t,0)))))},un=(e,t)=>o=>"rtl"===fn(o)?t:e,fn=e=>"rtl"===_t(e,"direction")?"rtl":"ltr",gn=_o("height",(e=>{const t=e.dom;return lt(e)?t.getBoundingClientRect().height:t.offsetHeight})),hn=e=>gn.get(e),pn=e=>gn.getOuter(e),wn=(e,t)=>({left:e,top:t,translate:(o,n)=>wn(e+o,t+n)}),bn=wn,vn=(e,t)=>void 0!==e?e:void 0!==t?t:0,yn=e=>{const t=e.dom.ownerDocument,o=t.body,n=t.defaultView,r=t.documentElement;if(o===e.dom)return bn(o.offsetLeft,o.offsetTop);const s=vn(null==n?void 0:n.pageYOffset,r.scrollTop),l=vn(null==n?void 0:n.pageXOffset,r.scrollLeft),a=vn(r.clientTop,o.clientTop),c=vn(r.clientLeft,o.clientLeft);return xn(e).translate(l-c,s-a)},xn=e=>{const t=e.dom,o=t.ownerDocument.body;return o===t?bn(o.offsetLeft,o.offsetTop):lt(e)?(e=>{const t=e.getBoundingClientRect();return bn(t.left,t.top)})(t):bn(0,0)},Cn=(e,t)=>({row:e,y:t}),Sn=(e,t)=>({col:e,x:t}),Tn=e=>yn(e).left+Wo(e),Rn=e=>yn(e).left,Dn=(e,t)=>Sn(e,Rn(t)),On=(e,t)=>Sn(e,Tn(t)),kn=e=>yn(e).top,En=(e,t)=>Cn(e,kn(t)),Nn=(e,t)=>Cn(e,kn(t)+pn(t)),Bn=(e,t,o)=>{if(0===o.length)return[];const n=E(o.slice(1),((t,o)=>t.map((t=>e(o,t))))),r=o[o.length-1].map((e=>t(o.length-1,e)));return n.concat([r])},_n={delta:h,positions:e=>Bn(En,Nn,e),edge:kn},zn=un({delta:h,edge:Rn,positions:e=>Bn(Dn,On,e)},{delta:e=>-e,edge:Tn,positions:e=>Bn(On,Dn,e)}),An={delta:(e,t)=>zn(t).delta(e,t),positions:(e,t)=>zn(t).positions(e,t),edge:e=>zn(e).edge(e)},Ln={unsupportedLength:["em","ex","cap","ch","ic","rem","lh","rlh","vw","vh","vi","vb","vmin","vmax","cm","mm","Q","in","pc","pt","px"],fixed:["px","pt"],relative:["%"],empty:[""]},Wn=(()=>{const e="[0-9]+",t="[eE][+-]?"+e,o=e=>`(?:${e})?`,n=["Infinity",e+"\\."+o(e)+o(t),"\\."+e+o(t),e+o(t)].join("|");return new RegExp(`^([+-]?(?:${n}))(.*)$`)})(),Mn=/(\d+(\.\d+)?)%/,jn=/(\d+(\.\d+)?)px|em/,Pn=ue("col"),In=(e,t,o)=>{const n=Be(e).getOrThunk((()=>at(ke(e))));return t(e)/o(n)*100},Fn=(e,t)=>{Nt(e,"width",t+"px")},Hn=(e,t)=>{Nt(e,"width",t+"%")},$n=(e,t)=>{Nt(e,"height",t+"px")},Vn=e=>{const t=(e=>{return zo(t=e,"height",t.dom.offsetHeight)+"px";var t})(e);return t?((e,t,o,n)=>{const r=parseFloat(e);return Rt(e,"%")&&"table"!==ne(t)?((e,t,o,n)=>{const r=Kt(e).map((e=>{const n=o(e);return Math.floor(t/100*n)})).getOr(t);return n(e,r),r})(t,r,o,n):r})(t,e,hn,$n):hn(e)},qn=(e,t)=>At(e,t).orThunk((()=>we(e,t).map((e=>e+"px")))),Un=e=>qn(e,"width"),Gn=e=>In(e,Lo,Mo),Kn=e=>{return Pn(e)?Lo(e):zo(t=e,"width",t.dom.offsetWidth);var t},Yn=e=>((e,t,o)=>o(e)/Mt(e,"rowspan"))(e,0,Vn),Jn=(e,t,o)=>{Nt(e,"width",t+o)},Qn=e=>In(e,Lo,Mo)+"%",Xn=g(Mn),Zn=ue("col"),er=e=>Un(e).getOrThunk((()=>Kn(e)+"px")),tr=e=>{return(t=e,qn(t,"height")).getOrThunk((()=>Yn(e)+"px"));var t},or=(e,t,o,n,r,s)=>e.filter(n).fold((()=>s(((e,t)=>{if(t<0||t>=e.length-1)return C.none();const o=e[t].fold((()=>{const o=(e=>{const t=S.call(e,0);return t.reverse(),t})(e.slice(0,t));return V(o,((e,t)=>e.map((e=>({value:e,delta:t+1})))))}),(e=>C.some({value:e,delta:0}))),n=e[t+1].fold((()=>{const o=e.slice(t+1);return V(o,((e,t)=>e.map((e=>({value:e,delta:t+1})))))}),(e=>C.some({value:e,delta:1})));return o.bind((e=>n.map((t=>{const o=t.delta+e.delta;return Math.abs(t.value-e.value)/o}))))})(o,t))),(e=>r(e))),nr=(e,t,o,n)=>{const r=cn(e),s=ln(e)?(e=>E(sn(e),(e=>C.from(e.element))))(e):r,l=[C.some(An.edge(t))].concat(E(An.positions(r,t),(e=>e.map((e=>e.x))))),a=b(jt);return E(s,((e,t)=>or(e,t,l,a,(e=>{if((e=>{const t=Bo().browser,o=t.isChromium()||t.isFirefox();return!Zn(e)||o})(e))return o(e);{const e=null!=(s=r[t])?h(s):C.none();return or(e,t,l,a,(e=>n(C.some(Lo(e)))),n)}var s}),n)))},rr=e=>e.map((e=>e+"px")).getOr(""),sr=(e,t,o)=>nr(e,t,Kn,(e=>e.getOrThunk(o.minCellWidth))),lr=(e,t,o,n,r)=>{const s=dn(e),l=[C.some(o.edge(t))].concat(E(o.positions(s,t),(e=>e.map((e=>e.y)))));return E(s,((e,t)=>or(e,t,l,b(Pt),n,r)))},ar=(e,t)=>()=>lt(e)?t(e):parseFloat(At(e,"width").getOr("0")),cr=e=>{const t=ar(e,(e=>parseFloat(Qn(e)))),o=ar(e,Lo);return{width:t,pixelWidth:o,getWidths:(t,o)=>((e,t,o)=>nr(e,t,Gn,(e=>e.fold((()=>o.minCellWidth()),(e=>e/o.pixelWidth()*100)))))(t,e,o),getCellDelta:e=>e/o()*100,singleColumnWidth:(e,t)=>[100-e],minCellWidth:()=>Ft()/o()*100,setElementWidth:Hn,adjustTableWidth:o=>{const n=t();Hn(e,n+o/100*n)},isRelative:!0,label:"percent"}},ir=e=>{const t=ar(e,Lo);return{width:t,pixelWidth:t,getWidths:(t,o)=>sr(t,e,o),getCellDelta:h,singleColumnWidth:(e,t)=>[Math.max(Ft(),e+t)-e],minCellWidth:Ft,setElementWidth:Fn,adjustTableWidth:o=>{const n=t()+o;Fn(e,n)},isRelative:!1,label:"pixel"}},mr=e=>Un(e).fold((()=>(e=>{const t=ar(e,Lo),o=g(0);return{width:t,pixelWidth:t,getWidths:(t,o)=>sr(t,e,o),getCellDelta:o,singleColumnWidth:g([0]),minCellWidth:o,setElementWidth:f,adjustTableWidth:f,isRelative:!0,label:"none"}})(e)),(t=>((e,t)=>null!==Xn().exec(t)?cr(e):ir(e))(e,t))),dr=ir,ur=cr,fr=(e,t,o)=>{const n=e[o].element,r=xe.fromTag("td");Ie(r,xe.fromTag("br")),(t?Ie:Pe)(n,r)},gr=((e,t)=>{const o=t=>e(t)?C.from(t.dom.nodeValue):C.none();return{get:t=>{if(!e(t))throw new Error("Can only get text value of a text node");return o(t).getOr("")},getOption:o,set:(t,o)=>{if(!e(t))throw new Error("Can only set raw text value of a text node");t.dom.nodeValue=o}}})(ie),hr=e=>gr.get(e),pr=e=>gr.getOption(e),wr=(e,t)=>gr.set(e,t),br=e=>"img"===ne(e)?1:pr(e).fold((()=>Le(e).length),(e=>e.length)),vr=["img","br"],yr=e=>pr(e).filter((e=>0!==e.trim().length||e.indexOf("\xa0")>-1)).isSome()||D(vr,ne(e))||(e=>ae(e)&&"false"===pe(e,"contenteditable"))(e),xr=e=>((e,t)=>{const o=e=>{for(let n=0;nSr(e,yr),Sr=(e,t)=>{const o=e=>{const n=Le(e);for(let e=n.length-1;e>=0;e--){const r=n[e];if(t(r))return C.some(r);const s=o(r);if(s.isSome())return s}return C.none()};return o(e)},Tr={scope:["row","col"]},Rr=e=>()=>{const t=xe.fromTag("td",e.dom);return Ie(t,xe.fromTag("br",e.dom)),t},Dr=e=>()=>xe.fromTag("col",e.dom),Or=e=>()=>xe.fromTag("colgroup",e.dom),kr=e=>()=>xe.fromTag("tr",e.dom),Er=(e,t,o)=>{const n=((e,t)=>{const o=Je(e,t),n=Le(Ye(e));return $e(o,n),o})(e,t);return G(o,((e,t)=>{null===e?be(n,t):ge(n,t,e)})),n},Nr=e=>e,Br=(e,t,o)=>{const n=(e,t)=>{((e,t)=>{const o=e.dom,n=t.dom;kt(o)&&kt(n)&&(n.style.cssText=o.style.cssText)})(e.element,t),Lt(t,"height"),1!==e.colspan&&Lt(t,"width")};return{col:o=>{const r=xe.fromTag(ne(o.element),t.dom);return n(o,r),e(o.element,r),r},colgroup:Or(t),row:kr(t),cell:r=>{const s=xe.fromTag(ne(r.element),t.dom),l=o.getOr(["strong","em","b","i","span","font","h1","h2","h3","h4","h5","h6","p","div"]),a=l.length>0?((e,t,o)=>xr(e).map((n=>{const r=o.join(","),s=it(n,r,(t=>Re(t,e)));return z(s,((e,t)=>{const o=Ke(t);return Ie(e,o),o}),t)})).getOr(t))(r.element,s,l):s;return Ie(a,xe.fromTag("br")),n(r,s),((e,t)=>{G(Tr,((o,n)=>we(e,n).filter((e=>D(o,e))).each((e=>ge(t,n,e)))))})(r.element,s),e(r.element,s),s},replace:Er,colGap:Dr(t),gap:Rr(t)}},_r=e=>({col:Dr(e),colgroup:Or(e),row:kr(e),cell:Rr(e),replace:Nr,colGap:Dr(e),gap:Rr(e)}),zr=e=>t=>t.options.get(e),Ar="100%",Lr=e=>{var t;const o=e.dom,n=null!==(t=o.getParent(e.selection.getStart(),o.isBlock))&&void 0!==t?t:e.getBody();return Mo(xe.fromDom(n))+"px"},Wr=e=>C.from(e.options.get("table_clone_elements")),Mr=zr("table_header_type"),jr=zr("table_column_resizing"),Pr=e=>"preservetable"===jr(e),Ir=e=>"resizetable"===jr(e),Fr=zr("table_sizing_mode"),Hr=e=>"relative"===Fr(e),$r=e=>"fixed"===Fr(e),Vr=e=>"responsive"===Fr(e),qr=zr("table_resize_bars"),Ur=zr("table_style_by_css"),Gr=zr("table_merge_content_on_paste"),Kr=e=>{const t=e.options,o=t.get("table_default_attributes");return t.isSet("table_default_attributes")?o:((e,t)=>Vr(e)||Ur(e)?t:$r(e)?{...t,width:Lr(e)}:{...t,width:Ar})(e,o)},Yr=zr("table_use_colgroups"),Jr=e=>bt(e,"[contenteditable]"),Qr=(e,t=!1)=>lt(e)?e.dom.isContentEditable:Jr(e).fold(g(t),(e=>"true"===Xr(e))),Xr=e=>e.dom.contentEditable,Zr=e=>xe.fromDom(e.getBody()),es=e=>t=>Re(t,Zr(e)),ts=e=>{be(e,"data-mce-style");const t=e=>be(e,"data-mce-style");N(Ut(e),t),N(Gt(e),t),N(Yt(e),t)},os=e=>xe.fromDom(e.selection.getStart()),ns=e=>e.getBoundingClientRect().width,rs=e=>e.getBoundingClientRect().height,ss=e=>gt(e,ue("table")).exists(Qr),ls=(e,t)=>{const o=t.column,n=t.column+t.colspan-1,r=t.row,s=t.row+t.rowspan-1;return o<=e.finishCol&&n>=e.startCol&&r<=e.finishRow&&s>=e.startRow},as=(e,t)=>t.column>=e.startCol&&t.column+t.colspan-1<=e.finishCol&&t.row>=e.startRow&&t.row+t.rowspan-1<=e.finishRow,cs=(e,t,o)=>{const n=on(e,t,Re),r=on(e,o,Re);return n.bind((e=>r.map((t=>{return o=e,n=t,{startRow:Math.min(o.row,n.row),startCol:Math.min(o.column,n.column),finishRow:Math.max(o.row+o.rowspan-1,n.row+n.rowspan-1),finishCol:Math.max(o.column+o.colspan-1,n.column+n.colspan-1)};var o,n}))))},is=(e,t,o)=>cs(e,t,o).map((t=>{const o=nn(e,w(ls,t));return E(o,(e=>e.element))})),ms=(e,t)=>on(e,t,((e,t)=>De(t,e))).map((e=>e.element)),ds=(e,t,o)=>{const n=fs(e);return is(n,t,o)},us=(e,t,o,n,r)=>{const s=fs(e),l=Re(e,o)?C.some(t):ms(s,t),a=Re(e,r)?C.some(n):ms(s,n);return l.bind((e=>a.bind((t=>is(s,e,t)))))},fs=Zo;var gs=["body","p","div","article","aside","figcaption","figure","footer","header","nav","section","ol","ul","li","table","thead","tbody","tfoot","caption","tr","td","th","h1","h2","h3","h4","h5","h6","blockquote","pre","address"],hs=()=>({up:g({selector:ht,closest:bt,predicate:ft,all:_e}),down:g({selector:dt,predicate:ct}),styles:g({get:_t,getRaw:At,set:Nt,remove:Lt}),attrs:g({get:pe,set:ge,remove:be,copyTo:(e,t)=>{const o=ve(e);he(t,o)}}),insert:g({before:Me,after:je,afterAll:He,append:Ie,appendAll:$e,prepend:Pe,wrap:Fe}),remove:g({unwrap:Ue,remove:qe}),create:g({nu:xe.fromTag,clone:e=>xe.fromDom(e.dom.cloneNode(!1)),text:xe.fromText}),query:g({comparePosition:(e,t)=>e.dom.compareDocumentPosition(t.dom),prevSibling:ze,nextSibling:Ae}),property:g({children:Le,name:ne,parent:Ne,document:e=>Ee(e).dom,isText:ie,isComment:le,isElement:ce,isSpecial:e=>{const t=ne(e);return D(["script","noscript","iframe","noframes","noembed","title","style","textarea","xmp"],t)},getLanguage:e=>ce(e)?we(e,"lang"):C.none(),getText:hr,setText:wr,isBoundary:e=>!!ce(e)&&("body"===ne(e)||D(gs,ne(e))),isEmptyTag:e=>!!ce(e)&&D(["br","img","hr","input"],ne(e)),isNonEditable:e=>ce(e)&&"false"===pe(e,"contenteditable")}),eq:Re,is:Oe});const ps=(e,t,o,n)=>{const r=t(e,o);return z(n,((o,n)=>{const r=t(e,n);return ws(e,o,r)}),r)},ws=(e,t,o)=>t.bind((t=>o.filter(w(e.eq,t)))),bs=hs(),vs=(e,t)=>((e,t,o)=>o.length>0?((e,t,o,n)=>n(e,t,o[0],o.slice(1)))(e,t,o,ps):C.none())(bs,((t,o)=>e(o)),t),ys=e=>ht(e,"table"),xs=(e,t,o)=>{const n=e=>t=>void 0!==o&&o(t)||Re(t,e);return Re(e,t)?C.some({boxes:C.some([e]),start:e,finish:t}):ys(e).bind((r=>ys(t).bind((s=>{if(Re(r,s))return C.some({boxes:ds(r,e,t),start:e,finish:t});if(De(r,s)){const o=it(t,"td,th",n(r)),l=o.length>0?o[o.length-1]:t;return C.some({boxes:us(r,e,r,t,s),start:e,finish:l})}if(De(s,r)){const o=it(e,"td,th",n(s)),l=o.length>0?o[o.length-1]:e;return C.some({boxes:us(s,e,r,t,s),start:e,finish:l})}return((e,t,o)=>((e,t,o,n=y)=>{const r=[t].concat(e.up().all(t)),s=[o].concat(e.up().all(o)),l=e=>W(e,n).fold((()=>e),(t=>e.slice(0,t+1))),a=l(r),c=l(s),i=L(a,(t=>O(c,((e,t)=>w(e.eq,t))(e,t))));return{firstpath:a,secondpath:c,shared:i}})(bs,e,t,void 0))(e,t).shared.bind((l=>bt(l,"table",o).bind((o=>{const l=it(t,"td,th",n(o)),a=l.length>0?l[l.length-1]:t,c=it(e,"td,th",n(o)),i=c.length>0?c[c.length-1]:e;return C.some({boxes:us(o,e,r,t,s),start:i,finish:a})}))))}))))},Cs=(e,t)=>{const o=dt(e,t);return o.length>0?C.some(o):C.none()},Ss=(e,t,o)=>wt(e,t).bind((t=>wt(e,o).bind((e=>vs(ys,[t,e]).map((o=>({first:t,last:e,table:o}))))))),Ts=(e,t,o,n,r)=>((e,t)=>L(e,(e=>Ce(e,t))))(e,r).bind((e=>((e,t,o)=>Kt(e).bind((n=>((e,t,o,n)=>on(e,t,Re).bind((t=>{const r=o>0?t.row+t.rowspan-1:t.row,s=n>0?t.column+t.colspan-1:t.column;return tn(e,r+o,s+n).map((e=>e.element))})))(fs(n),e,t,o))))(e,t,o).bind((e=>((e,t)=>ht(e,"table").bind((o=>wt(o,t).bind((t=>xs(t,e).bind((e=>e.boxes.map((t=>({boxes:t,start:e.start,finish:e.finish}))))))))))(e,n))))),Rs=(e,t)=>Cs(e,t),Ds=(e,t,o)=>Ss(e,t,o).bind((t=>{const o=t=>Re(e,t),n="thead,tfoot,tbody,table",r=ht(t.first,n,o),s=ht(t.last,n,o);return r.bind((e=>s.bind((o=>Re(e,o)?((e,t,o)=>((e,t,o)=>cs(e,t,o).bind((t=>((e,t)=>{let o=!0;const n=w(as,t);for(let r=t.startRow;r<=t.finishRow;r++)for(let s=t.startCol;s<=t.finishCol;s++)o=o&&tn(e,r,s).exists(n);return o?C.some(t):C.none()})(e,t))))(fs(e),t,o))(t.table,t.first,t.last):C.none()))))})),Os=h,ks=e=>{const t=(e,t)=>we(e,t).exists((e=>parseInt(e,10)>1));return e.length>0&&P(e,(e=>t(e,"rowspan")||t(e,"colspan")))?C.some(e):C.none()},Es=(e,t,o)=>t.length<=1?C.none():Ds(e,o.firstSelectedSelector,o.lastSelectedSelector).map((e=>({bounds:e,cells:t}))),Ns="data-mce-selected",Bs="data-mce-first-selected",_s="data-mce-last-selected",zs="["+Ns+"]",As={selected:Ns,selectedSelector:"td["+Ns+"],th["+Ns+"]",firstSelected:Bs,firstSelectedSelector:"td["+Bs+"],th["+Bs+"]",lastSelected:_s,lastSelectedSelector:"td["+_s+"],th["+_s+"]"},Ls=(e,t,o)=>({element:o,mergable:Es(t,e,As),unmergable:ks(e),selection:Os(e)}),Ws=e=>(t,o)=>{const n=ne(t),r="col"===n||"colgroup"===n?Kt(s=t).bind((e=>Rs(e,As.firstSelectedSelector))).fold(g(s),(e=>e[0])):t;var s;return bt(r,e,o)},Ms=Ws("th,td,caption"),js=Ws("th,td"),Ps=e=>{return t=e.model.table.getSelectedCells(),E(t,xe.fromDom);var t},Is=(e,t)=>{e.on("BeforeGetContent",(t=>{const o=o=>{t.preventDefault(),(e=>Kt(e[0]).map((e=>{const t=((e,t)=>{const o=e=>Ce(e.element,t),n=Ye(e),r=Zt(n),s=mr(e),l=en(r),a=((e,t)=>{const o=e.grid.columns;let n=e.grid.rows,r=o,s=0,l=0;const a=[],c=[];return G(e.access,(e=>{if(a.push(e),t(e)){c.push(e);const t=e.row,o=t+e.rowspan-1,a=e.column,i=a+e.colspan-1;ts&&(s=o),al&&(l=i)}})),((e,t,o,n,r,s)=>({minRow:e,minCol:t,maxRow:o,maxCol:n,allCells:r,selectedCells:s}))(n,r,s,l,a,c)})(l,o),c="th:not("+t+"),td:not("+t+")",i=Vt(n,"th,td",(e=>Ce(e,c)));N(i,qe),((e,t,o,n)=>{const r=_(e,(e=>"colgroup"!==e.section)),s=t.grid.columns,l=t.grid.rows;for(let e=0;eo.maxRow||ao.maxCol||(tn(t,e,a).filter(n).isNone()?fr(r,l,e):l=!0)}})(r,l,a,o);const m=((e,t,o,n)=>{if(0===n.minCol&&t.grid.columns===n.maxCol+1)return 0;const r=sr(t,e,o),s=A(r,((e,t)=>e+t),0),l=A(r.slice(n.minCol,n.maxCol+1),((e,t)=>e+t),0),a=l/s*o.pixelWidth()-o.pixelWidth();return o.getCellDelta(a)})(e,Zo(e),s,a);return((e,t,o,n)=>{G(o.columns,(e=>{(e.columnt.maxCol)&&qe(e.element)}));const r=_($t(e,"tr"),(e=>0===e.dom.childElementCount));N(r,qe),t.minCol!==t.maxCol&&t.minRow!==t.maxRow||N($t(e,"th,td"),(e=>{be(e,"rowspan"),be(e,"colspan")})),be(e,Go),be(e,"data-snooker-col-series"),mr(e).adjustTableWidth(n)})(n,a,l,m),n})(e,zs);return ts(t),[t]})))(o).each((o=>{t.content="text"===t.format?(e=>E(e,(e=>e.dom.innerText)).join(""))(o):((e,t)=>E(t,(t=>e.selection.serializer.serialize(t.dom,{}))).join(""))(e,o)}))};if(!0===t.selection){const t=(e=>_(Ps(e),(e=>Ce(e,As.selectedSelector))))(e);t.length>=1&&o(t)}})),e.on("BeforeSetContent",(o=>{if(!0===o.selection&&!0===o.paste){const n=Ps(e);H(n).each((n=>{Kt(n).each((r=>{const s=_(((e,t)=>{const o=document.createElement("div");return o.innerHTML=e,Le(xe.fromDom(o))})(o.content),(e=>"meta"!==ne(e))),l=ue("table");if(Gr(e)&&1===s.length&&l(s[0])){o.preventDefault();const l=xe.fromDom(e.getDoc()),a=_r(l),c=((e,t,o)=>({element:e,clipboard:t,generators:o}))(n,s[0],a);t.pasteCells(r,c).each((()=>{e.focus()}))}}))}))}}))},Fs=(e,t)=>({element:e,offset:t}),Hs=(e,t,o)=>e.property().isText(t)&&0===e.property().getText(t).trim().length||e.property().isComment(t)?o(t).bind((t=>Hs(e,t,o).orThunk((()=>C.some(t))))):C.none(),$s=(e,t)=>e.property().isText(t)?e.property().getText(t).length:e.property().children(t).length,Vs=(e,t)=>{const o=Hs(e,t,e.query().prevSibling).getOr(t);if(e.property().isText(o))return Fs(o,$s(e,o));const n=e.property().children(o);return n.length>0?Vs(e,n[n.length-1]):Fs(o,$s(e,o))},qs=Vs,Us=hs(),Gs=(e,t)=>{if(!jt(e)){const o=(e=>Un(e).bind((e=>{return t=e,o=["fixed","relative","empty"],C.from(Wn.exec(t)).bind((e=>{const t=Number(e[1]),n=e[2];return((e,t)=>O(t,(t=>O(Ln[t],(t=>e===t)))))(n,o)?C.some({value:t,unit:n}):C.none()}));var t,o})))(e);o.each((o=>{const n=o.value/2;Jn(e,n,o.unit),Jn(t,n,o.unit)}))}},Ks=e=>E(e,g(0)),Ys=(e,t,o,n,r)=>r(e.slice(0,t)).concat(n).concat(r(e.slice(o))),Js=e=>(t,o,n,r)=>{if(e(n)){const e=Math.max(r,t[o]-Math.abs(n)),s=Math.abs(e-t[o]);return n>=0?s:-s}return n},Qs=Js((e=>e<0)),Xs=Js(x),Zs=()=>{const e=(e,t,o,n)=>{const r=(100+o)/100,s=Math.max(n,(e[t]+o)/r);return E(e,((e,o)=>(o===t?s:e/r)-e))},t=(t,o,n,r,s,l)=>l?e(t,o,r,s):((e,t,o,n,r)=>{const s=Qs(e,t,n,r);return Ys(e,t,o+1,[s,0],Ks)})(t,o,n,r,s);return{resizeTable:(e,t)=>e(t),clampTableDelta:Qs,calcLeftEdgeDeltas:t,calcMiddleDeltas:(e,o,n,r,s,l,a)=>t(e,n,r,s,l,a),calcRightEdgeDeltas:(t,o,n,r,s,l)=>{if(l)return e(t,n,r,s);{const e=Qs(t,n,r,s);return Ks(t.slice(0,n)).concat([e])}},calcRedestributedWidths:(e,t,o,n)=>{if(n){const n=(t+o)/t,r=E(e,(e=>e/n));return{delta:100*n-100,newSizes:r}}return{delta:o,newSizes:e}}}},el=()=>{const e=(e,t,o,n,r)=>{const s=Xs(e,n>=0?o:t,n,r);return Ys(e,t,o+1,[s,-s],Ks)};return{resizeTable:(e,t,o)=>{o&&e(t)},clampTableDelta:(e,t,o,n,r)=>{if(r){if(o>=0)return o;{const t=A(e,((e,t)=>e+t-n),0);return Math.max(-t,o)}}return Qs(e,t,o,n)},calcLeftEdgeDeltas:e,calcMiddleDeltas:(t,o,n,r,s,l)=>e(t,n,r,s,l),calcRightEdgeDeltas:(e,t,o,n,r,s)=>{if(s)return Ks(e);{const t=n/e.length;return E(e,g(t))}},calcRedestributedWidths:(e,t,o,n)=>({delta:0,newSizes:e})}},tl=e=>Zo(e).grid,ol=ue("th"),nl=e=>P(e,(e=>ol(e.element))),rl=(e,t)=>e&&t?"sectionCells":e?"section":"cells",sl=e=>{const t="thead"===e.section,o=vt(ll(e.cells),"th");return"tfoot"===e.section?{type:"footer"}:t||o?{type:"header",subType:rl(t,o)}:{type:"body"}},ll=e=>{const t=_(e,(e=>ol(e.element)));return 0===t.length?C.some("td"):t.length===e.length?C.some("th"):C.none()},al=(e,t,o)=>et(o(e.element,t),!0,e.isLocked),cl=(e,t)=>e.section!==t?tt(e.element,e.cells,t,e.isNew):e,il=()=>({transformRow:cl,transformCell:(e,t,o)=>{const n=o(e.element,t),r="td"!==ne(n)?((e,t)=>{const o=Je(e,"td");je(e,o);const n=Le(e);return $e(o,n),qe(e),o})(n):n;return et(r,e.isNew,e.isLocked)}}),ml=()=>({transformRow:cl,transformCell:al}),dl=()=>({transformRow:(e,t)=>cl(e,"thead"===t?"tbody":t),transformCell:al}),ul=il,fl=ml,gl=dl,hl=()=>({transformRow:h,transformCell:al}),pl=(e,t,o,n)=>{o===n?be(e,t):ge(e,t,o)},wl=(e,t,o)=>{$(mt(e,t)).fold((()=>Pe(e,o)),(e=>je(e,o)))},bl=(e,t)=>{const o=[],n=[],r=e=>E(e,(e=>{e.isNew&&o.push(e.element);const t=e.element;return Ve(t),N(e.cells,(e=>{e.isNew&&n.push(e.element),pl(e.element,"colspan",e.colspan,1),pl(e.element,"rowspan",e.rowspan,1),Ie(t,e.element)})),t})),s=e=>j(e,(e=>E(e.cells,(e=>(pl(e.element,"span",e.colspan,1),e.element))))),l=(t,o)=>{const n=((e,t)=>{const o=pt(e,t).getOrThunk((()=>{const o=xe.fromTag(t,ke(e).dom);return"thead"===t?wl(e,"caption,colgroup",o):"colgroup"===t?wl(e,"caption",o):Ie(e,o),o}));return Ve(o),o})(e,o),l=("colgroup"===o?s:r)(t);$e(n,l)},a=(t,o)=>{t.length>0?l(t,o):(t=>{pt(e,t).each(qe)})(o)},c=[],i=[],m=[],d=[];return N(t,(e=>{switch(e.section){case"thead":c.push(e);break;case"tbody":i.push(e);break;case"tfoot":m.push(e);break;case"colgroup":d.push(e)}})),a(d,"colgroup"),a(c,"thead"),a(i,"tbody"),a(m,"tfoot"),{newRows:o,newCells:n}},vl=(e,t)=>{if(0===e.length)return 0;const o=e[0];return W(e,(e=>!t(o.element,e.element))).getOr(e.length)},yl=(e,t)=>{const o=E(e,(e=>E(e.cells,y)));return E(e,((n,r)=>{const s=j(n.cells,((n,s)=>{if(!1===o[r][s]){const m=((e,t,o,n)=>{const r=((e,t)=>e[t])(e,t),s="colgroup"===r.section,l=vl(r.cells.slice(o),n),a=s?1:vl(((e,t)=>E(e,(e=>Ho(e,t))))(e.slice(t),o),n);return{colspan:l,rowspan:a}})(e,r,s,t);return((e,t,n,r)=>{for(let s=e;s({element:e,cells:t,section:o,isNew:n}))(n.element,s,n.section,n.isNew)}))},xl=(e,t,o)=>{const n=[];N(e.colgroups,(r=>{const s=[];for(let n=0;net(e.element,o,!1))).getOrThunk((()=>et(t.colGap(),!0,!1)));s.push(r)}n.push(tt(r.element,s,"colgroup",o))}));for(let r=0;ret(e.element,o,e.isLocked))).getOrThunk((()=>et(t.gap(),!0,!1)));s.push(l)}const l=e.all[r],a=tt(l.element,s,l.section,o);n.push(a)}return n},Cl=e=>yl(e,Re),Sl=(e,t)=>V(e.all,(e=>L(e.cells,(e=>Re(t,e.element))))),Tl=(e,t,o)=>{const n=E(t.selection,(t=>qt(t).bind((t=>Sl(e,t))).filter(o))),r=yt(n);return xt(r.length>0,r)},Rl=(e,t,o,n,r)=>(s,l,a,c)=>{const i=Zo(s),m=C.from(null==c?void 0:c.section).getOrThunk(hl);return t(i,l).map((t=>{const o=((e,t)=>xl(e,t,!1))(i,a),n=e(o,t,Re,r(a),m),s=Yo(n.grid);return{info:t,grid:Cl(n.grid),cursor:n.cursor,lockedColumns:s}})).bind((e=>{const t=bl(s,e.grid),r=C.from(null==c?void 0:c.sizing).getOrThunk((()=>mr(s))),l=C.from(null==c?void 0:c.resize).getOrThunk(el);return o(s,e.grid,e.info,{sizing:r,resize:l,section:m}),n(s),be(s,Go),e.lockedColumns.length>0&&ge(s,Go,e.lockedColumns.join(",")),C.some({cursor:e.cursor,newRows:t.newRows,newCells:t.newCells})}))},Dl=(e,t)=>Tl(e,t,x).map((e=>({cells:e,generators:t.generators,clipboard:t.clipboard}))),Ol=(e,t)=>Tl(e,t,x),kl=(e,t)=>Tl(e,t,(e=>!e.isLocked)),El=(e,t)=>P(t,(t=>((e,t)=>Sl(e,t).exists((e=>!e.isLocked)))(e,t))),Nl=(e,t,o,n)=>{const r=qo(e).rows;let s=!0;for(let e=0;e{const t=t=>t(e),o=g(e),n=()=>r,r={tag:!0,inner:e,fold:(t,o)=>o(e),isValue:x,isError:y,map:t=>zl.value(t(e)),mapError:n,bind:t,exists:t,forall:t,getOr:o,or:n,getOrThunk:o,orThunk:n,getOrDie:o,each:t=>{t(e)},toOptional:()=>C.some(e)};return r},_l=e=>{const t=()=>o,o={tag:!1,inner:e,fold:(t,o)=>t(e),isValue:y,isError:x,map:t,mapError:t=>zl.error(t(e)),bind:t,exists:y,forall:x,getOr:h,or:h,getOrThunk:v,orThunk:v,getOrDie:(n=String(e),()=>{throw new Error(n)}),each:f,toOptional:C.none};var n;return o},zl={value:Bl,error:_l,fromOption:(e,t)=>e.fold((()=>_l(t)),Bl)},Al=(e,t)=>({rowDelta:0,colDelta:Vo(e[0])-Vo(t[0])}),Ll=(e,t)=>({rowDelta:e.length-t.length,colDelta:0}),Wl=(e,t,o,n)=>{const r="colgroup"===t.section?o.col:o.cell;return k(e,(e=>et(r(),!0,n(e))))},Ml=(e,t,o,n)=>{const r=e[e.length-1];return e.concat(k(t,(()=>{const e="colgroup"===r.section?o.colgroup:o.row,t=Uo(r,e,h),s=Wl(t.cells.length,t,o,(e=>X(n,e.toString())));return Fo(t,s)})))},jl=(e,t,o,n)=>E(e,(e=>{const r=Wl(t,e,o,y);return jo(e,n,r)})),Pl=(e,t,o)=>{const n=t.colDelta<0?jl:h,r=t.rowDelta<0?Ml:h,s=Yo(e),l=Vo(e[0]),a=O(s,(e=>e===l-1)),c=n(e,Math.abs(t.colDelta),o,a?l-1:l),i=Yo(c);return r(c,Math.abs(t.rowDelta),o,I(i,x))},Il=(e,t,o,n)=>{const r=w(n,Ho(e[t],o).element),s=e[t];return e.length>1&&Vo(s)>1&&(o>0&&r($o(s,o-1))||o0&&r($o(e[t-1],o))||t_(o,(o=>o>=e.column&&o<=Vo(t[0])+e.column)),Hl=(e,t,o,n,r)=>{((e,t,o,n)=>{t>0&&t{const r=e.cells[t-1];let s=0;const l=n();for(;e.cells.length>t+s&&o(r.element,e.cells[t+s].element);)Io(e,t+s,et(l,!0,e.cells[t+s].isLocked)),s++}))})(t,e,r,n.cell);const s=Ll(o,t),l=Pl(o,s,n),a=Ll(t,l),c=Pl(t,a,n);return E(c,((t,o)=>jo(t,e,l[o].cells)))},$l=(e,t,o,n,r)=>{((e,t,o,n)=>{const r=qo(e).rows;if(t>0&&tA(e,((e,o)=>O(e,(e=>t(e.element,o.element)))?e:e.concat([o])),[]))(r[t-1].cells,o);N(e,(e=>{let s=C.none();for(let l=t;l{Io(a,t,et(e,!0,c.isLocked))})))}}))}})(t,e,r,n.cell);const s=Yo(t),l=Al(t,o),a={...l,colDelta:l.colDelta-s.length},c=Pl(t,a,n),{cols:i,rows:m}=qo(c),d=Yo(c),u=Al(o,t),f={...u,colDelta:u.colDelta+d.length},g=(p=n,w=d,E(o,(e=>A(w,((t,o)=>{const n=Wl(1,e,p,x)[0];return Po(t,o,n)}),e)))),h=Pl(g,f,n);var p,w;return[...i,...m.slice(0,e),...h,...m.slice(e,m.length)]},Vl=(e,t,o,n,r)=>{const{rows:s,cols:l}=qo(e),a=s.slice(0,t),c=s.slice(t);return[...l,...a,((e,t,o,n)=>Uo(e,(e=>n(e,o)),t))(s[o],((e,o)=>t>0&&tE(e,(e=>{const s=t>0&&t{if("colgroup"!==o&&n)return Ho(e,t);{const t=Ho(e,r);return et(l(t.element,s),!0,!1)}})(e,t,e.section,s,o,n,r);return Po(e,t,l)})),Ul=(e,t,o,n)=>((e,t,o,n)=>void 0!==$o(e[t],o)&&t>0&&n($o(e[t-1],o),$o(e[t],o)))(e,t,o,n)||((e,t,o)=>t>0&&o($o(e,t-1),$o(e,t)))(e[t],o,n),Gl=(e,t,o,n)=>{const r=e=>(e=>"row"===e?Pt(t):jt(t))(e)?`${e}group`:e;return e?ol(t)?r(o):null:n&&ol(t)?r("row"===o?"col":"row"):null},Kl=(e,t,o)=>et(o(e.element,t),!0,e.isLocked),Yl=(e,t,o,n,r,s,l)=>E(e,((e,a)=>((e,c)=>{const i=e.cells,m=E(i,((e,c)=>{if((e=>O(t,(t=>o(e.element,t.element))))(e)){const t=l(e,a,c)?r(e,o,n):e;return s(t,a,c).each((e=>{var o,n;o=t.element,n={scope:C.from(e)},G(n,((e,t)=>{e.fold((()=>{be(o,t)}),(e=>{fe(o.dom,t,e)}))}))})),t}return e}));return tt(e.element,m,e.section,e.isNew)})(e))),Jl=(e,t,o)=>j(e,((n,r)=>Ul(e,r,t,o)?[]:[Ho(n,t)])),Ql=(e,t,o,n,r)=>{const s=qo(e).rows,l=j(t,(e=>Jl(s,e,n))),a=E(s,(e=>nl(e.cells))),c=((e,t)=>P(t,h)&&nl(e)?x:(e,o,n)=>!("th"===ne(e.element)&&t[o]))(l,a),i=((e,t)=>(o,n)=>C.some(Gl(e,o.element,"row",t[n])))(o,a);return Yl(e,l,n,r,Kl,i,c)},Xl=(e,t,o,n)=>{const r=qo(e).rows,s=E(t,(e=>Ho(r[e.row],e.column)));return Yl(e,s,o,n,Kl,C.none,x)},Zl=e=>{if(!l(e))throw new Error("cases must be an array");if(0===e.length)throw new Error("there must be at least one case");const t=[],o={};return N(e,((n,r)=>{const s=q(n);if(1!==s.length)throw new Error("one and only one name per case");const a=s[0],c=n[a];if(void 0!==o[a])throw new Error("duplicate key detected:"+a);if("cata"===a)throw new Error("cannot have a case named cata (sorry)");if(!l(c))throw new Error("case arguments must be an array");t.push(a),o[a]=(...o)=>{const n=o.length;if(n!==c.length)throw new Error("Wrong number of arguments to case "+a+". Expected "+c.length+" ("+c+"), got "+n);return{fold:(...t)=>{if(t.length!==e.length)throw new Error("Wrong number of arguments to fold. Expected "+e.length+", got "+t.length);return t[r].apply(null,o)},match:e=>{const n=q(e);if(t.length!==n.length)throw new Error("Wrong number of arguments to match. Expected: "+t.join(",")+"\nActual: "+n.join(","));if(!P(t,(e=>D(n,e))))throw new Error("Not all branches were specified when using match. Specified: "+n.join(", ")+"\nRequired: "+t.join(", "));return e[a].apply(null,o)},log:e=>{console.log(e,{constructors:t,constructor:a,params:o})}}}})),o},ea={...Zl([{none:[]},{only:["index"]},{left:["index","next"]},{middle:["prev","index","next"]},{right:["prev","index"]}])},ta=(e,t,o)=>{let n=0;for(let r=e;r{const o=rn(e);return E(o,(e=>{const o=ta(e.row,e.row+e.rowspan,t);return{element:e.element,height:o,rowspan:e.rowspan}}))},na=(e,t,o)=>{const n=((e,t)=>ln(e)?((e,t)=>{const o=sn(e);return E(o,((e,o)=>({element:e.element,width:t[o],colspan:e.colspan})))})(e,t):((e,t)=>{const o=rn(e);return E(o,(e=>{const o=ta(e.column,e.column+e.colspan,t);return{element:e.element,width:o,colspan:e.colspan}}))})(e,t))(e,t);N(n,(e=>{o.setElementWidth(e.element,e.width)}))},ra=(e,t,o,n,r)=>{const s=Zo(e),l=r.getCellDelta(t),a=r.getWidths(s,r),c=o===s.grid.columns-1,i=n.clampTableDelta(a,o,l,r.minCellWidth(),c),m=((e,t,o,n,r)=>{const s=e.slice(0),l=((e,t)=>0===e.length?ea.none():1===e.length?ea.only(0):0===t?ea.left(0,1):t===e.length-1?ea.right(t-1,t):t>0&&tn.singleColumnWidth(s[e],o)),((e,t)=>r.calcLeftEdgeDeltas(s,e,t,o,n.minCellWidth(),n.isRelative)),((e,t,l)=>r.calcMiddleDeltas(s,e,t,l,o,n.minCellWidth(),n.isRelative)),((e,t)=>r.calcRightEdgeDeltas(s,e,t,o,n.minCellWidth(),n.isRelative)))})(a,o,i,r,n),d=E(m,((e,t)=>e+a[t]));na(s,d,r),n.resizeTable(r.adjustTableWidth,i,c)},sa=e=>A(e,((e,t)=>O(e,(e=>e.column===t.column))?e:e.concat([t])),[]).sort(((e,t)=>e.column-t.column)),la=ue("col"),aa=ue("colgroup"),ca=e=>"tr"===ne(e)||aa(e),ia=e=>({element:e,colspan:Wt(e,"colspan",1),rowspan:Wt(e,"rowspan",1)}),ma=e=>we(e,"scope").map((e=>e.substr(0,3))),da=(e,t=ia)=>{const o=o=>{if(ca(o))return aa((r={element:o}).element)?e.colgroup(r):e.row(r);{const r=o,s=(t=>la(t.element)?e.col(t):e.cell(t))(t(r));return n=C.some({item:r,replacement:s}),s}var r};let n=C.none();return{getOrInit:(e,t)=>n.fold((()=>o(e)),(n=>t(e,n.item)?n.replacement:o(e)))}},ua=e=>t=>{const o=[],n=n=>{const r="td"===e?{scope:null}:{},s=t.replace(n,e,r);return o.push({item:n,sub:s}),s};return{replaceOrInit:(e,t)=>{if(ca(e)||la(e))return e;{const r=e;return((e,t)=>L(o,(o=>t(o.item,e))))(r,t).fold((()=>n(r)),(o=>t(e,o.item)?o.sub:n(r)))}}}},fa=e=>({unmerge:t=>{const o=ma(t);return o.each((e=>ge(t,"scope",e))),()=>{const n=e.cell({element:t,colspan:1,rowspan:1});return Lt(n,"width"),Lt(t,"width"),o.each((e=>ge(n,"scope",e))),n}},merge:e=>(Lt(e[0],"width"),(()=>{const t=yt(E(e,ma));if(0===t.length)return C.none();{const e=t[0],o=["row","col"];return O(t,(t=>t!==e&&D(o,t)))?C.none():C.from(e)}})().fold((()=>be(e[0],"scope")),(t=>ge(e[0],"scope",t+"group"))),g(e[0]))}),ga=["body","p","div","article","aside","figcaption","figure","footer","header","nav","section","ol","ul","table","thead","tfoot","tbody","caption","tr","td","th","h1","h2","h3","h4","h5","h6","blockquote","pre","address"],ha=hs(),pa=e=>((e,t)=>{const o=e.property().name(t);return D(ga,o)})(ha,e),wa=e=>((e,t)=>{const o=e.property().name(t);return D(["ol","ul"],o)})(ha,e),ba=e=>{const t=ue("br"),o=e=>Cr(e).bind((o=>{const n=Ae(o).map((e=>!!pa(e)||!!((e,t)=>D(["br","img","hr","input"],e.property().name(t)))(ha,e)&&"img"!==ne(e))).getOr(!1);return Ne(o).map((r=>{return!0===n||("li"===ne(s=r)||ft(s,wa).isSome())||t(o)||pa(r)&&!Re(e,r)?[]:[xe.fromTag("br")];var s}))})).getOr([]),n=(()=>{const n=j(e,(e=>{const n=Le(e);return(e=>P(e,(e=>t(e)||ie(e)&&0===hr(e).trim().length)))(n)?[]:n.concat(o(e))}));return 0===n.length?[xe.fromTag("br")]:n})();Ve(e[0]),$e(e[0],n)},va=e=>Qr(e,!0),ya=e=>{0===Ut(e).length&&qe(e)},xa=(e,t)=>({grid:e,cursor:t}),Ca=(e,t,o)=>{const n=((e,t,o)=>{var n,r;const s=qo(e).rows;return C.from(null===(r=null===(n=s[t])||void 0===n?void 0:n.cells[o])||void 0===r?void 0:r.element).filter(va).orThunk((()=>(e=>V(e,(e=>V(e.cells,(e=>{const t=e.element;return xt(va(t),t)})))))(s)))})(e,t,o);return xa(e,n)},Sa=e=>A(e,((e,t)=>O(e,(e=>e.row===t.row))?e:e.concat([t])),[]).sort(((e,t)=>e.row-t.row)),Ta=(e,t)=>(o,n,r,s,l)=>{const a=Sa(n),c=E(a,(e=>e.row)),i=((e,t,o,n,r,s,l)=>{const{cols:a,rows:c}=qo(e),i=c[t[0]],m=j(t,(e=>((e,t,o)=>{const n=e[t];return j(n.cells,((n,r)=>Ul(e,t,r,o)?[]:[n]))})(c,e,r))),d=E(i.cells,((e,t)=>nl(Jl(c,t,r)))),u=[...c];N(t,(e=>{u[e]=l.transformRow(c[e],o)}));const f=[...a,...u],g=((e,t)=>P(t,h)&&nl(e.cells)?x:(e,o,n)=>!("th"===ne(e.element)&&t[n]))(i,d),p=((e,t)=>(o,n,r)=>C.some(Gl(e,o.element,"col",t[r])))(n,d);return Yl(f,m,r,s,l.transformCell,p,g)})(o,c,e,t,r,s.replaceOrInit,l);return Ca(i,n[0].row,n[0].column)},Ra=Ta("thead",!0),Da=Ta("tbody",!1),Oa=Ta("tfoot",!1),ka=(e,t,o)=>{const n=((e,t)=>Qt(e,(()=>t)))(e,o.section),r=en(n);return xl(r,t,!0)},Ea=(e,t,o,n)=>((e,t,o,n)=>{const r=en(t),s=n.getWidths(r,n);na(r,s,n)})(0,t,0,n.sizing),Na=(e,t,o,n)=>((e,t,o,n,r)=>{const s=en(t),l=n.getWidths(s,n),a=n.pixelWidth(),{newSizes:c,delta:i}=r.calcRedestributedWidths(l,a,o.pixelDelta,n.isRelative);na(s,c,n),n.adjustTableWidth(i)})(0,t,o,n.sizing,n.resize),Ba=(e,t)=>O(t,(e=>0===e.column&&e.isLocked)),_a=(e,t)=>O(t,(t=>t.column+t.colspan>=e.grid.columns&&t.isLocked)),za=(e,t)=>{const o=cn(e),n=sa(t);return A(n,((e,t)=>e+o[t.column].map(Wo).getOr(0)),0)},Aa=e=>(t,o)=>Ol(t,o).filter((o=>!(e?Ba:_a)(t,o))).map((e=>({details:e,pixelDelta:za(t,e)}))),La=e=>(t,o)=>Dl(t,o).filter((o=>!(e?Ba:_a)(t,o.cells))),Wa=ua("th"),Ma=ua("td"),ja=Rl(((e,t,o,n)=>{const r=t[0].row,s=Sa(t),l=z(s,((e,t)=>({grid:Vl(e.grid,r,t.row+e.delta,o,n.getOrInit),delta:e.delta+1})),{grid:e,delta:0}).grid;return Ca(l,r,t[0].column)}),Ol,f,f,da),Pa=Rl(((e,t,o,n)=>{const r=Sa(t),s=r[r.length-1],l=s.row+s.rowspan,a=z(r,((e,t)=>Vl(e,l,t.row,o,n.getOrInit)),e);return Ca(a,l,t[0].column)}),Ol,f,f,da),Ia=Rl(((e,t,o,n)=>{const r=t.details,s=sa(r),l=s[0].column,a=z(s,((e,t)=>({grid:ql(e.grid,l,t.column+e.delta,o,n.getOrInit),delta:e.delta+1})),{grid:e,delta:0}).grid;return Ca(a,r[0].row,l)}),Aa(!0),Na,f,da),Fa=Rl(((e,t,o,n)=>{const r=t.details,s=r[r.length-1],l=s.column+s.colspan,a=sa(r),c=z(a,((e,t)=>ql(e,l,t.column,o,n.getOrInit)),e);return Ca(c,r[0].row,l)}),Aa(!1),Na,f,da),Ha=Rl(((e,t,o,n)=>{const r=sa(t.details),s=((e,t)=>j(e,(e=>{const o=e.cells,n=z(t,((e,t)=>t>=0&&t0?[tt(e.element,n,e.section,e.isNew)]:[]})))(e,E(r,(e=>e.column))),l=s.length>0?s[0].cells.length-1:0;return Ca(s,r[0].row,Math.min(r[0].column,l))}),((e,t)=>kl(e,t).map((t=>({details:t,pixelDelta:-za(e,t)})))),Na,ya,da),$a=Rl(((e,t,o,n)=>{const r=Sa(t),s=((e,t,o)=>{const{rows:n,cols:r}=qo(e);return[...r,...n.slice(0,t),...n.slice(o+1)]})(e,r[0].row,r[r.length-1].row),l=s.length>0?s.length-1:0;return Ca(s,Math.min(t[0].row,l),t[0].column)}),Ol,f,ya,da),Va=Rl(((e,t,o,n)=>{const r=sa(t),s=E(r,(e=>e.column)),l=Ql(e,s,!0,o,n.replaceOrInit);return Ca(l,t[0].row,t[0].column)}),kl,f,f,Wa),qa=Rl(((e,t,o,n)=>{const r=sa(t),s=E(r,(e=>e.column)),l=Ql(e,s,!1,o,n.replaceOrInit);return Ca(l,t[0].row,t[0].column)}),kl,f,f,Ma),Ua=Rl(Ra,kl,f,f,Wa),Ga=Rl(Da,kl,f,f,Ma),Ka=Rl(Oa,kl,f,f,Ma),Ya=Rl(((e,t,o,n)=>{const r=Xl(e,t,o,n.replaceOrInit);return Ca(r,t[0].row,t[0].column)}),kl,f,f,Wa),Ja=Rl(((e,t,o,n)=>{const r=Xl(e,t,o,n.replaceOrInit);return Ca(r,t[0].row,t[0].column)}),kl,f,f,Ma),Qa=Rl(((e,t,o,n)=>{const r=t.cells;ba(r);const s=((e,t,o,n)=>{const r=qo(e).rows;if(0===r.length)return e;for(let e=t.startRow;e<=t.finishRow;e++)for(let o=t.startCol;o<=t.finishCol;o++){const t=r[e],s=Ho(t,o).isLocked;Io(t,o,et(n(),!1,s))}return e})(e,t.bounds,0,n.merge(r));return xa(s,C.from(r[0]))}),((e,t)=>((e,t)=>t.mergable)(0,t).filter((t=>El(e,t.cells)))),Ea,f,fa),Xa=Rl(((e,t,o,n)=>{const r=z(t,((e,t)=>Nl(e,t,o,n.unmerge(t))),e);return xa(r,C.from(t[0]))}),((e,t)=>((e,t)=>t.unmergable)(0,t).filter((t=>El(e,t)))),Ea,f,fa),Za=Rl(((e,t,o,n)=>{const r=((e,t)=>{const o=Zo(e);return xl(o,t,!0)})(t.clipboard,t.generators);var s,l;return((e,t,o,n,r)=>{const s=Yo(t),l=((e,t,o)=>{const n=Vo(t[0]),r=qo(t).cols.length+e.row,s=k(n-e.column,(t=>t+e.column));return{row:r,column:L(s,(e=>P(o,(t=>t!==e)))).getOr(n-1)}})(e,t,s),a=qo(o).rows,c=Fl(l,a,s),i=((e,t,o)=>{if(e.row>=t.length||e.column>Vo(t[0]))return zl.error("invalid start address out of table bounds, row: "+e.row+", column: "+e.column);const n=t.slice(e.row),r=n[0].cells.slice(e.column),s=Vo(o[0]),l=o.length;return zl.value({rowDelta:n.length-l,colDelta:r.length-s})})(l,t,a);return i.map((e=>{const o={...e,colDelta:e.colDelta-c.length},s=Pl(t,o,n),i=Yo(s),m=Fl(l,a,i);return((e,t,o,n,r,s)=>{const l=e.row,a=e.column,c=l+o.length,i=a+Vo(o[0])+s.length,m=I(s,x);for(let e=l;exa(e,C.some(t.element))),(e=>Ca(e,t.row,t.column)))}),((e,t)=>qt(t.element).bind((o=>Sl(e,o).map((e=>({...e,generators:t.generators,clipboard:t.clipboard})))))),Ea,f,da),ec=Rl(((e,t,o,n)=>{const r=qo(e).rows,s=t.cells[0].column,l=r[t.cells[0].row],a=ka(t.clipboard,t.generators,l),c=Hl(s,e,a,t.generators,o);return Ca(c,t.cells[0].row,t.cells[0].column)}),La(!0),f,f,da),tc=Rl(((e,t,o,n)=>{const r=qo(e).rows,s=t.cells[t.cells.length-1].column+t.cells[t.cells.length-1].colspan,l=r[t.cells[0].row],a=ka(t.clipboard,t.generators,l),c=Hl(s,e,a,t.generators,o);return Ca(c,t.cells[0].row,t.cells[0].column)}),La(!1),f,f,da),oc=Rl(((e,t,o,n)=>{const r=qo(e).rows,s=t.cells[0].row,l=r[s],a=ka(t.clipboard,t.generators,l),c=$l(s,e,a,t.generators,o);return Ca(c,t.cells[0].row,t.cells[0].column)}),Dl,f,f,da),nc=Rl(((e,t,o,n)=>{const r=qo(e).rows,s=t.cells[t.cells.length-1].row+t.cells[t.cells.length-1].rowspan,l=r[t.cells[0].row],a=ka(t.clipboard,t.generators,l),c=$l(s,e,a,t.generators,o);return Ca(c,t.cells[0].row,t.cells[0].column)}),Dl,f,f,da),rc=(e,t)=>{const o=Zo(e);return Ol(o,t).bind((e=>{const t=e[e.length-1],n=e[0].column,r=t.column+t.colspan,s=M(E(o.all,(e=>_(e.cells,(e=>e.column>=n&&e.column{const o=Zo(e);return Ol(o,t).bind(ll).getOr("")},lc=(e,t)=>{const o=Zo(e);return Ol(o,t).bind((e=>{const t=e[e.length-1],n=e[0].row,r=t.row+t.rowspan;return(e=>{const t=E(e,(e=>sl(e).type)),o=D(t,"header"),n=D(t,"footer");if(o||n){const e=D(t,"body");return!o||e||n?o||e||!n?C.none():C.some("footer"):C.some("header")}return C.some("body")})(o.all.slice(n,r))})).getOr("")},ac=(e,t)=>e.dispatch("NewRow",{node:t}),cc=(e,t)=>e.dispatch("NewCell",{node:t}),ic=(e,t,o)=>{e.dispatch("TableModified",{...o,table:t})},mc={structure:!1,style:!0},dc={structure:!0,style:!1},uc={structure:!0,style:!0},fc=(e,t)=>Hr(e)?ur(t):$r(e)?dr(t):mr(t),gc=(e,t,o)=>{const n=e=>"table"===ne(Zr(e)),r=Wr(e),s=Ir(e)?f:Gs,l=t=>{switch(Mr(e)){case"section":return ul();case"sectionCells":return fl();case"cells":return gl();default:return((e,t)=>{var o;switch((o=Zo(e),V(o.all,(e=>{const t=sl(e);return"header"===t.type?C.from(t.subType):C.none()}))).getOr(t)){case"section":return il();case"sectionCells":return ml();case"cells":return dl()}})(t,"section")}},a=(n,s,a,c)=>(i,m,d=!1)=>{ts(i);const u=xe.fromDom(e.getDoc()),f=Br(a,u,r),g={sizing:fc(e,i),resize:Ir(e)?Zs():el(),section:l(i)};return s(i)?n(i,m,f,g).bind((n=>{t.refresh(i.dom),N(n.newRows,(t=>{ac(e,t.dom)})),N(n.newCells,(t=>{cc(e,t.dom)}));const r=((t,n)=>n.cursor.fold((()=>{const n=Ut(t);return H(n).filter(lt).map((n=>{o.clearSelectedCells(t.dom);const r=e.dom.createRng();return r.selectNode(n.dom),e.selection.setRng(r),ge(n,"data-mce-selected","1"),r}))}),(n=>{const r=qs(Us,n),s=e.dom.createRng();return s.setStart(r.element.dom,r.offset),s.setEnd(r.element.dom,r.offset),e.selection.setRng(s),o.clearSelectedCells(t.dom),C.some(s)})))(i,n);return lt(i)&&(ts(i),d||ic(e,i.dom,c)),r.map((e=>({rng:e,effect:c})))})):C.none()},c=a($a,(t=>!n(e)||tl(t).rows>1),f,dc),i=a(Ha,(t=>!n(e)||tl(t).columns>1),f,dc);return{deleteRow:c,deleteColumn:i,insertRowsBefore:a(ja,x,f,dc),insertRowsAfter:a(Pa,x,f,dc),insertColumnsBefore:a(Ia,x,s,dc),insertColumnsAfter:a(Fa,x,s,dc),mergeCells:a(Qa,x,f,dc),unmergeCells:a(Xa,x,f,dc),pasteColsBefore:a(ec,x,f,dc),pasteColsAfter:a(tc,x,f,dc),pasteRowsBefore:a(oc,x,f,dc),pasteRowsAfter:a(nc,x,f,dc),pasteCells:a(Za,x,f,uc),makeCellsHeader:a(Ya,x,f,dc),unmakeCellsHeader:a(Ja,x,f,dc),makeColumnsHeader:a(Va,x,f,dc),unmakeColumnsHeader:a(qa,x,f,dc),makeRowsHeader:a(Ua,x,f,dc),makeRowsBody:a(Ga,x,f,dc),makeRowsFooter:a(Ka,x,f,dc),getTableRowType:lc,getTableCellType:sc,getTableColType:rc}},hc=(e,t,o)=>{const n=Wt(e,t,1);1===o||n<=1?be(e,t):ge(e,t,Math.min(o,n))},pc=(e,t)=>o=>{const n=o.column+o.colspan-1,r=o.column;return n>=e&&r{const n=o.substring(0,o.length-e.length),r=parseFloat(n);return n===r.toString()?t(r):wc.invalid(o)},vc={...wc,from:e=>Rt(e,"%")?bc("%",wc.percent,e):Rt(e,"px")?bc("px",wc.pixels,e):wc.invalid(e)},yc=(e,t,o)=>{const n=vc.from(o),r=P(e,(e=>"0px"===e))?((e,t)=>{const o=e.fold((()=>g("")),(e=>g(e/t+"px")),(()=>g(100/t+"%")));return k(t,o)})(n,e.length):((e,t,o)=>e.fold((()=>t),(e=>((e,t,o)=>{const n=o/t;return E(e,(e=>vc.from(e).fold((()=>e),(e=>e*n+"px"),(e=>e/100*o+"px"))))})(t,o,e)),(e=>((e,t)=>E(e,(e=>vc.from(e).fold((()=>e),(e=>e/t*100+"%"),(e=>e+"%")))))(t,o))))(n,e,t);return Sc(r)},xc=(e,t)=>0===e.length?t:z(e,((e,t)=>vc.from(t).fold(g(0),h,h)+e),0),Cc=(e,t)=>vc.from(e).fold(g(e),(e=>e+t+"px"),(e=>e+t+"%")),Sc=e=>{if(0===e.length)return e;const t=z(e,((e,t)=>{const o=vc.from(t).fold((()=>({value:t,remainder:0})),(e=>((e,t)=>{const o=Math.floor(e);return{value:o+"px",remainder:e-o}})(e)),(e=>({value:e+"%",remainder:0})));return{output:[o.value].concat(e.output),remainder:e.remainder+o.remainder}}),{output:[],remainder:0}),o=t.output;return o.slice(0,o.length-1).concat([Cc(o[o.length-1],Math.round(t.remainder))])},Tc=vc.from,Rc=e=>Tc(e).fold(g("px"),g("px"),g("%")),Dc=(e,t,o)=>{const n=Zo(e),r=n.all,s=rn(n),l=sn(n);t.each((t=>{const o=Rc(t),r=Lo(e),a=((e,t)=>nr(e,t,er,rr))(n,e),c=yc(a,r,t);ln(n)?((e,t,o)=>{N(t,((t,n)=>{const r=xc([e[n]],Ft());Nt(t.element,"width",r+o)}))})(c,l,o):((e,t,o)=>{N(t,(t=>{const n=e.slice(t.column,t.colspan+t.column),r=xc(n,Ft());Nt(t.element,"width",r+o)}))})(c,s,o),Nt(e,"width",t)})),o.each((t=>{const o=Rc(t),l=hn(e),a=((e,t,o)=>lr(e,t,o,tr,rr))(n,e,_n);((e,t,o,n)=>{N(o,(t=>{const o=e.slice(t.row,t.rowspan+t.row),r=xc(o,Ht());Nt(t.element,"height",r+n)})),N(t,((t,o)=>{Nt(t.element,"height",e[o])}))})(yc(a,l,t),r,s,o),Nt(e,"height",t)}))},Oc=e=>Un(e).exists((e=>Mn.test(e))),kc=e=>Un(e).exists((e=>jn.test(e))),Ec=e=>Un(e).isNone(),Nc=e=>{be(e,"width")},Bc=e=>{const t=Qn(e);Dc(e,C.some(t),C.none()),Nc(e)},_c=e=>{const t=(e=>Lo(e)+"px")(e);Dc(e,C.some(t),C.none()),Nc(e)},zc=e=>{Lt(e,"width");const t=Gt(e),o=t.length>0?t:Ut(e);N(o,(e=>{Lt(e,"width"),Nc(e)})),Nc(e)},Ac={styles:{"border-collapse":"collapse",width:"100%"},attributes:{border:"1"},colGroups:!1},Lc=(e,t,o,n)=>k(e,(e=>((e,t,o,n)=>{const r=xe.fromTag("tr");for(let s=0;s{e.selection.select(t.dom,!0),e.selection.collapse(!0)},Mc=(e,t,o,n,s)=>{const l=(e=>{const t=e.options,o=t.get("table_default_styles");return t.isSet("table_default_styles")?o:((e,t)=>Vr(e)||!Ur(e)?t:$r(e)?{...t,width:Lr(e)}:{...t,width:Ar})(e,o)})(e),a={styles:l,attributes:Kr(e),colGroups:Yr(e)};return e.undoManager.ignore((()=>{const r=((e,t,o,n,r,s=Ac)=>{const l=xe.fromTag("table"),a="cells"!==r;Bt(l,s.styles),he(l,s.attributes),s.colGroups&&Ie(l,(e=>{const t=xe.fromTag("colgroup");return k(e,(()=>Ie(t,xe.fromTag("col")))),t})(t));const c=Math.min(e,o);if(a&&o>0){const e=xe.fromTag("thead");Ie(l,e);const s=Lc(o,t,"sectionCells"===r?c:0,n);$e(e,s)}const i=xe.fromTag("tbody");Ie(l,i);const m=Lc(a?e-c:e,t,a?0:o,n);return $e(i,m),l})(o,t,s,n,Mr(e),a);ge(r,"data-mce-id","__mce");const l=(e=>{const t=xe.fromTag("div"),o=xe.fromDom(e.dom.cloneNode(!0));return Ie(t,o),(e=>e.dom.innerHTML)(t)})(r);e.insertContent(l),e.addVisual()})),wt(Zr(e),'table[data-mce-id="__mce"]').map((t=>($r(e)?_c(t):Vr(e)?zc(t):(Hr(e)||(e=>r(e)&&-1!==e.indexOf("%"))(l.width))&&Bc(t),ts(t),be(t,"data-mce-id"),((e,t)=>{N(dt(t,"tr"),(t=>{ac(e,t.dom),N(dt(t,"th,td"),(t=>{cc(e,t.dom)}))}))})(e,t),((e,t)=>{wt(t,"td,th").each(w(Wc,e))})(e,t),t.dom))).getOrNull()};var jc=tinymce.util.Tools.resolve("tinymce.FakeClipboard");const Pc="x-tinymce/dom-table-",Ic=Pc+"rows",Fc=Pc+"columns",Hc=e=>{const t=jc.FakeClipboardItem(e);jc.write([t])},$c=e=>{var t;const o=null!==(t=jc.read())&&void 0!==t?t:[];return V(o,(t=>C.from(t.getType(e))))},Vc=e=>{$c(e).isSome()&&jc.clear()},qc=e=>{e.fold(Gc,(e=>Hc({[Ic]:e})))},Uc=()=>$c(Ic),Gc=()=>Vc(Ic),Kc=e=>{e.fold(Jc,(e=>Hc({[Fc]:e})))},Yc=()=>$c(Fc),Jc=()=>Vc(Fc),Qc=e=>Ms(os(e),es(e)).filter(ss),Xc=(e,t)=>{const o=es(e),n=e=>Kt(e,o),l=t=>(e=>js(os(e),es(e)).filter(ss))(e).bind((e=>n(e).map((o=>t(o,e))))),a=t=>{e.focus()},c=(t,o=!1)=>l(((n,r)=>{const s=Ls(Ps(e),n,r);t(n,s,o).each(a)})),i=()=>l(((t,o)=>((e,t,o)=>{const n=Zo(e);return Ol(n,t).bind((e=>{const t=xl(n,o,!1),r=qo(t).rows.slice(e[0].row,e[e.length-1].row+e[e.length-1].rowspan),s=j(r,(e=>{const t=_(e.cells,(e=>!e.isLocked));return t.length>0?[{...e,cells:t}]:[]})),l=Cl(s);return xt(l.length>0,l)})).map((e=>E(e,(e=>{const t=Ke(e.element);return N(e.cells,(e=>{const o=Ye(e.element);pl(o,"colspan",e.colspan,1),pl(o,"rowspan",e.rowspan,1),Ie(t,o)})),t}))))})(t,Ls(Ps(e),t,o),Br(f,xe.fromDom(e.getDoc()),C.none())))),m=()=>l(((t,o)=>((e,t)=>{const o=Zo(e);return kl(o,t).map((e=>{const t=e[e.length-1],n=e[0].column,r=t.column+t.colspan,s=((e,t,o)=>{if(ln(e)){const n=_(sn(e),pc(t,o)),r=E(n,(e=>{const n=Ye(e.element);return hc(n,"span",o-t),n})),s=xe.fromTag("colgroup");return $e(s,r),[s]}return[]})(o,n,r),l=((e,t,o)=>E(e.all,(e=>{const n=_(e.cells,pc(t,o)),r=E(n,(e=>{const n=Ye(e.element);return hc(n,"colspan",o-t),n})),s=xe.fromTag("tr");return $e(s,r),s})))(o,n,r);return[...s,...l]}))})(t,Ls(Ps(e),t,o)))),d=(t,o)=>o().each((o=>{const n=E(o,(e=>Ye(e)));l(((o,r)=>{const s=_r(xe.fromDom(e.getDoc())),l=((e,t,o,n)=>({selection:Os(e),clipboard:o,generators:n}))(Ps(e),0,n,s);t(o,l).each(a)}))})),g=e=>(t,o)=>((e,t)=>X(e,t)?C.from(e[t]):C.none())(o,"type").each((t=>{c(e(t),o.no_events)}));G({mceTableSplitCells:()=>c(t.unmergeCells),mceTableMergeCells:()=>c(t.mergeCells),mceTableInsertRowBefore:()=>c(t.insertRowsBefore),mceTableInsertRowAfter:()=>c(t.insertRowsAfter),mceTableInsertColBefore:()=>c(t.insertColumnsBefore),mceTableInsertColAfter:()=>c(t.insertColumnsAfter),mceTableDeleteCol:()=>c(t.deleteColumn),mceTableDeleteRow:()=>c(t.deleteRow),mceTableCutCol:()=>m().each((e=>{Kc(e),c(t.deleteColumn)})),mceTableCutRow:()=>i().each((e=>{qc(e),c(t.deleteRow)})),mceTableCopyCol:()=>m().each((e=>Kc(e))),mceTableCopyRow:()=>i().each((e=>qc(e))),mceTablePasteColBefore:()=>d(t.pasteColsBefore,Yc),mceTablePasteColAfter:()=>d(t.pasteColsAfter,Yc),mceTablePasteRowBefore:()=>d(t.pasteRowsBefore,Uc),mceTablePasteRowAfter:()=>d(t.pasteRowsAfter,Uc),mceTableDelete:()=>Qc(e).each((t=>{Kt(t,o).filter(b(o)).each((t=>{const o=xe.fromText("");if(je(t,o),qe(t),e.dom.isEmpty(e.getBody()))e.setContent(""),e.selection.setCursorLocation();else{const t=e.dom.createRng();t.setStart(o.dom,0),t.setEnd(o.dom,0),e.selection.setRng(t),e.nodeChanged()}}))})),mceTableCellToggleClass:(t,o)=>{l((t=>{const n=Ps(e),r=P(n,(t=>e.formatter.match("tablecellclass",{value:o},t.dom))),s=r?e.formatter.remove:e.formatter.apply;N(n,(e=>s("tablecellclass",{value:o},e.dom))),ic(e,t.dom,mc)}))},mceTableToggleClass:(t,o)=>{l((t=>{e.formatter.toggle("tableclass",{value:o},t.dom),ic(e,t.dom,mc)}))},mceTableToggleCaption:()=>{Qc(e).each((t=>{Kt(t,o).each((o=>{pt(o,"caption").fold((()=>{const t=xe.fromTag("caption");Ie(t,xe.fromText("Caption")),((e,t,o)=>{We(e,0).fold((()=>{Ie(e,t)}),(e=>{Me(e,t)}))})(o,t),e.selection.setCursorLocation(t.dom,0)}),(n=>{ue("caption")(t)&&Te("td",o).each((t=>e.selection.setCursorLocation(t.dom,0))),qe(n)})),ic(e,o.dom,dc)}))}))},mceTableSizingMode:(t,n)=>(t=>Qc(e).each((n=>{Vr(e)||$r(e)||Hr(e)||Kt(n,o).each((o=>{"relative"!==t||Oc(o)?"fixed"!==t||kc(o)?"responsive"!==t||Ec(o)||zc(o):_c(o):Bc(o),ts(o),ic(e,o.dom,dc)}))})))(n),mceTableCellType:g((e=>"th"===e?t.makeCellsHeader:t.unmakeCellsHeader)),mceTableColType:g((e=>"th"===e?t.makeColumnsHeader:t.unmakeColumnsHeader)),mceTableRowType:g((e=>{switch(e){case"header":return t.makeRowsHeader;case"footer":return t.makeRowsFooter;default:return t.makeRowsBody}}))},((t,o)=>e.addCommand(o,t))),e.addCommand("mceInsertTable",((t,o)=>{((e,t,o,n={})=>{const r=e=>u(e)&&e>0;if(r(t)&&r(o)){const r=n.headerRows||0,s=n.headerColumns||0;return Mc(e,o,t,s,r)}console.error("Invalid values for mceInsertTable - rows and columns values are required to insert a table.")})(e,o.rows,o.columns,o.options)})),e.addCommand("mceTableApplyCellStyle",((t,o)=>{const l=e=>"tablecell"+e.toLowerCase().replace("-","");if(!s(o))return;const a=_(Ps(e),ss);if(0===a.length)return;const c=((e,t)=>{const o={};return((e,t,o,n)=>{G(e,((e,r)=>{(t(e,r)?o:n)(e,r)}))})(e,t,(e=>(t,o)=>{e[o]=t})(o),f),o})(o,((t,o)=>e.formatter.has(l(o))&&r(t)));(e=>{for(const t in e)if(U.call(e,t))return!1;return!0})(c)||(G(c,((t,o)=>{const n=l(o);N(a,(o=>{""===t?e.formatter.remove(n,{value:null},o.dom,!0):e.formatter.apply(n,{value:t},o.dom)}))})),n(a[0]).each((t=>ic(e,t.dom,mc))))}))},Zc=Zl([{before:["element"]},{on:["element","offset"]},{after:["element"]}]),ei={before:Zc.before,on:Zc.on,after:Zc.after,cata:(e,t,o,n)=>e.fold(t,o,n),getStart:e=>e.fold(h,h,h)},ti=(e,t)=>({selection:e,kill:t}),oi=(e,t)=>{const o=e.document.createRange();return o.selectNode(t.dom),o},ni=(e,t)=>{const o=e.document.createRange();return ri(o,t),o},ri=(e,t)=>e.selectNodeContents(t.dom),si=(e,t,o)=>{const n=e.document.createRange();var r;return r=n,t.fold((e=>{r.setStartBefore(e.dom)}),((e,t)=>{r.setStart(e.dom,t)}),(e=>{r.setStartAfter(e.dom)})),((e,t)=>{t.fold((t=>{e.setEndBefore(t.dom)}),((t,o)=>{e.setEnd(t.dom,o)}),(t=>{e.setEndAfter(t.dom)}))})(n,o),n},li=(e,t,o,n,r)=>{const s=e.document.createRange();return s.setStart(t.dom,o),s.setEnd(n.dom,r),s},ai=e=>({left:e.left,top:e.top,right:e.right,bottom:e.bottom,width:e.width,height:e.height}),ci=Zl([{ltr:["start","soffset","finish","foffset"]},{rtl:["start","soffset","finish","foffset"]}]),ii=(e,t,o)=>t(xe.fromDom(o.startContainer),o.startOffset,xe.fromDom(o.endContainer),o.endOffset),mi=(e,t)=>{const o=((e,t)=>t.match({domRange:e=>({ltr:g(e),rtl:C.none}),relative:(t,o)=>({ltr:eo((()=>si(e,t,o))),rtl:eo((()=>C.some(si(e,o,t))))}),exact:(t,o,n,r)=>({ltr:eo((()=>li(e,t,o,n,r))),rtl:eo((()=>C.some(li(e,n,r,t,o))))})}))(e,t);return((e,t)=>{const o=t.ltr();return o.collapsed?t.rtl().filter((e=>!1===e.collapsed)).map((e=>ci.rtl(xe.fromDom(e.endContainer),e.endOffset,xe.fromDom(e.startContainer),e.startOffset))).getOrThunk((()=>ii(0,ci.ltr,o))):ii(0,ci.ltr,o)})(0,o)},di=(e,t)=>mi(e,t).match({ltr:(t,o,n,r)=>{const s=e.document.createRange();return s.setStart(t.dom,o),s.setEnd(n.dom,r),s},rtl:(t,o,n,r)=>{const s=e.document.createRange();return s.setStart(n.dom,r),s.setEnd(t.dom,o),s}});ci.ltr,ci.rtl;const ui=(e,t,o,n)=>({start:e,soffset:t,finish:o,foffset:n}),fi=(e,t,o,n)=>({start:ei.on(e,t),finish:ei.on(o,n)}),gi=(e,t)=>{const o=di(e,t);return ui(xe.fromDom(o.startContainer),o.startOffset,xe.fromDom(o.endContainer),o.endOffset)},hi=fi,pi=(e,t,o,n,r)=>Re(o,n)?C.none():xs(o,n,t).bind((t=>{const n=t.boxes.getOr([]);return n.length>1?(r(e,n,t.start,t.finish),C.some(ti(C.some(hi(o,0,o,br(o))),!0))):C.none()})),wi=(e,t)=>({item:e,mode:t}),bi=(e,t,o,n=vi)=>e.property().parent(t).map((e=>wi(e,n))),vi=(e,t,o,n=yi)=>o.sibling(e,t).map((e=>wi(e,n))),yi=(e,t,o,n=yi)=>{const r=e.property().children(t);return o.first(r).map((e=>wi(e,n)))},xi=[{current:bi,next:vi,fallback:C.none()},{current:vi,next:yi,fallback:C.some(bi)},{current:yi,next:yi,fallback:C.some(vi)}],Ci=(e,t,o,n,r=xi)=>L(r,(e=>e.current===o)).bind((o=>o.current(e,t,n,o.next).orThunk((()=>o.fallback.bind((o=>Ci(e,t,o,n))))))),Si=(e,t,o,n,r,s)=>Ci(e,t,n,r).bind((t=>s(t.item)?C.none():o(t.item)?C.some(t.item):Si(e,t.item,o,t.mode,r,s))),Ti=e=>t=>0===e.property().children(t).length,Ri=(e,t,o,n)=>Si(e,t,o,vi,{sibling:(e,t)=>e.query().prevSibling(t),first:e=>e.length>0?C.some(e[e.length-1]):C.none()},n),Di=(e,t,o,n)=>Si(e,t,o,vi,{sibling:(e,t)=>e.query().nextSibling(t),first:e=>e.length>0?C.some(e[0]):C.none()},n),Oi=hs(),ki=(e,t)=>((e,t,o)=>Ri(e,t,Ti(e),o))(Oi,e,t),Ei=(e,t)=>((e,t,o)=>Di(e,t,Ti(e),o))(Oi,e,t),Ni=Zl([{none:["message"]},{success:[]},{failedUp:["cell"]},{failedDown:["cell"]}]),Bi=e=>bt(e,"tr"),_i={...Ni,verify:(e,t,o,n,r,s,l)=>bt(n,"td,th",l).bind((o=>bt(t,"td,th",l).map((t=>Re(o,t)?Re(n,o)&&br(o)===r?s(t):Ni.none("in same cell"):vs(Bi,[o,t]).fold((()=>((e,t,o)=>{const n=e.getRect(t),r=e.getRect(o);return r.right>n.left&&r.lefts(t))))))).getOr(Ni.none("default")),cata:(e,t,o,n,r)=>e.fold(t,o,n,r)},zi=ue("br"),Ai=(e,t,o)=>t(e,o).bind((e=>ie(e)&&0===hr(e).trim().length?Ai(e,t,o):C.some(e))),Li=(e,t,o,n)=>((e,t)=>We(e,t).filter(zi).orThunk((()=>We(e,t-1).filter(zi))))(t,o).bind((t=>n.traverse(t).fold((()=>Ai(t,n.gather,e).map(n.relative)),(e=>(e=>Ne(e).bind((t=>{const o=Le(t);return((e,t)=>W(e,w(Re,t)))(o,e).map((n=>((e,t,o,n)=>({parent:e,children:t,element:o,index:n}))(t,o,e,n)))})))(e).map((e=>ei.on(e.parent,e.index))))))),Wi=(e,t)=>({left:e.left,top:e.top+t,right:e.right,bottom:e.bottom+t}),Mi=(e,t)=>({left:e.left,top:e.top-t,right:e.right,bottom:e.bottom-t}),ji=(e,t,o)=>({left:e.left+t,top:e.top+o,right:e.right+t,bottom:e.bottom+o}),Pi=e=>({left:e.left,top:e.top,right:e.right,bottom:e.bottom}),Ii=(e,t)=>C.some(e.getRect(t)),Fi=(e,t,o)=>ce(t)?Ii(e,t).map(Pi):ie(t)?((e,t,o)=>o>=0&&o0?e.getRangedRect(t,o-1,t,o):C.none())(e,t,o).map(Pi):C.none(),Hi=(e,t)=>ce(t)?Ii(e,t).map(Pi):ie(t)?e.getRangedRect(t,0,t,br(t)).map(Pi):C.none(),$i=Zl([{none:[]},{retry:["caret"]}]),Vi=(e,t,o)=>gt(t,pa).fold(y,(t=>Hi(e,t).exists((e=>((e,t)=>e.leftt.right)(o,e))))),qi={point:e=>e.bottom,adjuster:(e,t,o,n,r)=>{const s=Wi(r,5);return Math.abs(o.bottom-n.bottom)<1||o.top>r.bottom?$i.retry(s):o.top===r.bottom?$i.retry(Wi(r,1)):Vi(e,t,r)?$i.retry(ji(s,5,0)):$i.none()},move:Wi,gather:Ei},Ui=(e,t,o,n,r)=>0===r?C.some(n):((e,t,o)=>e.elementFromPoint(t,o).filter((e=>"table"===ne(e))).isSome())(e,n.left,t.point(n))?((e,t,o,n,r)=>Ui(e,t,o,t.move(n,5),r))(e,t,o,n,r-1):e.situsFromPoint(n.left,t.point(n)).bind((s=>s.start.fold(C.none,(s=>Hi(e,s).bind((l=>t.adjuster(e,s,l,o,n).fold(C.none,(n=>Ui(e,t,o,n,r-1))))).orThunk((()=>C.some(n)))),C.none))),Gi=(e,t,o)=>{const n=e.move(o,5),r=Ui(t,e,o,n,100).getOr(n);return((e,t,o)=>e.point(t)>o.getInnerHeight()?C.some(e.point(t)-o.getInnerHeight()):e.point(t)<0?C.some(-e.point(t)):C.none())(e,r,t).fold((()=>t.situsFromPoint(r.left,e.point(r))),(o=>(t.scrollBy(0,o),t.situsFromPoint(r.left,e.point(r)-o))))},Ki={tryUp:w(Gi,{point:e=>e.top,adjuster:(e,t,o,n,r)=>{const s=Mi(r,5);return Math.abs(o.top-n.top)<1||o.bottome.getSelection().bind((n=>((e,t,o,n)=>{const r=zi(t)?((e,t,o)=>o.traverse(t).orThunk((()=>Ai(t,o.gather,e))).map(o.relative))(e,t,n):Li(e,t,o,n);return r.map((e=>({start:e,finish:e})))})(t,n.finish,n.foffset,o).fold((()=>C.some(Fs(n.finish,n.foffset))),(r=>{const s=e.fromSitus(r);return l=_i.verify(e,n.finish,n.foffset,s.finish,s.foffset,o.failure,t),_i.cata(l,(e=>C.none()),(()=>C.none()),(e=>C.some(Fs(e,0))),(e=>C.some(Fs(e,br(e)))));var l})))),Ji=(e,t,o,n,r,s)=>0===s?C.none():Zi(e,t,o,n,r).bind((l=>{const a=e.fromSitus(l),c=_i.verify(e,o,n,a.finish,a.foffset,r.failure,t);return _i.cata(c,(()=>C.none()),(()=>C.some(l)),(l=>Re(o,l)&&0===n?Qi(e,o,n,Mi,r):Ji(e,t,l,0,r,s-1)),(l=>Re(o,l)&&n===br(l)?Qi(e,o,n,Wi,r):Ji(e,t,l,br(l),r,s-1)))})),Qi=(e,t,o,n,r)=>Fi(e,t,o).bind((t=>Xi(e,r,n(t,Ki.getJumpSize())))),Xi=(e,t,o)=>{const n=Bo().browser;return n.isChromium()||n.isSafari()||n.isFirefox()?t.retry(e,o):C.none()},Zi=(e,t,o,n,r)=>Fi(e,o,n).bind((t=>Xi(e,r,t))),em=(e,t,o,n,r)=>bt(n,"td,th",t).bind((n=>bt(n,"table",t).bind((s=>((e,t)=>ft(e,(e=>Ne(e).exists((e=>Re(e,t)))),void 0).isSome())(r,s)?((e,t,o)=>Yi(e,t,o).bind((n=>Ji(e,t,n.element,n.offset,o,20).map(e.fromSitus))))(e,t,o).bind((e=>bt(e.finish,"td,th",t).map((t=>({start:n,finish:t,range:e}))))):C.none())))),tm=(e,t,o,n,r,s)=>s(n,t).orThunk((()=>em(e,t,o,n,r).map((e=>{const t=e.range;return ti(C.some(hi(t.start,t.soffset,t.finish,t.foffset)),!0)})))),om=(e,t)=>bt(e,"tr",t).bind((e=>bt(e,"table",t).bind((o=>{const n=dt(o,"tr");return Re(e,n[0])?((e,t,o)=>Ri(Oi,e,(e=>Cr(e).isSome()),o))(o,0,t).map((e=>{const t=br(e);return ti(C.some(hi(e,t,e,t)),!0)})):C.none()})))),nm=(e,t)=>bt(e,"tr",t).bind((e=>bt(e,"table",t).bind((o=>{const n=dt(o,"tr");return Re(e,n[n.length-1])?((e,t,o)=>Di(Oi,e,(e=>xr(e).isSome()),o))(o,0,t).map((e=>ti(C.some(hi(e,0,e,0)),!0))):C.none()})))),rm=(e,t,o,n,r,s,l)=>em(e,o,n,r,s).bind((e=>pi(t,o,e.start,e.finish,l))),sm=e=>{let t=e;return{get:()=>t,set:e=>{t=e}}},lm=()=>{const e=(e=>{const t=sm(C.none()),o=()=>t.get().each(e);return{clear:()=>{o(),t.set(C.none())},isSet:()=>t.get().isSome(),get:()=>t.get(),set:e=>{o(),t.set(C.some(e))}}})(f);return{...e,on:t=>e.get().each(t)}},am=(e,t)=>bt(e,"td,th",t),cm=e=>Be(e).exists(Qr),im={traverse:Ae,gather:Ei,relative:ei.before,retry:Ki.tryDown,failure:_i.failedDown},mm={traverse:ze,gather:ki,relative:ei.before,retry:Ki.tryUp,failure:_i.failedUp},dm=e=>t=>t===e,um=dm(38),fm=dm(40),gm=e=>e>=37&&e<=40,hm={isBackward:dm(37),isForward:dm(39)},pm={isBackward:dm(39),isForward:dm(37)},wm=Zl([{domRange:["rng"]},{relative:["startSitu","finishSitu"]},{exact:["start","soffset","finish","foffset"]}]),bm={domRange:wm.domRange,relative:wm.relative,exact:wm.exact,exactFromRange:e=>wm.exact(e.start,e.soffset,e.finish,e.foffset),getWin:e=>{const t=(e=>e.match({domRange:e=>xe.fromDom(e.startContainer),relative:(e,t)=>ei.getStart(e),exact:(e,t,o,n)=>e}))(e);return xe.fromDom(Ee(t).dom.defaultView)},range:ui},vm=document.caretPositionFromPoint?(e,t,o)=>{var n,r;return C.from(null===(r=(n=e.dom).caretPositionFromPoint)||void 0===r?void 0:r.call(n,t,o)).bind((t=>{if(null===t.offsetNode)return C.none();const o=e.dom.createRange();return o.setStart(t.offsetNode,t.offset),o.collapse(),C.some(o)}))}:document.caretRangeFromPoint?(e,t,o)=>{var n,r;return C.from(null===(r=(n=e.dom).caretRangeFromPoint)||void 0===r?void 0:r.call(n,t,o))}:C.none,ym=(e,t)=>{const o=ne(e);return"input"===o?ei.after(e):D(["br","img"],o)?0===t?ei.before(e):ei.after(e):ei.on(e,t)},xm=e=>C.from(e.getSelection()),Cm=(e,t)=>{xm(e).each((e=>{e.removeAllRanges(),e.addRange(t)}))},Sm=(e,t,o,n,r)=>{const s=li(e,t,o,n,r);Cm(e,s)},Tm=(e,t)=>mi(e,t).match({ltr:(t,o,n,r)=>{Sm(e,t,o,n,r)},rtl:(t,o,n,r)=>{xm(e).each((s=>{if(s.setBaseAndExtent)s.setBaseAndExtent(t.dom,o,n.dom,r);else if(s.extend)try{((e,t,o,n,r,s)=>{t.collapse(o.dom,n),t.extend(r.dom,s)})(0,s,t,o,n,r)}catch(s){Sm(e,n,r,t,o)}else Sm(e,n,r,t,o)}))}}),Rm=(e,t,o,n,r)=>{const s=((e,t,o,n)=>{const r=ym(e,t),s=ym(o,n);return bm.relative(r,s)})(t,o,n,r);Tm(e,s)},Dm=(e,t,o)=>{const n=((e,t)=>{const o=e.fold(ei.before,ym,ei.after),n=t.fold(ei.before,ym,ei.after);return bm.relative(o,n)})(t,o);Tm(e,n)},Om=e=>{if(e.rangeCount>0){const t=e.getRangeAt(0),o=e.getRangeAt(e.rangeCount-1);return C.some(ui(xe.fromDom(t.startContainer),t.startOffset,xe.fromDom(o.endContainer),o.endOffset))}return C.none()},km=e=>{if(null===e.anchorNode||null===e.focusNode)return Om(e);{const t=xe.fromDom(e.anchorNode),o=xe.fromDom(e.focusNode);return((e,t,o,n)=>{const r=((e,t,o,n)=>{const r=ke(e).dom.createRange();return r.setStart(e.dom,t),r.setEnd(o.dom,n),r})(e,t,o,n),s=Re(e,o)&&t===n;return r.collapsed&&!s})(t,e.anchorOffset,o,e.focusOffset)?C.some(ui(t,e.anchorOffset,o,e.focusOffset)):Om(e)}},Em=(e,t,o=!0)=>{const n=(o?ni:oi)(e,t);Cm(e,n)},Nm=e=>(e=>xm(e).filter((e=>e.rangeCount>0)).bind(km))(e).map((e=>bm.exact(e.start,e.soffset,e.finish,e.foffset))),Bm=e=>({elementFromPoint:(t,o)=>xe.fromPoint(xe.fromDom(e.document),t,o),getRect:e=>e.dom.getBoundingClientRect(),getRangedRect:(t,o,n,r)=>{const s=bm.exact(t,o,n,r);return((e,t)=>(e=>{const t=e.getClientRects(),o=t.length>0?t[0]:e.getBoundingClientRect();return o.width>0||o.height>0?C.some(o).map(ai):C.none()})(di(e,t)))(e,s)},getSelection:()=>Nm(e).map((t=>gi(e,t))),fromSitus:t=>{const o=bm.relative(t.start,t.finish);return gi(e,o)},situsFromPoint:(t,o)=>((e,t,o)=>((e,t,o)=>{const n=xe.fromDom(e.document);return vm(n,t,o).map((e=>ui(xe.fromDom(e.startContainer),e.startOffset,xe.fromDom(e.endContainer),e.endOffset)))})(e,t,o))(e,t,o).map((e=>fi(e.start,e.soffset,e.finish,e.foffset))),clearSelection:()=>{(e=>{xm(e).each((e=>e.removeAllRanges()))})(e)},collapseSelection:(t=!1)=>{Nm(e).each((o=>o.fold((e=>e.collapse(t)),((o,n)=>{const r=t?o:n;Dm(e,r,r)}),((o,n,r,s)=>{const l=t?o:r,a=t?n:s;Rm(e,l,a,l,a)}))))},setSelection:t=>{Rm(e,t.start,t.soffset,t.finish,t.foffset)},setRelativeSelection:(t,o)=>{Dm(e,t,o)},selectNode:t=>{Em(e,t,!1)},selectContents:t=>{Em(e,t)},getInnerHeight:()=>e.innerHeight,getScrollY:()=>(e=>{const t=void 0!==e?e.dom:document,o=t.body.scrollLeft||t.documentElement.scrollLeft,n=t.body.scrollTop||t.documentElement.scrollTop;return bn(o,n)})(xe.fromDom(e.document)).top,scrollBy:(t,o)=>{((e,t,o)=>{const n=(void 0!==o?o.dom:document).defaultView;n&&n.scrollBy(e,t)})(t,o,xe.fromDom(e.document))}}),_m=(e,t)=>({rows:e,cols:t}),zm=e=>gt(e,ae).exists(Qr),Am=(e,t)=>zm(e)||zm(t),Lm=e=>void 0!==e.dom.classList,Wm=(e,t)=>((e,t,o)=>{const n=((e,t)=>{const o=pe(e,t);return void 0===o||""===o?[]:o.split(" ")})(e,t).concat([o]);return ge(e,t,n.join(" ")),!0})(e,"class",t),Mm=(e,t)=>{Lm(e)?e.dom.classList.add(t):Wm(e,t)},jm=(e,t)=>Lm(e)&&e.dom.classList.contains(t),Pm=()=>({tag:"none"}),Im=e=>({tag:"multiple",elements:e}),Fm=e=>({tag:"single",element:e}),Hm=e=>{const t=xe.fromDom((e=>{if(nt()&&m(e.target)){const t=xe.fromDom(e.target);if(ce(t)&&m(t.dom.shadowRoot)&&e.composed&&e.composedPath){const t=e.composedPath();if(t)return H(t)}}return C.from(e.target)})(e).getOr(e.target)),o=()=>e.stopPropagation(),n=()=>e.preventDefault(),r=(s=n,l=o,(...e)=>s(l.apply(null,e)));var s,l;return((e,t,o,n,r,s,l)=>({target:e,x:t,y:o,stop:n,prevent:r,kill:s,raw:l}))(t,e.clientX,e.clientY,o,n,r,e)},$m=(e,t,o,n)=>{e.dom.removeEventListener(t,o,n)},Vm=x,qm=(e,t,o)=>((e,t,o,n)=>((e,t,o,n,r)=>{const s=((e,t)=>o=>{e(o)&&t(Hm(o))})(o,n);return e.dom.addEventListener(t,s,r),{unbind:w($m,e,t,s,r)}})(e,t,o,n,!1))(e,t,Vm,o),Um=Hm,Gm=e=>!jm(xe.fromDom(e.target),"ephox-snooker-resizer-bar"),Km=(e,t)=>{const o=(r=As.selectedSelector,{get:()=>Rs(xe.fromDom(e.getBody()),r).fold((()=>js(os(e),es(e)).fold(Pm,Fm)),Im)}),n=((e,t,o)=>{const n=t=>{be(t,e.selected),be(t,e.firstSelected),be(t,e.lastSelected)},r=t=>{ge(t,e.selected,"1")},s=e=>{l(e),o()},l=t=>{const o=dt(t,`${e.selectedSelector},${e.firstSelectedSelector},${e.lastSelectedSelector}`);N(o,n)};return{clearBeforeUpdate:l,clear:s,selectRange:(o,n,l,a)=>{s(o),N(n,r),ge(l,e.firstSelected,"1"),ge(a,e.lastSelected,"1"),t(n,l,a)},selectedSelector:e.selectedSelector,firstSelectedSelector:e.firstSelectedSelector,lastSelectedSelector:e.lastSelectedSelector}})(As,((t,o,n)=>{Kt(o).each((r=>{const s=Wr(e),l=Br(f,xe.fromDom(e.getDoc()),s),a=((e,t,o)=>{const n=Zo(e);return Ol(n,t).map((e=>{const t=xl(n,o,!1),{rows:r}=qo(t),s=((e,t)=>{const o=e.slice(0,t[t.length-1].row+1),n=Cl(o);return j(n,(e=>{const o=e.cells.slice(0,t[t.length-1].column+1);return E(o,(e=>e.element))}))})(r,e),l=((e,t)=>{const o=e.slice(t[0].row+t[0].rowspan-1,e.length),n=Cl(o);return j(n,(e=>{const o=e.cells.slice(t[0].column+t[0].colspan-1,e.cells.length);return E(o,(e=>e.element))}))})(r,e);return{upOrLeftCells:s,downOrRightCells:l}}))})(r,{selection:Ps(e)},l);((e,t,o,n,r)=>{e.dispatch("TableSelectionChange",{cells:t,start:o,finish:n,otherCells:r})})(e,t,o,n,a)}))}),(()=>(e=>{e.dispatch("TableSelectionClear")})(e)));var r;return e.on("init",(o=>{const r=e.getWin(),s=Zr(e),l=es(e),a=((e,t,o,n)=>{const r=((e,t,o,n)=>{const r=lm(),s=r.clear,l=s=>{r.on((r=>{n.clearBeforeUpdate(t),am(s.target,o).each((l=>{xs(r,l,o).each((o=>{const r=o.boxes.getOr([]);if(1===r.length){const o=r[0],l="false"===Xr(o),a=vt(Jr(s.target),o,Re);l&&a&&(n.selectRange(t,r,o,o),e.selectContents(o))}else r.length>1&&(n.selectRange(t,r,o.start,o.finish),e.selectContents(l))}))}))}))};return{clearstate:s,mousedown:e=>{n.clear(t),am(e.target,o).filter(cm).each(r.set)},mouseover:e=>{l(e)},mouseup:e=>{l(e),s()}}})(Bm(e),t,o,n);return{clearstate:r.clearstate,mousedown:r.mousedown,mouseover:r.mouseover,mouseup:r.mouseup}})(r,s,l,n),c=((e,t,o,n)=>{const r=Bm(e),s=()=>(n.clear(t),C.none());return{keydown:(e,l,a,c,i,m)=>{const d=e.raw,u=d.which,f=!0===d.shiftKey,g=Cs(t,n.selectedSelector).fold((()=>(gm(u)&&!f&&n.clearBeforeUpdate(t),gm(u)&&f&&!Am(l,c)?C.none:fm(u)&&f?w(rm,r,t,o,im,c,l,n.selectRange):um(u)&&f?w(rm,r,t,o,mm,c,l,n.selectRange):fm(u)?w(tm,r,o,im,c,l,nm):um(u)?w(tm,r,o,mm,c,l,om):C.none)),(e=>{const o=o=>()=>{const s=V(o,(o=>((e,t,o,n,r)=>Ts(n,e,t,r.firstSelectedSelector,r.lastSelectedSelector).map((e=>(r.clearBeforeUpdate(o),r.selectRange(o,e.boxes,e.start,e.finish),e.boxes))))(o.rows,o.cols,t,e,n)));return s.fold((()=>Ss(t,n.firstSelectedSelector,n.lastSelectedSelector).map((e=>{const o=fm(u)||m.isForward(u)?ei.after:ei.before;return r.setRelativeSelection(ei.on(e.first,0),o(e.table)),n.clear(t),ti(C.none(),!0)}))),(e=>C.some(ti(C.none(),!0))))};return gm(u)&&f&&!Am(l,c)?C.none:fm(u)&&f?o([_m(1,0)]):um(u)&&f?o([_m(-1,0)]):m.isBackward(u)&&f?o([_m(0,-1),_m(-1,0)]):m.isForward(u)&&f?o([_m(0,1),_m(1,0)]):gm(u)&&!f?s:C.none}));return g()},keyup:(e,r,s,l,a)=>Cs(t,n.selectedSelector).fold((()=>{const c=e.raw,i=c.which;return!0===c.shiftKey&&gm(i)&&Am(r,l)?((e,t,o,n,r,s,l)=>Re(o,r)&&n===s?C.none():bt(o,"td,th",t).bind((o=>bt(r,"td,th",t).bind((n=>pi(e,t,o,n,l))))))(t,o,r,s,l,a,n.selectRange):C.none()}),C.none)}})(r,s,l,n),i=((e,t,o,n)=>{const r=Bm(e);return(e,s)=>{n.clearBeforeUpdate(t),xs(e,s,o).each((e=>{const o=e.boxes.getOr([]);n.selectRange(t,o,e.start,e.finish),r.selectContents(s),r.collapseSelection()}))}})(r,s,l,n);e.on("TableSelectorChange",(e=>i(e.start,e.finish)));const m=(t,o)=>{(e=>!0===e.raw.shiftKey)(t)&&(o.kill&&t.kill(),o.selection.each((t=>{const o=bm.relative(t.start,t.finish),n=di(r,o);e.selection.setRng(n)})))},d=e=>0===e.button,u=(()=>{const e=sm(xe.fromDom(s)),t=sm(0);return{touchEnd:o=>{const n=xe.fromDom(o.target);if(ue("td")(n)||ue("th")(n)){const r=e.get(),s=t.get();Re(r,n)&&o.timeStamp-s<300&&(o.preventDefault(),i(n,n))}e.set(n),t.set(o.timeStamp)}}})();e.on("dragstart",(e=>{a.clearstate()})),e.on("mousedown",(e=>{d(e)&&Gm(e)&&a.mousedown(Um(e))})),e.on("mouseover",(e=>{var t;void 0!==(t=e).buttons&&0==(1&t.buttons)||!Gm(e)||a.mouseover(Um(e))})),e.on("mouseup",(e=>{d(e)&&Gm(e)&&a.mouseup(Um(e))})),e.on("touchend",u.touchEnd),e.on("keyup",(t=>{const o=Um(t);if(o.raw.shiftKey&&gm(o.raw.which)){const t=e.selection.getRng(),n=xe.fromDom(t.startContainer),r=xe.fromDom(t.endContainer);c.keyup(o,n,t.startOffset,r,t.endOffset).each((e=>{m(o,e)}))}})),e.on("keydown",(o=>{const n=Um(o);t.hide();const r=e.selection.getRng(),s=xe.fromDom(r.startContainer),l=xe.fromDom(r.endContainer),a=un(hm,pm)(xe.fromDom(e.selection.getStart()));c.keydown(n,s,r.startOffset,l,r.endOffset,a).each((e=>{m(n,e)})),t.show()})),e.on("NodeChange",(()=>{const t=e.selection,o=xe.fromDom(t.getStart()),r=xe.fromDom(t.getEnd());vs(Kt,[o,r]).fold((()=>n.clear(s)),f)}))})),e.on("PreInit",(()=>{e.serializer.addTempAttr(As.firstSelected),e.serializer.addTempAttr(As.lastSelected)})),{getSelectedCells:()=>((e,t,o,n)=>{switch(e.tag){case"none":return t();case"single":return(e=>[e.dom])(e.element);case"multiple":return(e=>E(e,(e=>e.dom)))(e.elements)}})(o.get(),g([])),clearSelectedCells:e=>n.clear(xe.fromDom(e))}},Ym=e=>{let t=[];return{bind:e=>{if(void 0===e)throw new Error("Event bind error: undefined handler");t.push(e)},unbind:e=>{t=_(t,(t=>t!==e))},trigger:(...o)=>{const n={};N(e,((e,t)=>{n[e]=o[t]})),N(t,(e=>{e(n)}))}}},Jm=e=>({registry:K(e,(e=>({bind:e.bind,unbind:e.unbind}))),trigger:K(e,(e=>e.trigger))}),Qm=e=>e.slice(0).sort(),Xm=(e,t)=>{const o=_(t,(t=>!D(e,t)));o.length>0&&(e=>{throw new Error("Unsupported keys for object: "+Qm(e).join(", "))})(o)},Zm=e=>((e,t)=>((e,t,o)=>{if(0===t.length)throw new Error("You must specify at least one required field.");return((e,t)=>{if(!l(t))throw new Error("The "+e+" fields must be an array. Was: "+t+".");N(t,(t=>{if(!r(t))throw new Error("The value "+t+" in the "+e+" fields was not a string.")}))})("required",t),(e=>{const t=Qm(e);L(t,((e,o)=>o{throw new Error("The field: "+e+" occurs more than once in the combined fields: ["+t.join(", ")+"].")}))})(t),n=>{const r=q(n);P(t,(e=>D(r,e)))||((e,t)=>{throw new Error("All required keys ("+Qm(e).join(", ")+") were not specified. Specified keys were: "+Qm(t).join(", ")+".")})(t,r),e(t,r);const s=_(t,(e=>!o.validate(n[e],e)));return s.length>0&&((e,t)=>{throw new Error("All values need to be of type: "+t+". Keys ("+Qm(e).join(", ")+") were not.")})(s,o.label),n}})(e,t,{validate:d,label:"function"}))(Xm,e),ed=Zm(["compare","extract","mutate","sink"]),td=Zm(["element","start","stop","destroy"]),od=Zm(["forceDrop","drop","move","delayDrop"]),nd=()=>{const e=(()=>{const e=Jm({move:Ym(["info"])});return{onEvent:f,reset:f,events:e.registry}})(),t=(()=>{let e=C.none();const t=Jm({move:Ym(["info"])});return{onEvent:(o,n)=>{n.extract(o).each((o=>{const r=((t,o)=>{const n=e.map((e=>t.compare(e,o)));return e=C.some(o),n})(n,o);r.each((e=>{t.trigger.move(e)}))}))},reset:()=>{e=C.none()},events:t.registry}})();let o=e;return{on:()=>{o.reset(),o=t},off:()=>{o.reset(),o=e},isOn:()=>o===t,onEvent:(e,t)=>{o.onEvent(e,t)},events:t.events}},rd=e=>{const t=e.replace(/\./g,"-");return{resolve:e=>t+"-"+e}},sd=rd("ephox-dragster").resolve;var ld=ed({compare:(e,t)=>bn(t.left-e.left,t.top-e.top),extract:e=>C.some(bn(e.x,e.y)),sink:(e,t)=>{const o=(e=>{const t={layerClass:sd("blocker"),...e},o=xe.fromTag("div");return ge(o,"role","presentation"),Bt(o,{position:"fixed",left:"0px",top:"0px",width:"100%",height:"100%"}),Mm(o,sd("blocker")),Mm(o,t.layerClass),{element:g(o),destroy:()=>{qe(o)}}})(t),n=qm(o.element(),"mousedown",e.forceDrop),r=qm(o.element(),"mouseup",e.drop),s=qm(o.element(),"mousemove",e.move),l=qm(o.element(),"mouseout",e.delayDrop);return td({element:o.element,start:e=>{Ie(e,o.element())},stop:()=>{qe(o.element())},destroy:()=>{o.destroy(),r.unbind(),s.unbind(),l.unbind(),n.unbind()}})},mutate:(e,t)=>{e.mutate(t.left,t.top)}});const ad=rd("ephox-snooker").resolve,cd=ad("resizer-bar"),id=ad("resizer-rows"),md=ad("resizer-cols"),dd=e=>{const t=dt(e.parent(),"."+cd);N(t,qe)},ud=(e,t,o)=>{const n=e.origin();N(t,(t=>{t.each((t=>{const r=o(n,t);Mm(r,cd),Ie(e.parent(),r)}))}))},fd=(e,t,o,n,r)=>{const s=yn(o),l=t.isResizable,a=n.length>0?_n.positions(n,o):[],c=a.length>0?((e,t)=>j(e.all,((e,o)=>t(e.element)?[o]:[])))(e,l):[];((e,t,o,n)=>{ud(e,t,((e,t)=>{const r=((e,t,o,n,r)=>{const s=xe.fromTag("div");return Bt(s,{position:"absolute",left:t+"px",top:o-3.5+"px",height:"7px",width:n+"px"}),he(s,{"data-row":e,role:"presentation"}),s})(t.row,o.left-e.left,t.y-e.top,n);return Mm(r,id),r}))})(t,_(a,((e,t)=>O(c,(e=>t===e)))),s,Wo(o));const i=r.length>0?An.positions(r,o):[],m=i.length>0?((e,t)=>{const o=[];return k(e.grid.columns,(n=>{an(e,n).map((e=>e.element)).forall(t)&&o.push(n)})),_(o,(o=>{const n=nn(e,(e=>e.column===o));return P(n,(e=>t(e.element)))}))})(e,l):[];((e,t,o,n)=>{ud(e,t,((e,t)=>{const r=((e,t,o,n,r)=>{const s=xe.fromTag("div");return Bt(s,{position:"absolute",left:t-3.5+"px",top:o+"px",height:r+"px",width:"7px"}),he(s,{"data-column":e,role:"presentation"}),s})(t.col,t.x-e.left,o.top-e.top,0,n);return Mm(r,md),r}))})(t,_(i,((e,t)=>O(m,(e=>t===e)))),s,pn(o))},gd=(e,t)=>{if(dd(e),e.isResizable(t)){const o=Zo(t),n=dn(o),r=cn(o);fd(o,e,t,n,r)}},hd=(e,t)=>{const o=dt(e.parent(),"."+cd);N(o,t)},pd=e=>{hd(e,(e=>{Nt(e,"display","none")}))},wd=e=>{hd(e,(e=>{Nt(e,"display","block")}))},bd=ad("resizer-bar-dragging"),vd=e=>{const t=(()=>{const e=Jm({drag:Ym(["xDelta","yDelta","target"])});let t=C.none();const o=(()=>{const e=Jm({drag:Ym(["xDelta","yDelta"])});return{mutate:(t,o)=>{e.trigger.drag(t,o)},events:e.registry}})();return o.events.drag.bind((o=>{t.each((t=>{e.trigger.drag(o.xDelta,o.yDelta,t)}))})),{assign:e=>{t=C.some(e)},get:()=>t,mutate:o.mutate,events:e.registry}})(),o=((e,t={})=>{var o;return((e,t,o)=>{let n=!1;const r=Jm({start:Ym([]),stop:Ym([])}),s=nd(),l=()=>{m.stop(),s.isOn()&&(s.off(),r.trigger.stop())},c=((e,t)=>{let o=null;const n=()=>{a(o)||(clearTimeout(o),o=null)};return{cancel:n,throttle:(...t)=>{n(),o=setTimeout((()=>{o=null,e.apply(null,t)}),200)}}})(l);s.events.move.bind((o=>{t.mutate(e,o.info)}));const i=e=>(...t)=>{n&&e.apply(null,t)},m=t.sink(od({forceDrop:l,drop:i(l),move:i((e=>{c.cancel(),s.onEvent(e,t)})),delayDrop:i(c.throttle)}),o);return{element:m.element,go:e=>{m.start(e),s.on(),r.trigger.start()},on:()=>{n=!0},off:()=>{n=!1},isActive:()=>n,destroy:()=>{m.destroy()},events:r.registry}})(e,null!==(o=t.mode)&&void 0!==o?o:ld,t)})(t,{});let n=C.none();const r=(e,t)=>C.from(pe(e,t));t.events.drag.bind((e=>{r(e.target,"data-row").each((t=>{const o=It(e.target,"top");Nt(e.target,"top",o+e.yDelta+"px")})),r(e.target,"data-column").each((t=>{const o=It(e.target,"left");Nt(e.target,"left",o+e.xDelta+"px")}))}));const s=(e,t)=>It(e,t)-Wt(e,"data-initial-"+t,0);o.events.stop.bind((()=>{t.get().each((t=>{n.each((o=>{r(t,"data-row").each((e=>{const n=s(t,"top");be(t,"data-initial-top"),d.trigger.adjustHeight(o,n,parseInt(e,10))})),r(t,"data-column").each((e=>{const n=s(t,"left");be(t,"data-initial-left"),d.trigger.adjustWidth(o,n,parseInt(e,10))})),gd(e,o)}))}))}));const l=(n,r)=>{d.trigger.startAdjust(),t.assign(n),ge(n,"data-initial-"+r,It(n,r)),Mm(n,bd),Nt(n,"opacity","0.2"),o.go(e.parent())},c=qm(e.parent(),"mousedown",(e=>{var t;t=e.target,jm(t,id)&&l(e.target,"top"),(e=>jm(e,md))(e.target)&&l(e.target,"left")})),i=t=>Re(t,e.view()),m=qm(e.view(),"mouseover",(t=>{var r;(r=t.target,bt(r,"table",i).filter(Qr)).fold((()=>{lt(t.target)&&dd(e)}),(t=>{o.isActive()&&(n=C.some(t),gd(e,t))}))})),d=Jm({adjustHeight:Ym(["table","delta","row"]),adjustWidth:Ym(["table","delta","column"]),startAdjust:Ym([])});return{destroy:()=>{c.unbind(),m.unbind(),o.destroy(),dd(e)},refresh:t=>{gd(e,t)},on:o.on,off:o.off,hideBars:w(pd,e),showBars:w(wd,e),events:d.registry}},yd=(e,t,o)=>{const n=_n,r=An,s=vd(e),l=Jm({beforeResize:Ym(["table","type"]),afterResize:Ym(["table","type"]),startDrag:Ym([])});return s.events.adjustHeight.bind((e=>{const t=e.table;l.trigger.beforeResize(t,"row");((e,t,o,n)=>{const r=Zo(e),s=((e,t,o)=>lr(e,t,o,Yn,(e=>e.getOrThunk(Ht))))(r,e,n),l=E(s,((e,n)=>o===n?Math.max(t+e,Ht()):e)),a=oa(r,l),c=((e,t)=>E(e.all,((e,o)=>({element:e.element,height:t[o]}))))(r,l);N(c,(e=>{$n(e.element,e.height)})),N(a,(e=>{$n(e.element,e.height)}));const i=z(l,((e,t)=>e+t),0);$n(e,i)})(t,n.delta(e.delta,t),e.row,n),l.trigger.afterResize(t,"row")})),s.events.startAdjust.bind((e=>{l.trigger.startDrag()})),s.events.adjustWidth.bind((e=>{const n=e.table;l.trigger.beforeResize(n,"col");const s=r.delta(e.delta,n),a=o(n);ra(n,s,e.column,t,a),l.trigger.afterResize(n,"col")})),{on:s.on,off:s.off,refreshBars:s.refresh,hideBars:s.hideBars,showBars:s.showBars,destroy:s.destroy,events:l.registry}},xd=e=>m(e)&&"TABLE"===e.nodeName,Cd="bar-",Sd=e=>"false"!==pe(e,"data-mce-resize"),Td=e=>{const t=lm(),o=lm(),n=lm();let r,s;const l=t=>fc(e,t),a=()=>Pr(e)?el():Zs();return e.on("init",(()=>{const r=((e,t)=>e.inline?((e,t,o)=>({parent:g(t),view:g(e),origin:g(bn(0,0)),isResizable:o}))(xe.fromDom(e.getBody()),(()=>{const e=xe.fromTag("div");return Bt(e,{position:"static",height:"0",width:"0",padding:"0",margin:"0",border:"0"}),Ie(at(xe.fromDom(document)),e),e})(),t):((e,t)=>{const o=me(e)?(e=>xe.fromDom(Ee(e).dom.documentElement))(e):e;return{parent:g(o),view:g(e),origin:g(bn(0,0)),isResizable:t}})(xe.fromDom(e.getDoc()),t))(e,Sd);if(n.set(r),(e=>{const t=e.options.get("object_resizing");return D(t.split(","),"table")})(e)&&qr(e)){const n=a(),s=yd(r,n,l);s.on(),s.events.startDrag.bind((o=>{t.set(e.selection.getRng())})),s.events.beforeResize.bind((t=>{const o=t.table.dom;((e,t,o,n,r)=>{e.dispatch("ObjectResizeStart",{target:t,width:o,height:n,origin:r})})(e,o,ns(o),rs(o),Cd+t.type)})),s.events.afterResize.bind((o=>{const n=o.table,r=n.dom;ts(n),t.on((t=>{e.selection.setRng(t),e.focus()})),((e,t,o,n,r)=>{e.dispatch("ObjectResized",{target:t,width:o,height:n,origin:r})})(e,r,ns(r),rs(r),Cd+o.type),e.undoManager.add()})),o.set(s)}})),e.on("ObjectResizeStart",(t=>{const o=t.target;if(xd(o)){const n=xe.fromDom(o);N(e.dom.select(".mce-clonedresizable"),(t=>{e.dom.addClass(t,"mce-"+jr(e)+"-columns")})),!kc(n)&&$r(e)?_c(n):!Oc(n)&&Hr(e)&&Bc(n),Ec(n)&&Tt(t.origin,Cd)&&Bc(n),r=t.width,s=Vr(e)?"":((e,t)=>{const o=e.dom.getStyle(t,"width")||e.dom.getAttrib(t,"width");return C.from(o).filter(Ot)})(e,o).getOr("")}})),e.on("ObjectResized",(t=>{const o=t.target;if(xd(o)){const n=xe.fromDom(o),c=t.origin;Tt(c,"corner-")&&((t,o,n)=>{const c=Rt(o,"e");if(""===s&&Bc(t),n!==r&&""!==s){Nt(t,"width",s);const o=a(),i=l(t),m=Pr(e)||c?(e=>tl(e).columns)(t)-1:0;ra(t,n-r,m,o,i)}else if((e=>/^(\d+(\.\d+)?)%$/.test(e))(s)){const e=parseFloat(s.replace("%",""));Nt(t,"width",n*e/r+"%")}(e=>/^(\d+(\.\d+)?)px$/.test(e))(s)&&(e=>{const t=Zo(e);ln(t)||N(Ut(e),(e=>{const t=_t(e,"width");Nt(e,"width",t),be(e,"width")}))})(t)})(n,c,t.width),ts(n),ic(e,n.dom,mc)}})),e.on("SwitchMode",(()=>{o.on((t=>{e.mode.isReadOnly()?t.hideBars():t.showBars()}))})),e.on("dragstart dragend",(e=>{o.on((t=>{"dragstart"===e.type?(t.hideBars(),t.off()):(t.on(),t.showBars())}))})),e.on("remove",(()=>{o.on((e=>{e.destroy()})),n.on((t=>{((e,t)=>{e.inline&&qe(t.parent())})(e,t)}))})),{refresh:e=>{o.on((t=>t.refreshBars(xe.fromDom(e))))},hide:()=>{o.on((e=>e.hideBars()))},show:()=>{o.on((e=>e.showBars()))}}},Rd=e=>{(e=>{const t=e.options.register;t("table_clone_elements",{processor:"string[]"}),t("table_use_colgroups",{processor:"boolean",default:!0}),t("table_header_type",{processor:e=>{const t=D(["section","cells","sectionCells","auto"],e);return t?{value:e,valid:t}:{valid:!1,message:"Must be one of: section, cells, sectionCells or auto."}},default:"section"}),t("table_sizing_mode",{processor:"string",default:"auto"}),t("table_default_attributes",{processor:"object",default:{border:"1"}}),t("table_default_styles",{processor:"object",default:{"border-collapse":"collapse"}}),t("table_column_resizing",{processor:e=>{const t=D(["preservetable","resizetable"],e);return t?{value:e,valid:t}:{valid:!1,message:"Must be preservetable, or resizetable."}},default:"preservetable"}),t("table_resize_bars",{processor:"boolean",default:!0}),t("table_style_by_css",{processor:"boolean",default:!0}),t("table_merge_content_on_paste",{processor:"boolean",default:!0})})(e);const t=Td(e),o=Km(e,t),n=gc(e,t,o);return Xc(e,n),((e,t)=>{const o=es(e),n=t=>js(os(e)).bind((n=>Kt(n,o).map((o=>{const r=Ls(Ps(e),o,n);return t(o,r)})))).getOr("");G({mceTableRowType:()=>n(t.getTableRowType),mceTableCellType:()=>n(t.getTableCellType),mceTableColType:()=>n(t.getTableColType)},((t,o)=>e.addQueryValueHandler(o,t)))})(e,n),Is(e,n),{getSelectedCells:o.getSelectedCells,clearSelectedCells:o.clearSelectedCells}};e.add("dom",(e=>({table:Rd(e)})))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/advlist/plugin.min.js b/public/libs/tinymce/plugins/advlist/plugin.min.js index ef5c0141bc2..8be78ea4211 100644 --- a/public/libs/tinymce/plugins/advlist/plugin.min.js +++ b/public/libs/tinymce/plugins/advlist/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=(t,e,s)=>{const r="UL"===e?"InsertUnorderedList":"InsertOrderedList";t.execCommand(r,!1,!1===s?null:{"list-style-type":s})},s=t=>e=>e.options.get(t),r=s("advlist_number_styles"),n=s("advlist_bullet_styles"),i=t=>null==t,l=t=>!i(t);var o=tinymce.util.Tools.resolve("tinymce.util.Tools");class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return l(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=t=>e=>l(e)&&t.test(e.nodeName),d=u(/^(OL|UL|DL)$/),g=u(/^(TH|TD)$/),c=t=>i(t)||"default"===t?"":t,h=(t,e)=>s=>((t,e)=>{const s=t.selection.getNode();return e({parents:t.dom.getParents(s),element:s}),t.on("NodeChange",e),()=>t.off("NodeChange",e)})(t,(r=>((t,r)=>{const n=t.selection.getStart(!0);s.setActive(((t,e,s)=>((t,e,s)=>{for(let e=0,n=t.length;ee.nodeName===s&&((t,e)=>t.dom.isChildOf(e,t.getBody()))(t,e))))(t,r,e)),s.setEnabled(!((t,e)=>{const s=t.dom.getParent(e,"ol,ul,dl");return((t,e)=>null!==e&&!t.dom.isEditable(e))(t,s)&&t.selection.isEditable()})(t,n)&&t.selection.isEditable())})(t,r.parents))),m=(t,s,r,n,i,l)=>{l.length>1?((t,s,r,n,i,l)=>{t.ui.registry.addSplitButton(s,{tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",presets:"listpreview",columns:3,fetch:t=>{t(o.map(l,(t=>{const e="OL"===i?"num":"bull",s="disc"===t||"decimal"===t?"default":t,r=c(t),n=(t=>t.replace(/\-/g," ").replace(/\b\w/g,(t=>t.toUpperCase())))(t);return{type:"choiceitem",value:r,icon:"list-"+e+"-"+s,text:n}})))},onAction:()=>t.execCommand(n),onItemAction:(s,r)=>{e(t,i,r)},select:e=>{const s=(t=>{const e=t.dom.getParent(t.selection.getNode(),"ol,ul"),s=t.dom.getStyle(e,"listStyleType");return a.from(s)})(t);return s.map((t=>e===t)).getOr(!1)},onSetup:h(t,i)})})(t,s,r,n,i,l):((t,s,r,n,i,l)=>{t.ui.registry.addToggleButton(s,{active:!1,tooltip:r,icon:"OL"===i?"ordered-list":"unordered-list",onSetup:h(t,i),onAction:()=>t.queryCommandState(n)||""===l?t.execCommand(n):e(t,i,l)})})(t,s,r,n,i,c(l[0]))};t.add("advlist",(t=>{t.hasPlugin("lists")?((t=>{const e=t.options.register;e("advlist_number_styles",{processor:"string[]",default:"default,lower-alpha,lower-greek,lower-roman,upper-alpha,upper-roman".split(",")}),e("advlist_bullet_styles",{processor:"string[]",default:"default,circle,square".split(",")})})(t),(t=>{m(t,"numlist","Numbered list","InsertOrderedList","OL",r(t)),m(t,"bullist","Bullet list","InsertUnorderedList","UL",n(t))})(t),(t=>{t.addCommand("ApplyUnorderedListStyle",((s,r)=>{e(t,"UL",r["list-style-type"])})),t.addCommand("ApplyOrderedListStyle",((s,r)=>{e(t,"OL",r["list-style-type"])}))})(t)):console.error("Please use the Lists plugin together with the Advanced List plugin.")}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/anchor/plugin.min.js b/public/libs/tinymce/plugins/anchor/plugin.min.js index cf19304ed5f..8049b4d3ae5 100644 --- a/public/libs/tinymce/plugins/anchor/plugin.min.js +++ b/public/libs/tinymce/plugins/anchor/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=("allow_html_in_named_anchor",e=>e.options.get("allow_html_in_named_anchor"));const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;ot=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/autolink/plugin.min.js b/public/libs/tinymce/plugins/autolink/plugin.min.js index df56de1f648..32772191f62 100644 --- a/public/libs/tinymce/plugins/autolink/plugin.min.js +++ b/public/libs/tinymce/plugins/autolink/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),n=t("autolink_pattern"),o=t("link_default_target"),r=t("link_default_protocol"),a=t("allow_unsafe_link_target"),s=("string",e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e));const l=(void 0,e=>undefined===e);const i=e=>!(e=>null==e)(e),c=Object.hasOwnProperty,d=e=>"\ufeff"===e;var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!d(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=n(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]"))return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),c.call(t,n))||"false"===i.getContentEditable(e);var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return $="www.",(b=t).length>=4&&b.substr(0,4)===$?t=r(e)+"://"+t:((e,t,n=0,o)=>{const r=e.indexOf(t,n);return-1!==r&&(!!l(o)||r+t.length<=o)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,$;return null},k=(e,t)=>{const{dom:n,selection:r}=e,{rng:l,url:i}=t,c=r.getBookmark();r.setRng(l);const d="createlink",u={command:d,ui:!1,value:i};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(d,!1,i),e.dispatch("ExecCommand",u);const t=o(e);if(s(t)){const o=r.getNode();n.setAttrib(o,"target",t),"_blank"!==t||a(e)||n.setAttrib(o,"rel","noopener")}}r.moveToBookmark(c),e.nodeChanged()},p=e=>{const t=m(e,-1);i(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);i(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/autoresize/plugin.min.js b/public/libs/tinymce/plugins/autoresize/plugin.min.js index c4d6f877515..939bd27cad6 100644 --- a/public/libs/tinymce/plugins/autoresize/plugin.min.js +++ b/public/libs/tinymce/plugins/autoresize/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),s=o("min_height"),i=o("max_height"),n=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),l=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},g=(e,t,o,s)=>{var i;const n=parseInt(null!==(i=e.getStyle(t,o,s))&&void 0!==i?i:"",10);return isNaN(n)?0:n},a=(e,o,r,c)=>{var d;const f=e.dom,u=e.getDoc();if(!u)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void l(e,!0);const m=u.documentElement,h=c?c():n(e),p=null!==(d=s(e))&&void 0!==d?d:e.getElement().offsetHeight;let y=p;const S=g(f,m,"margin-top",!0),v=g(f,m,"margin-bottom",!0);let C=m.offsetHeight+S+v+h;C<0&&(C=0);const b=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+b>p&&(y=C+b);const w=i(e);if(w&&y>w?(y=w,l(e,!0)):l(e,!1),y!==o.get()){const s=y-o.get();if(f.setStyle(e.getContainer(),"height",y+"px"),o.set(y),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===(null==e?void 0:e.type.toLowerCase())){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&s<0&&a(e,o,r,c)}};e.add("autoresize",(e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(e=>{let t=0;return{get:()=>t,set:e=>{t=e}}})();((e,t)=>{e.addCommand("mceAutoResize",(()=>{a(e,t)}))})(e,o),((e,o)=>{let s,i,l=()=>r(e);e.on("init",(i=>{s=0;const r=n(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,i,l),s+=1})),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",(t=>{if(1===s)i=e.getContainer().offsetHeight,a(e,o,t,l),s+=1;else if(2===s){const t=i0):l,s+=1}else a(e,o,t,l)}))})(e,o)}}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/autosave/plugin.min.js b/public/libs/tinymce/plugins/autosave/plugin.min.js index 110f508d65d..b8a68e4a62e 100644 --- a/public/libs/tinymce/plugins/autosave/plugin.min.js +++ b/public/libs/tinymce/plugins/autosave/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=("string",t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===a.name)?"string":e;var r,o,a,s})(t));const r=(void 0,t=>undefined===t);var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),l=i("autosave_restore_when_empty"),c=i("autosave_interval"),d=i("autosave_retention"),m=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},v=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},f=t=>{var e;const r=parseInt(null!==(e=a.getItem(m(t)+"time"))&&void 0!==e?e:"0",10)||0;return!((new Date).getTime()-r>d(t)&&(p(t,!1),1))},p=(t,e)=>{const r=m(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},g=t=>{const e=m(t);!v(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},y=t=>{var e;const r=m(t);f(t)&&(t.setContent(null!==(e=a.getItem(r+"draft"))&&void 0!==e?e:"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{e.setEnabled(f(t));const r=()=>e.setEnabled(f(t));return t.on("StoreDraft RestoreDraft RemoveDraft",r),()=>t.off("StoreDraft RestoreDraft RemoveDraft",r)};t.add("autosave",(t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",(t=>{let e;s.each(D.get(),(t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))})),e&&(t.preventDefault(),t.returnValue=e)}))})(t),(t=>{(t=>{const e=c(t);o.setEditorInterval(t,(()=>{g(t)}),e)})(t);const e=()=>{(t=>{t.undoManager.transact((()=>{y(t),p(t)})),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",(()=>{l(t)&&t.dom.isEmpty(t.getBody())&&y(t)})),(t=>({hasDraft:()=>f(t),storeDraft:()=>g(t),restoreDraft:()=>y(t),removeDraft:e=>p(t,e),isEmpty:e=>v(t,e)}))(t))))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/charmap/plugin.min.js b/public/libs/tinymce/plugins/charmap/plugin.min.js index 9f7f1c161af..6c12ece2b23 100644 --- a/public/libs/tinymce/plugins/charmap/plugin.min.js +++ b/public/libs/tinymce/plugins/charmap/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=(e,t)=>{const r=((e,t)=>e.dispatch("insertCustomChar",{chr:t}))(e,t).chr;e.execCommand("mceInsertContent",!1,r)},r=e=>t=>e===t,a=("array",e=>"array"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(r=a=e,(n=String).prototype.isPrototypeOf(r)||(null===(i=a.constructor)||void 0===i?void 0:i.name)===n.name)?"string":t;var r,a,n,i})(e));const n=r(null),i=r(void 0),o=e=>"function"==typeof e,s=(!1,()=>false);class l{constructor(e,t){this.tag=e,this.value=t}static some(e){return new l(!0,e)}static none(){return l.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?l.some(e(this.value)):l.none()}bind(e){return this.tag?e(this.value):l.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:l.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return null==e?l.none():l.some(e)}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}l.singletonNone=new l(!1);const c=Array.prototype.push,u=(e,t)=>{const r=e.length,a=new Array(r);for(let n=0;nt=>t.options.get(e),m=h("charmap"),p=h("charmap_append"),d=g.isArray,f="User Defined",y=e=>{return d(e)?(t=e,g.grep(t,(e=>d(e)&&2===e.length))):"function"==typeof e?e():[];var t},b=e=>{const t=((e,t)=>{const r=m(e);r&&(t=[{name:f,characters:y(r)}]);const a=p(e);if(a){const e=g.grep(t,(e=>e.name===f));return e.length?(e[0].characters=[...e[0].characters,...y(a)],t):t.concat({name:f,characters:y(a)})}return t})(e,[{name:"Currency",characters:[[36,"dollar sign"],[162,"cent sign"],[8364,"euro sign"],[163,"pound sign"],[165,"yen sign"],[164,"currency sign"],[8352,"euro-currency sign"],[8353,"colon sign"],[8354,"cruzeiro sign"],[8355,"french franc sign"],[8356,"lira sign"],[8357,"mill sign"],[8358,"naira sign"],[8359,"peseta sign"],[8360,"rupee sign"],[8361,"won sign"],[8362,"new sheqel sign"],[8363,"dong sign"],[8365,"kip sign"],[8366,"tugrik sign"],[8367,"drachma sign"],[8368,"german penny symbol"],[8369,"peso sign"],[8370,"guarani sign"],[8371,"austral sign"],[8372,"hryvnia sign"],[8373,"cedi sign"],[8374,"livre tournois sign"],[8375,"spesmilo sign"],[8376,"tenge sign"],[8377,"indian rupee sign"],[8378,"turkish lira sign"],[8379,"nordic mark sign"],[8380,"manat sign"],[8381,"ruble sign"],[20870,"yen character"],[20803,"yuan character"],[22291,"yuan character, in hong kong and taiwan"],[22278,"yen/yuan character variant one"]]},{name:"Text",characters:[[169,"copyright sign"],[174,"registered sign"],[8482,"trade mark sign"],[8240,"per mille sign"],[181,"micro sign"],[183,"middle dot"],[8226,"bullet"],[8230,"three dot leader"],[8242,"minutes / feet"],[8243,"seconds / inches"],[167,"section sign"],[182,"paragraph sign"],[223,"sharp s / ess-zed"]]},{name:"Quotations",characters:[[8249,"single left-pointing angle quotation mark"],[8250,"single right-pointing angle quotation mark"],[171,"left pointing guillemet"],[187,"right pointing guillemet"],[8216,"left single quotation mark"],[8217,"right single quotation mark"],[8220,"left double quotation mark"],[8221,"right double quotation mark"],[8218,"single low-9 quotation mark"],[8222,"double low-9 quotation mark"],[60,"less-than sign"],[62,"greater-than sign"],[8804,"less-than or equal to"],[8805,"greater-than or equal to"],[8211,"en dash"],[8212,"em dash"],[175,"macron"],[8254,"overline"],[164,"currency sign"],[166,"broken bar"],[168,"diaeresis"],[161,"inverted exclamation mark"],[191,"turned question mark"],[710,"circumflex accent"],[732,"small tilde"],[176,"degree sign"],[8722,"minus sign"],[177,"plus-minus sign"],[247,"division sign"],[8260,"fraction slash"],[215,"multiplication sign"],[185,"superscript one"],[178,"superscript two"],[179,"superscript three"],[188,"fraction one quarter"],[189,"fraction one half"],[190,"fraction three quarters"]]},{name:"Mathematical",characters:[[402,"function / florin"],[8747,"integral"],[8721,"n-ary sumation"],[8734,"infinity"],[8730,"square root"],[8764,"similar to"],[8773,"approximately equal to"],[8776,"almost equal to"],[8800,"not equal to"],[8801,"identical to"],[8712,"element of"],[8713,"not an element of"],[8715,"contains as member"],[8719,"n-ary product"],[8743,"logical and"],[8744,"logical or"],[172,"not sign"],[8745,"intersection"],[8746,"union"],[8706,"partial differential"],[8704,"for all"],[8707,"there exists"],[8709,"diameter"],[8711,"backward difference"],[8727,"asterisk operator"],[8733,"proportional to"],[8736,"angle"]]},{name:"Extended Latin",characters:[[192,"A - grave"],[193,"A - acute"],[194,"A - circumflex"],[195,"A - tilde"],[196,"A - diaeresis"],[197,"A - ring above"],[256,"A - macron"],[198,"ligature AE"],[199,"C - cedilla"],[200,"E - grave"],[201,"E - acute"],[202,"E - circumflex"],[203,"E - diaeresis"],[274,"E - macron"],[204,"I - grave"],[205,"I - acute"],[206,"I - circumflex"],[207,"I - diaeresis"],[298,"I - macron"],[208,"ETH"],[209,"N - tilde"],[210,"O - grave"],[211,"O - acute"],[212,"O - circumflex"],[213,"O - tilde"],[214,"O - diaeresis"],[216,"O - slash"],[332,"O - macron"],[338,"ligature OE"],[352,"S - caron"],[217,"U - grave"],[218,"U - acute"],[219,"U - circumflex"],[220,"U - diaeresis"],[362,"U - macron"],[221,"Y - acute"],[376,"Y - diaeresis"],[562,"Y - macron"],[222,"THORN"],[224,"a - grave"],[225,"a - acute"],[226,"a - circumflex"],[227,"a - tilde"],[228,"a - diaeresis"],[229,"a - ring above"],[257,"a - macron"],[230,"ligature ae"],[231,"c - cedilla"],[232,"e - grave"],[233,"e - acute"],[234,"e - circumflex"],[235,"e - diaeresis"],[275,"e - macron"],[236,"i - grave"],[237,"i - acute"],[238,"i - circumflex"],[239,"i - diaeresis"],[299,"i - macron"],[240,"eth"],[241,"n - tilde"],[242,"o - grave"],[243,"o - acute"],[244,"o - circumflex"],[245,"o - tilde"],[246,"o - diaeresis"],[248,"o slash"],[333,"o macron"],[339,"ligature oe"],[353,"s - caron"],[249,"u - grave"],[250,"u - acute"],[251,"u - circumflex"],[252,"u - diaeresis"],[363,"u - macron"],[253,"y - acute"],[254,"thorn"],[255,"y - diaeresis"],[563,"y - macron"],[913,"Alpha"],[914,"Beta"],[915,"Gamma"],[916,"Delta"],[917,"Epsilon"],[918,"Zeta"],[919,"Eta"],[920,"Theta"],[921,"Iota"],[922,"Kappa"],[923,"Lambda"],[924,"Mu"],[925,"Nu"],[926,"Xi"],[927,"Omicron"],[928,"Pi"],[929,"Rho"],[931,"Sigma"],[932,"Tau"],[933,"Upsilon"],[934,"Phi"],[935,"Chi"],[936,"Psi"],[937,"Omega"],[945,"alpha"],[946,"beta"],[947,"gamma"],[948,"delta"],[949,"epsilon"],[950,"zeta"],[951,"eta"],[952,"theta"],[953,"iota"],[954,"kappa"],[955,"lambda"],[956,"mu"],[957,"nu"],[958,"xi"],[959,"omicron"],[960,"pi"],[961,"rho"],[962,"final sigma"],[963,"sigma"],[964,"tau"],[965,"upsilon"],[966,"phi"],[967,"chi"],[968,"psi"],[969,"omega"]]},{name:"Symbols",characters:[[8501,"alef symbol"],[982,"pi symbol"],[8476,"real part symbol"],[978,"upsilon - hook symbol"],[8472,"Weierstrass p"],[8465,"imaginary part"]]},{name:"Arrows",characters:[[8592,"leftwards arrow"],[8593,"upwards arrow"],[8594,"rightwards arrow"],[8595,"downwards arrow"],[8596,"left right arrow"],[8629,"carriage return"],[8656,"leftwards double arrow"],[8657,"upwards double arrow"],[8658,"rightwards double arrow"],[8659,"downwards double arrow"],[8660,"left right double arrow"],[8756,"therefore"],[8834,"subset of"],[8835,"superset of"],[8836,"not a subset of"],[8838,"subset of or equal to"],[8839,"superset of or equal to"],[8853,"circled plus"],[8855,"circled times"],[8869,"perpendicular"],[8901,"dot operator"],[8968,"left ceiling"],[8969,"right ceiling"],[8970,"left floor"],[8971,"right floor"],[9001,"left-pointing angle bracket"],[9002,"right-pointing angle bracket"],[9674,"lozenge"],[9824,"black spade suit"],[9827,"black club suit"],[9829,"black heart suit"],[9830,"black diamond suit"],[8194,"en space"],[8195,"em space"],[8201,"thin space"],[8204,"zero width non-joiner"],[8205,"zero width joiner"],[8206,"left-to-right mark"],[8207,"right-to-left mark"]]}]);return t.length>1?[{name:"All",characters:(r=t,n=e=>e.characters,(e=>{const t=[];for(let r=0,n=e.length;r{let t=e;return{get:()=>t,set:e=>{t=e}}},v=(e,t,r=0,a)=>{const n=e.indexOf(t,r);return-1!==n&&(!!i(a)||n+t.length<=a)},k=String.fromCodePoint,C=(e,t)=>{const r=[],a=t.toLowerCase();return((e,t)=>{for(let t=0,i=e.length;t!!v(k(e).toLowerCase(),r)||v(t.toLowerCase(),r)||v(t.toLowerCase().replace(/\s+/g,""),r))((n=e[t])[0],n[1],a)&&r.push(n);var n})(e.characters),u(r,(e=>({text:e[1],value:k(e[0]),icon:k(e[0])})))},x="pattern",A=(e,r)=>{const a=()=>[{label:"Search",type:"input",name:x},{type:"collection",name:"results"}],i=1===r.length?w(f):w("All"),o=((e,t)=>{let r=null;const a=()=>{n(r)||(clearTimeout(r),r=null)};return{cancel:a,throttle:(...t)=>{a(),r=setTimeout((()=>{r=null,e.apply(null,t)}),40)}}})((e=>{const t=e.getData().pattern;((e,t)=>{var a,n;(a=r,n=e=>e.name===i.get(),((e,t,r)=>{for(let a=0,n=e.length;a{const a=C(r,t);e.setData({results:a})}))})(e,t)})),c={title:"Special Character",size:"normal",body:1===r.length?{type:"panel",items:a()}:{type:"tabpanel",tabs:u(r,(e=>({title:e.name,name:e.name,items:a()})))},buttons:[{type:"cancel",name:"close",text:"Close",primary:!0}],initialData:{pattern:"",results:C(r[0],"")},onAction:(r,a)=>{"results"===a.name&&(t(e,a.value),r.close())},onTabChange:(e,t)=>{i.set(t.newTabName),o.throttle(e)},onChange:(e,t)=>{t.name===x&&o.throttle(e)}};e.windowManager.open(c).focus(x)},q=e=>t=>{const r=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",r),r(),()=>{e.off("NodeChange",r)}};e.add("charmap",(e=>{(e=>{const t=e.options.register,r=e=>o(e)||a(e);t("charmap",{processor:r}),t("charmap_append",{processor:r})})(e);const r=b(e);return((e,t)=>{e.addCommand("mceShowCharmap",(()=>{A(e,t)}))})(e,r),(e=>{const t=()=>e.execCommand("mceShowCharmap");e.ui.registry.addButton("charmap",{icon:"insert-character",tooltip:"Special character",onAction:t,onSetup:q(e)}),e.ui.registry.addMenuItem("charmap",{icon:"insert-character",text:"Special character...",onAction:t,onSetup:q(e)})})(e),((e,t)=>{e.ui.registry.addAutocompleter("charmap",{trigger:":",columns:"auto",minChars:2,fetch:(e,r)=>new Promise(((r,a)=>{r(C(t,e))})),onAction:(t,r,a)=>{e.selection.setRng(r),e.insertContent(a),t.hide()}})})(e,r[0]),(e=>({getCharMap:()=>b(e),insertChar:r=>{t(e,r)}}))(e)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/code/plugin.min.js b/public/libs/tinymce/plugins/code/plugin.min.js index a76b4982452..1928a12f774 100644 --- a/public/libs/tinymce/plugins/code/plugin.min.js +++ b/public/libs/tinymce/plugins/code/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/codesample/plugin.min.js b/public/libs/tinymce/plugins/codesample/plugin.min.js index f3936a81051..504ea9e5213 100644 --- a/public/libs/tinymce/plugins/codesample/plugin.min.js +++ b/public/libs/tinymce/plugins/codesample/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>!(e=>null==e)(e),n=()=>{};class a{constructor(e,t){this.tag=e,this.value=t}static some(e){return new a(!0,e)}static none(){return a.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?a.some(e(this.value)):a.none()}bind(e){return this.tag?e(this.value):a.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:a.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return t(e)?a.some(e):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);var s=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils");const r="undefined"!=typeof window?window:Function("return this;")(),i=function(e,t,n){const a=window.Prism;window.Prism={manual:!0};var s=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,a={},s={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof r?new r(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,"&").replace(/=d.reach);x+=_.value.length,_=_.next){var F=_.value;if(t.length>e.length)return;if(!(F instanceof r)){var A,S=1;if(y){if(!(A=i(v,x,e,m))||A.index>=e.length)break;var $=A.index,z=A.index+A[0].length,E=x;for(E+=_.value.length;$>=E;)E+=(_=_.next).value.length;if(x=E-=_.value.length,_.value instanceof r)continue;for(var C=_;C!==t.tail&&(Ed.reach&&(d.reach=O);var P=_.prev;if(B&&(P=u(t,P,B),x+=B.length),c(t,P,S),_=u(t,P,new r(g,f?s.tokenize(j,f):j,w,j)),T&&u(t,_,T),S>1){var N={cause:g+","+b,reach:O};o(e,t,n,_.prev,x,N),d&&N.reach>d.reach&&(d.reach=N.reach)}}}}}}function l(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function u(e,t,n){var a=t.next,s={value:n,prev:t,next:a};return t.next=s,a.prev=s,e.length++,s}function c(e,t,n){for(var a=t.next,s=0;s"+r.content+""},!e.document)return e.addEventListener?(s.disableWorkerMessageHandler||e.addEventListener("message",(function(t){var n=JSON.parse(t.data),a=n.language,r=n.code,i=n.immediateClose;e.postMessage(s.highlight(r,s.languages[a],a)),i&&e.close()}),!1),s):s;var d=s.util.currentScript();function g(){s.manual||s.highlightAll()}if(d&&(s.filename=d.src,d.hasAttribute("data-manual")&&(s.manual=!0)),!s.manual){var p=document.readyState;"loading"===p||"interactive"===p&&d&&d.defer?document.addEventListener("DOMContentLoaded",g):window.requestAnimationFrame?window.requestAnimationFrame(g):window.setTimeout(g,16)}return s}("undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{});return s.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/},function(e){function t(e,t){return"___"+e.toUpperCase()+t+"___"}Object.defineProperties(e.languages["markup-templating"]={},{buildPlaceholders:{value:function(n,a,s,r){if(n.language===a){var i=n.tokenStack=[];n.code=n.code.replace(s,(function(e){if("function"==typeof r&&!r(e))return e;for(var s,o=i.length;-1!==n.code.indexOf(s=t(a,o));)++o;return i[o]=e,s})),n.grammar=e.languages.markup}}},tokenizePlaceholders:{value:function(n,a){if(n.language===a&&n.tokenStack){n.grammar=e.languages[a];var s=0,r=Object.keys(n.tokenStack);!function i(o){for(var l=0;l=r.length);l++){var u=o[l];if("string"==typeof u||u.content&&"string"==typeof u.content){var c=r[s],d=n.tokenStack[c],g="string"==typeof u?u:u.content,p=t(a,c),b=g.indexOf(p);if(b>-1){++s;var h=g.substring(0,b),f=new e.Token(a,e.tokenize(d,n.grammar),"language-"+a,d),m=g.substring(b+p.length),y=[];h&&y.push.apply(y,i([h])),y.push(f),m&&y.push.apply(y,i([m])),"string"==typeof u?o.splice.apply(o,[l,1].concat(y)):u.content=y}}else u.content&&i(u.content)}return o}(n.tokens)}}}})}(s),s.languages.c=s.languages.extend("clike",{comment:{pattern:/\/\/(?:[^\r\n\\]|\\(?:\r\n?|\n|(?![\r\n])))*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},string:{pattern:/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"/,greedy:!0},"class-name":{pattern:/(\b(?:enum|struct)\s+(?:__attribute__\s*\(\([\s\S]*?\)\)\s*)?)\w+|\b[a-z]\w*_t\b/,lookbehind:!0},keyword:/\b(?:_Alignas|_Alignof|_Atomic|_Bool|_Complex|_Generic|_Imaginary|_Noreturn|_Static_assert|_Thread_local|__attribute__|asm|auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|return|short|signed|sizeof|static|struct|switch|typedef|typeof|union|unsigned|void|volatile|while)\b/,function:/\b[a-z_]\w*(?=\s*\()/i,number:/(?:\b0x(?:[\da-f]+(?:\.[\da-f]*)?|\.[\da-f]+)(?:p[+-]?\d+)?|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?)[ful]{0,4}/i,operator:/>>=?|<<=?|->|([-+&|:])\1|[?:~]|[-+*/%&|^!=<>]=?/}),s.languages.insertBefore("c","string",{char:{pattern:/'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n]){0,32}'/,greedy:!0}}),s.languages.insertBefore("c","string",{macro:{pattern:/(^[\t ]*)#\s*[a-z](?:[^\r\n\\/]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:\r\n|[\s\S]))*/im,lookbehind:!0,greedy:!0,alias:"property",inside:{string:[{pattern:/^(#\s*include\s*)<[^>]+>/,lookbehind:!0},s.languages.c.string],char:s.languages.c.char,comment:s.languages.c.comment,"macro-name":[{pattern:/(^#\s*define\s+)\w+\b(?!\()/i,lookbehind:!0},{pattern:/(^#\s*define\s+)\w+\b(?=\()/i,lookbehind:!0,alias:"function"}],directive:{pattern:/^(#\s*)[a-z]+/,lookbehind:!0,alias:"keyword"},"directive-hash":/^#/,punctuation:/##|\\(?=[\r\n])/,expression:{pattern:/\S[\s\S]*/,inside:s.languages.c}}}}),s.languages.insertBefore("c","function",{constant:/\b(?:EOF|NULL|SEEK_CUR|SEEK_END|SEEK_SET|__DATE__|__FILE__|__LINE__|__TIMESTAMP__|__TIME__|__func__|stderr|stdin|stdout)\b/}),delete s.languages.c.boolean,function(e){var t=/\b(?:alignas|alignof|asm|auto|bool|break|case|catch|char|char16_t|char32_t|char8_t|class|co_await|co_return|co_yield|compl|concept|const|const_cast|consteval|constexpr|constinit|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|final|float|for|friend|goto|if|import|inline|int|int16_t|int32_t|int64_t|int8_t|long|module|mutable|namespace|new|noexcept|nullptr|operator|override|private|protected|public|register|reinterpret_cast|requires|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|using|virtual|void|volatile|wchar_t|while)\b/,n=/\b(?!)\w+(?:\s*\.\s*\w+)*\b/.source.replace(//g,(function(){return t.source}));e.languages.cpp=e.languages.extend("c",{"class-name":[{pattern:RegExp(/(\b(?:class|concept|enum|struct|typename)\s+)(?!)\w+/.source.replace(//g,(function(){return t.source}))),lookbehind:!0},/\b[A-Z]\w*(?=\s*::\s*\w+\s*\()/,/\b[A-Z_]\w*(?=\s*::\s*~\w+\s*\()/i,/\b\w+(?=\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>\s*::\s*\w+\s*\()/],keyword:t,number:{pattern:/(?:\b0b[01']+|\b0x(?:[\da-f']+(?:\.[\da-f']*)?|\.[\da-f']+)(?:p[+-]?[\d']+)?|(?:\b[\d']+(?:\.[\d']*)?|\B\.[\d']+)(?:e[+-]?[\d']+)?)[ful]{0,4}/i,greedy:!0},operator:/>>=?|<<=?|->|--|\+\+|&&|\|\||[?:~]|<=>|[-+*/%&|^!=<>]=?|\b(?:and|and_eq|bitand|bitor|not|not_eq|or|or_eq|xor|xor_eq)\b/,boolean:/\b(?:false|true)\b/}),e.languages.insertBefore("cpp","string",{module:{pattern:RegExp(/(\b(?:import|module)\s+)/.source+"(?:"+/"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|<[^<>\r\n]*>/.source+"|"+/(?:\s*:\s*)?|:\s*/.source.replace(//g,(function(){return n}))+")"),lookbehind:!0,greedy:!0,inside:{string:/^[<"][\s\S]+/,operator:/:/,punctuation:/\./}},"raw-string":{pattern:/R"([^()\\ ]{0,16})\([\s\S]*?\)\1"/,alias:"string",greedy:!0}}),e.languages.insertBefore("cpp","keyword",{"generic-function":{pattern:/\b(?!operator\b)[a-z_]\w*\s*<(?:[^<>]|<[^<>]*>)*>(?=\s*\()/i,inside:{function:/^\w+/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:e.languages.cpp}}}}),e.languages.insertBefore("cpp","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}}),e.languages.insertBefore("cpp","class-name",{"base-clause":{pattern:/(\b(?:class|struct)\s+\w+\s*:\s*)[^;{}"'\s]+(?:\s+[^;{}"'\s]+)*(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:e.languages.extend("cpp",{})}}),e.languages.insertBefore("inside","double-colon",{"class-name":/\b[a-z_]\w*\b(?!\s*::)/i},e.languages.cpp["base-clause"])}(s),function(e){function t(e,t){return e.replace(/<<(\d+)>>/g,(function(e,n){return"(?:"+t[+n]+")"}))}function n(e,n,a){return RegExp(t(e,n),a||"")}function a(e,t){for(var n=0;n>/g,(function(){return"(?:"+e+")"}));return e.replace(/<>/g,"[^\\s\\S]")}var s="bool byte char decimal double dynamic float int long object sbyte short string uint ulong ushort var void",r="class enum interface record struct",i="add alias and ascending async await by descending from(?=\\s*(?:\\w|$)) get global group into init(?=\\s*;) join let nameof not notnull on or orderby partial remove select set unmanaged value when where with(?=\\s*{)",o="abstract as base break case catch checked const continue default delegate do else event explicit extern finally fixed for foreach goto if implicit in internal is lock namespace new null operator out override params private protected public readonly ref return sealed sizeof stackalloc static switch this throw try typeof unchecked unsafe using virtual volatile while yield";function l(e){return"\\b(?:"+e.trim().replace(/ /g,"|")+")\\b"}var u=l(r),c=RegExp(l(s+" "+r+" "+i+" "+o)),d=l(r+" "+i+" "+o),g=l(s+" "+r+" "+o),p=a(/<(?:[^<>;=+\-*/%&|^]|<>)*>/.source,2),b=a(/\((?:[^()]|<>)*\)/.source,2),h=/@?\b[A-Za-z_]\w*\b/.source,f=t(/<<0>>(?:\s*<<1>>)?/.source,[h,p]),m=t(/(?!<<0>>)<<1>>(?:\s*\.\s*<<1>>)*/.source,[d,f]),y=/\[\s*(?:,\s*)*\]/.source,w=t(/<<0>>(?:\s*(?:\?\s*)?<<1>>)*(?:\s*\?)?/.source,[m,y]),k=t(/[^,()<>[\];=+\-*/%&|^]|<<0>>|<<1>>|<<2>>/.source,[p,b,y]),v=t(/\(<<0>>+(?:,<<0>>+)+\)/.source,[k]),_=t(/(?:<<0>>|<<1>>)(?:\s*(?:\?\s*)?<<2>>)*(?:\s*\?)?/.source,[v,m,y]),x={keyword:c,punctuation:/[<>()?,.:[\]]/},F=/'(?:[^\r\n'\\]|\\.|\\[Uux][\da-fA-F]{1,8})'/.source,A=/"(?:\\.|[^\\"\r\n])*"/.source,S=/@"(?:""|\\[\s\S]|[^\\"])*"(?!")/.source;e.languages.csharp=e.languages.extend("clike",{string:[{pattern:n(/(^|[^$\\])<<0>>/.source,[S]),lookbehind:!0,greedy:!0},{pattern:n(/(^|[^@$\\])<<0>>/.source,[A]),lookbehind:!0,greedy:!0}],"class-name":[{pattern:n(/(\busing\s+static\s+)<<0>>(?=\s*;)/.source,[m]),lookbehind:!0,inside:x},{pattern:n(/(\busing\s+<<0>>\s*=\s*)<<1>>(?=\s*;)/.source,[h,_]),lookbehind:!0,inside:x},{pattern:n(/(\busing\s+)<<0>>(?=\s*=)/.source,[h]),lookbehind:!0},{pattern:n(/(\b<<0>>\s+)<<1>>/.source,[u,f]),lookbehind:!0,inside:x},{pattern:n(/(\bcatch\s*\(\s*)<<0>>/.source,[m]),lookbehind:!0,inside:x},{pattern:n(/(\bwhere\s+)<<0>>/.source,[h]),lookbehind:!0},{pattern:n(/(\b(?:is(?:\s+not)?|as)\s+)<<0>>/.source,[w]),lookbehind:!0,inside:x},{pattern:n(/\b<<0>>(?=\s+(?!<<1>>|with\s*\{)<<2>>(?:\s*[=,;:{)\]]|\s+(?:in|when)\b))/.source,[_,g,h]),inside:x}],keyword:c,number:/(?:\b0(?:x[\da-f_]*[\da-f]|b[01_]*[01])|(?:\B\.\d+(?:_+\d+)*|\b\d+(?:_+\d+)*(?:\.\d+(?:_+\d+)*)?)(?:e[-+]?\d+(?:_+\d+)*)?)(?:[dflmu]|lu|ul)?\b/i,operator:/>>=?|<<=?|[-=]>|([-+&|])\1|~|\?\?=?|[-+*/%&|^!=<>]=?/,punctuation:/\?\.?|::|[{}[\];(),.:]/}),e.languages.insertBefore("csharp","number",{range:{pattern:/\.\./,alias:"operator"}}),e.languages.insertBefore("csharp","punctuation",{"named-parameter":{pattern:n(/([(,]\s*)<<0>>(?=\s*:)/.source,[h]),lookbehind:!0,alias:"punctuation"}}),e.languages.insertBefore("csharp","class-name",{namespace:{pattern:n(/(\b(?:namespace|using)\s+)<<0>>(?:\s*\.\s*<<0>>)*(?=\s*[;{])/.source,[h]),lookbehind:!0,inside:{punctuation:/\./}},"type-expression":{pattern:n(/(\b(?:default|sizeof|typeof)\s*\(\s*(?!\s))(?:[^()\s]|\s(?!\s)|<<0>>)*(?=\s*\))/.source,[b]),lookbehind:!0,alias:"class-name",inside:x},"return-type":{pattern:n(/<<0>>(?=\s+(?:<<1>>\s*(?:=>|[({]|\.\s*this\s*\[)|this\s*\[))/.source,[_,m]),inside:x,alias:"class-name"},"constructor-invocation":{pattern:n(/(\bnew\s+)<<0>>(?=\s*[[({])/.source,[_]),lookbehind:!0,inside:x,alias:"class-name"},"generic-method":{pattern:n(/<<0>>\s*<<1>>(?=\s*\()/.source,[h,p]),inside:{function:n(/^<<0>>/.source,[h]),generic:{pattern:RegExp(p),alias:"class-name",inside:x}}},"type-list":{pattern:n(/\b((?:<<0>>\s+<<1>>|record\s+<<1>>\s*<<5>>|where\s+<<2>>)\s*:\s*)(?:<<3>>|<<4>>|<<1>>\s*<<5>>|<<6>>)(?:\s*,\s*(?:<<3>>|<<4>>|<<6>>))*(?=\s*(?:where|[{;]|=>|$))/.source,[u,f,h,_,c.source,b,/\bnew\s*\(\s*\)/.source]),lookbehind:!0,inside:{"record-arguments":{pattern:n(/(^(?!new\s*\()<<0>>\s*)<<1>>/.source,[f,b]),lookbehind:!0,greedy:!0,inside:e.languages.csharp},keyword:c,"class-name":{pattern:RegExp(_),greedy:!0,inside:x},punctuation:/[,()]/}},preprocessor:{pattern:/(^[\t ]*)#.*/m,lookbehind:!0,alias:"property",inside:{directive:{pattern:/(#)\b(?:define|elif|else|endif|endregion|error|if|line|nullable|pragma|region|undef|warning)\b/,lookbehind:!0,alias:"keyword"}}}});var $=A+"|"+F,z=t(/\/(?![*/])|\/\/[^\r\n]*[\r\n]|\/\*(?:[^*]|\*(?!\/))*\*\/|<<0>>/.source,[$]),E=a(t(/[^"'/()]|<<0>>|\(<>*\)/.source,[z]),2),C=/\b(?:assembly|event|field|method|module|param|property|return|type)\b/.source,j=t(/<<0>>(?:\s*\(<<1>>*\))?/.source,[m,E]);e.languages.insertBefore("csharp","class-name",{attribute:{pattern:n(/((?:^|[^\s\w>)?])\s*\[\s*)(?:<<0>>\s*:\s*)?<<1>>(?:\s*,\s*<<1>>)*(?=\s*\])/.source,[C,j]),lookbehind:!0,greedy:!0,inside:{target:{pattern:n(/^<<0>>(?=\s*:)/.source,[C]),alias:"keyword"},"attribute-arguments":{pattern:n(/\(<<0>>*\)/.source,[E]),inside:e.languages.csharp},"class-name":{pattern:RegExp(m),inside:{punctuation:/\./}},punctuation:/[:,]/}}});var B=/:[^}\r\n]+/.source,T=a(t(/[^"'/()]|<<0>>|\(<>*\)/.source,[z]),2),O=t(/\{(?!\{)(?:(?![}:])<<0>>)*<<1>>?\}/.source,[T,B]),P=a(t(/[^"'/()]|\/(?!\*)|\/\*(?:[^*]|\*(?!\/))*\*\/|<<0>>|\(<>*\)/.source,[$]),2),N=t(/\{(?!\{)(?:(?![}:])<<0>>)*<<1>>?\}/.source,[P,B]);function R(t,a){return{interpolation:{pattern:n(/((?:^|[^{])(?:\{\{)*)<<0>>/.source,[t]),lookbehind:!0,inside:{"format-string":{pattern:n(/(^\{(?:(?![}:])<<0>>)*)<<1>>(?=\}$)/.source,[a,B]),lookbehind:!0,inside:{punctuation:/^:/}},punctuation:/^\{|\}$/,expression:{pattern:/[\s\S]+/,alias:"language-csharp",inside:e.languages.csharp}}},string:/[\s\S]+/}}e.languages.insertBefore("csharp","string",{"interpolation-string":[{pattern:n(/(^|[^\\])(?:\$@|@\$)"(?:""|\\[\s\S]|\{\{|<<0>>|[^\\{"])*"/.source,[O]),lookbehind:!0,greedy:!0,inside:R(O,T)},{pattern:n(/(^|[^@\\])\$"(?:\\.|\{\{|<<0>>|[^\\"{])*"/.source,[N]),lookbehind:!0,greedy:!0,inside:R(N,P)}],char:{pattern:RegExp(F),greedy:!0}}),e.languages.dotnet=e.languages.cs=e.languages.csharp}(s),function(e){var t=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:"+/[^;{\s"']|\s+(?!\s)/.source+"|"+t.source+")*?"+/(?:;|(?=\s*\{))/.source),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+t.source+"|"+/(?:[^\\\r\n()"']|\\[\s\S])*/.source+")\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+t.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+t.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:t,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css;var n=e.languages.markup;n&&(n.tag.addInlined("style","css"),n.tag.addAttribute("style","css"))}(s),function(e){var t=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\s*[(){}[\]<>=%~.:,;?+\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,n=/(?:[a-z]\w*\s*\.\s*)*(?:[A-Z]\w*\s*\.\s*)*/.source,a={pattern:RegExp(/(^|[^\w.])/.source+n+/[A-Z](?:[\d_A-Z]*[a-z]\w*)?\b/.source),lookbehind:!0,inside:{namespace:{pattern:/^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/,inside:{punctuation:/\./}},punctuation:/\./}};e.languages.java=e.languages.extend("clike",{string:{pattern:/(^|[^\\])"(?:\\.|[^"\\\r\n])*"/,lookbehind:!0,greedy:!0},"class-name":[a,{pattern:RegExp(/(^|[^\w.])/.source+n+/[A-Z]\w*(?=\s+\w+\s*[;,=()]|\s*(?:\[[\s,]*\]\s*)?::\s*new\b)/.source),lookbehind:!0,inside:a.inside},{pattern:RegExp(/(\b(?:class|enum|extends|implements|instanceof|interface|new|record|throws)\s+)/.source+n+/[A-Z]\w*\b/.source),lookbehind:!0,inside:a.inside}],keyword:t,function:[e.languages.clike.function,{pattern:/(::\s*)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x(?:\.[\da-f_p+-]+|[\da-f_]+(?:\.[\da-f_p+-]+)?)\b|(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0},constant:/\b[A-Z][A-Z_\d]+\b/}),e.languages.insertBefore("java","string",{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:"string"},char:{pattern:/'(?:\\.|[^'\\\r\n]){1,6}'/,greedy:!0}}),e.languages.insertBefore("java","class-name",{annotation:{pattern:/(^|[^.])@\w+(?:\s*\.\s*\w+)*/,lookbehind:!0,alias:"punctuation"},generics:{pattern:/<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&))*>)*>)*>)*>/,inside:{"class-name":a,keyword:t,punctuation:/[<>(),.:]/,operator:/[?&|]/}},import:[{pattern:RegExp(/(\bimport\s+)/.source+n+/(?:[A-Z]\w*|\*)(?=\s*;)/.source),lookbehind:!0,inside:{namespace:a.inside.namespace,punctuation:/\./,operator:/\*/,"class-name":/\w+/}},{pattern:RegExp(/(\bimport\s+static\s+)/.source+n+/(?:\w+|\*)(?=\s*;)/.source),lookbehind:!0,alias:"static",inside:{namespace:a.inside.namespace,static:/\b\w+$/,punctuation:/\./,operator:/\*/,"class-name":/\w+/}}],namespace:{pattern:RegExp(/(\b(?:exports|import(?:\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\s+)(?!)[a-z]\w*(?:\.[a-z]\w*)*\.?/.source.replace(//g,(function(){return t.source}))),lookbehind:!0,inside:{punctuation:/\./}}})}(s),s.languages.javascript=s.languages.extend("clike",{"class-name":[s.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp(/(^|[^\w$])/.source+"(?:"+/NaN|Infinity/.source+"|"+/0[bB][01]+(?:_[01]+)*n?/.source+"|"+/0[oO][0-7]+(?:_[0-7]+)*n?/.source+"|"+/0[xX][\dA-Fa-f]+(?:_[\dA-Fa-f]+)*n?/.source+"|"+/\d+(?:_\d+)*n/.source+"|"+/(?:\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\.\d+(?:_\d+)*)(?:[Ee][+-]?\d+(?:_\d+)*)?/.source+")"+/(?![\w$])/.source),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),s.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,s.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp(/((?:^|[^$\w\xA0-\uFFFF."'\])\s]|\b(?:return|yield))\s*)/.source+/\//.source+"(?:"+/(?:\[(?:[^\]\\\r\n]|\\.)*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}/.source+"|"+/(?:\[(?:[^[\]\\\r\n]|\\.|\[(?:[^[\]\\\r\n]|\\.|\[(?:[^[\]\\\r\n]|\\.)*\])*\])*\]|\\.|[^/\\\[\r\n])+\/[dgimyus]{0,7}v[dgimyus]{0,7}/.source+")"+/(?=(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:$|[\r\n,.;:})\]]|\/\/))/.source),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:s.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:s.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:s.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:s.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:s.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),s.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:s.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),s.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),s.languages.markup&&(s.languages.markup.tag.addInlined("script","javascript"),s.languages.markup.tag.addAttribute(/on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)/.source,"javascript")),s.languages.js=s.languages.javascript,s.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},s.languages.markup.tag.inside["attr-value"].inside.entity=s.languages.markup.entity,s.languages.markup.doctype.inside["internal-subset"].inside=s.languages.markup,s.hooks.add("wrap",(function(e){"entity"===e.type&&(e.attributes.title=e.content.replace(/&/,"&"))})),Object.defineProperty(s.languages.markup.tag,"addInlined",{value:function(e,t){var n={};n["language-"+t]={pattern:/(^$)/i,lookbehind:!0,inside:s.languages[t]},n.cdata=/^$/i;var a={"included-cdata":{pattern://i,inside:n}};a["language-"+t]={pattern:/[\s\S]+/,inside:s.languages[t]};var r={};r[e]={pattern:RegExp(/(<__[^>]*>)(?:))*\]\]>|(?!)/.source.replace(/__/g,(function(){return e})),"i"),lookbehind:!0,greedy:!0,inside:a},s.languages.insertBefore("markup","cdata",r)}}),Object.defineProperty(s.languages.markup.tag,"addAttribute",{value:function(e,t){s.languages.markup.tag.inside["special-attr"].push({pattern:RegExp(/(^|["'\s])/.source+"(?:"+e+")"+/\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))/.source,"i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[t,"language-"+t],inside:s.languages[t]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),s.languages.html=s.languages.markup,s.languages.mathml=s.languages.markup,s.languages.svg=s.languages.markup,s.languages.xml=s.languages.extend("markup",{}),s.languages.ssml=s.languages.xml,s.languages.atom=s.languages.xml,s.languages.rss=s.languages.xml,function(e){var t=/\/\*[\s\S]*?\*\/|\/\/.*|#(?!\[).*/,n=[{pattern:/\b(?:false|true)\b/i,alias:"boolean"},{pattern:/(::\s*)\b[a-z_]\w*\b(?!\s*\()/i,greedy:!0,lookbehind:!0},{pattern:/(\b(?:case|const)\s+)\b[a-z_]\w*(?=\s*[;=])/i,greedy:!0,lookbehind:!0},/\b(?:null)\b/i,/\b[A-Z_][A-Z0-9_]*\b(?!\s*\()/],a=/\b0b[01]+(?:_[01]+)*\b|\b0o[0-7]+(?:_[0-7]+)*\b|\b0x[\da-f]+(?:_[\da-f]+)*\b|(?:\b\d+(?:_\d+)*\.?(?:\d+(?:_\d+)*)?|\B\.\d+)(?:e[+-]?\d+)?/i,s=/|\?\?=?|\.{3}|\??->|[!=]=?=?|::|\*\*=?|--|\+\+|&&|\|\||<<|>>|[?~]|[/^|%*&<>.+-]=?/,r=/[{}\[\](),:;]/;e.languages.php={delimiter:{pattern:/\?>$|^<\?(?:php(?=\s)|=)?/i,alias:"important"},comment:t,variable:/\$+(?:\w+\b|(?=\{))/,package:{pattern:/(namespace\s+|use\s+(?:function\s+)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,lookbehind:!0,inside:{punctuation:/\\/}},"class-name-definition":{pattern:/(\b(?:class|enum|interface|trait)\s+)\b[a-z_]\w*(?!\\)\b/i,lookbehind:!0,alias:"class-name"},"function-definition":{pattern:/(\bfunction\s+)[a-z_]\w*(?=\s*\()/i,lookbehind:!0,alias:"function"},keyword:[{pattern:/(\(\s*)\b(?:array|bool|boolean|float|int|integer|object|string)\b(?=\s*\))/i,alias:"type-casting",greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|object|self|static|string)\b(?=\s*\$)/i,alias:"type-hint",greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|never|object|self|static|string|void)\b/i,alias:"return-type",greedy:!0,lookbehind:!0},{pattern:/\b(?:array(?!\s*\()|bool|float|int|iterable|mixed|object|string|void)\b/i,alias:"type-declaration",greedy:!0},{pattern:/(\|\s*)(?:false|null)\b|\b(?:false|null)(?=\s*\|)/i,alias:"type-declaration",greedy:!0,lookbehind:!0},{pattern:/\b(?:parent|self|static)(?=\s*::)/i,alias:"static-context",greedy:!0},{pattern:/(\byield\s+)from\b/i,lookbehind:!0},/\bclass\b/i,{pattern:/((?:^|[^\s>:]|(?:^|[^-])>|(?:^|[^:]):)\s*)\b(?:abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|never|new|or|parent|print|private|protected|public|readonly|require|require_once|return|self|static|switch|throw|trait|try|unset|use|var|while|xor|yield|__halt_compiler)\b/i,lookbehind:!0}],"argument-name":{pattern:/([(,]\s*)\b[a-z_]\w*(?=\s*:(?!:))/i,lookbehind:!0},"class-name":[{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self|\s+static))\s+|\bcatch\s*\()\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/(\|\s*)\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/\b[a-z_]\w*(?!\\)\b(?=\s*\|)/i,greedy:!0},{pattern:/(\|\s*)(?:\\?\b[a-z_]\w*)+\b/i,alias:"class-name-fully-qualified",greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(?:\\?\b[a-z_]\w*)+\b(?=\s*\|)/i,alias:"class-name-fully-qualified",greedy:!0,inside:{punctuation:/\\/}},{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self\b|\s+static\b))\s+|\bcatch\s*\()(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:"class-name-fully-qualified",greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*\$)/i,alias:"type-declaration",greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:["class-name-fully-qualified","type-declaration"],greedy:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*::)/i,alias:"static-context",greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*::)/i,alias:["class-name-fully-qualified","static-context"],greedy:!0,inside:{punctuation:/\\/}},{pattern:/([(,?]\s*)[a-z_]\w*(?=\s*\$)/i,alias:"type-hint",greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:["class-name-fully-qualified","type-hint"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b[a-z_]\w*(?!\\)\b/i,alias:"return-type",greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:["class-name-fully-qualified","return-type"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:n,function:{pattern:/(^|[^\\\w])\\?[a-z_](?:[\w\\]*\w)?(?=\s*\()/i,lookbehind:!0,inside:{punctuation:/\\/}},property:{pattern:/(->\s*)\w+/,lookbehind:!0},number:a,operator:s,punctuation:r};var i={pattern:/\{\$(?:\{(?:\{[^{}]+\}|[^{}]+)\}|[^{}])+\}|(^|[^\\{])\$+(?:\w+(?:\[[^\r\n\[\]]+\]|->\w+)?)/,lookbehind:!0,inside:e.languages.php},o=[{pattern:/<<<'([^']+)'[\r\n](?:.*[\r\n])*?\1;/,alias:"nowdoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<<'[^']+'|[a-z_]\w*;$/i,alias:"symbol",inside:{punctuation:/^<<<'?|[';]$/}}}},{pattern:/<<<(?:"([^"]+)"[\r\n](?:.*[\r\n])*?\1;|([a-z_]\w*)[\r\n](?:.*[\r\n])*?\2;)/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<<(?:"[^"]+"|[a-z_]\w*)|[a-z_]\w*;$/i,alias:"symbol",inside:{punctuation:/^<<<"?|[";]$/}},interpolation:i}},{pattern:/`(?:\\[\s\S]|[^\\`])*`/,alias:"backtick-quoted-string",greedy:!0},{pattern:/'(?:\\[\s\S]|[^\\'])*'/,alias:"single-quoted-string",greedy:!0},{pattern:/"(?:\\[\s\S]|[^\\"])*"/,alias:"double-quoted-string",greedy:!0,inside:{interpolation:i}}];e.languages.insertBefore("php","variable",{string:o,attribute:{pattern:/#\[(?:[^"'\/#]|\/(?![*/])|\/\/.*$|#(?!\[).*$|\/\*(?:[^*]|\*(?!\/))*\*\/|"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*')+\](?=\s*[a-z$#])/im,greedy:!0,inside:{"attribute-content":{pattern:/^(#\[)[\s\S]+(?=\]$)/,lookbehind:!0,inside:{comment:t,string:o,"attribute-class-name":[{pattern:/([^:]|^)\b[a-z_]\w*(?!\\)\b/i,alias:"class-name",greedy:!0,lookbehind:!0},{pattern:/([^:]|^)(?:\\?\b[a-z_]\w*)+/i,alias:["class-name","class-name-fully-qualified"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:n,number:a,operator:s,punctuation:r}},delimiter:{pattern:/^#\[|\]$/,alias:"punctuation"}}}}),e.hooks.add("before-tokenize",(function(t){/<\?/.test(t.code)&&e.languages["markup-templating"].buildPlaceholders(t,"php",/<\?(?:[^"'/#]|\/(?![*/])|("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|(?:\/\/|#(?!\[))(?:[^?\n\r]|\?(?!>))*(?=$|\?>|[\r\n])|#\[|\/\*(?:[^*]|\*(?!\/))*(?:\*\/|$))*?(?:\?>|$)/g)})),e.hooks.add("after-tokenize",(function(t){e.languages["markup-templating"].tokenizePlaceholders(t,"php")}))}(s),s.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:"string"},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:["annotation","punctuation"],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},s.languages.python["string-interpolation"].inside.interpolation.inside.rest=s.languages.python,s.languages.py=s.languages.python,function(e){e.languages.ruby=e.languages.extend("clike",{comment:{pattern:/#.*|^=begin\s[\s\S]*?^=end/m,greedy:!0},"class-name":{pattern:/(\b(?:class|module)\s+|\bcatch\s+\()[\w.\\]+|\b[A-Z_]\w*(?=\s*\.\s*new\b)/,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:BEGIN|END|alias|and|begin|break|case|class|def|define_method|defined|do|each|else|elsif|end|ensure|extend|for|if|in|include|module|new|next|nil|not|or|prepend|private|protected|public|raise|redo|require|rescue|retry|return|self|super|then|throw|undef|unless|until|when|while|yield)\b/,operator:/\.{2,3}|&\.|===||[!=]?~|(?:&&|\|\||<<|>>|\*\*|[+\-*/%<>!^&|=])=?|[?:]/,punctuation:/[(){}[\].,;]/}),e.languages.insertBefore("ruby","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}});var t={pattern:/((?:^|[^\\])(?:\\{2})*)#\{(?:[^{}]|\{[^{}]*\})*\}/,lookbehind:!0,inside:{content:{pattern:/^(#\{)[\s\S]+(?=\}$)/,lookbehind:!0,inside:e.languages.ruby},delimiter:{pattern:/^#\{|\}$/,alias:"punctuation"}}};delete e.languages.ruby.function;var n="(?:"+[/([^a-zA-Z0-9\s{(\[<=])(?:(?!\1)[^\\]|\\[\s\S])*\1/.source,/\((?:[^()\\]|\\[\s\S]|\((?:[^()\\]|\\[\s\S])*\))*\)/.source,/\{(?:[^{}\\]|\\[\s\S]|\{(?:[^{}\\]|\\[\s\S])*\})*\}/.source,/\[(?:[^\[\]\\]|\\[\s\S]|\[(?:[^\[\]\\]|\\[\s\S])*\])*\]/.source,/<(?:[^<>\\]|\\[\s\S]|<(?:[^<>\\]|\\[\s\S])*>)*>/.source].join("|")+")",a=/(?:"(?:\\.|[^"\\\r\n])*"|(?:\b[a-zA-Z_]\w*|[^\s\0-\x7F]+)[?!]?|\$.)/.source;e.languages.insertBefore("ruby","keyword",{"regex-literal":[{pattern:RegExp(/%r/.source+n+/[egimnosux]{0,6}/.source),greedy:!0,inside:{interpolation:t,regex:/[\s\S]+/}},{pattern:/(^|[^/])\/(?!\/)(?:\[[^\r\n\]]+\]|\\.|[^[/\\\r\n])+\/[egimnosux]{0,6}(?=\s*(?:$|[\r\n,.;})#]))/,lookbehind:!0,greedy:!0,inside:{interpolation:t,regex:/[\s\S]+/}}],variable:/[@$]+[a-zA-Z_]\w*(?:[?!]|\b)/,symbol:[{pattern:RegExp(/(^|[^:]):/.source+a),lookbehind:!0,greedy:!0},{pattern:RegExp(/([\r\n{(,][ \t]*)/.source+a+/(?=:(?!:))/.source),lookbehind:!0,greedy:!0}],"method-definition":{pattern:/(\bdef\s+)\w+(?:\s*\.\s*\w+)?/,lookbehind:!0,inside:{function:/\b\w+$/,keyword:/^self\b/,"class-name":/^\w+/,punctuation:/\./}}}),e.languages.insertBefore("ruby","string",{"string-literal":[{pattern:RegExp(/%[qQiIwWs]?/.source+n),greedy:!0,inside:{interpolation:t,string:/[\s\S]+/}},{pattern:/("|')(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|(?!\1)[^\\#\r\n])*\1/,greedy:!0,inside:{interpolation:t,string:/[\s\S]+/}},{pattern:/<<[-~]?([a-z_]\w*)[\r\n](?:.*[\r\n])*?[\t ]*\1/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<[-~]?[a-z_]\w*|\b[a-z_]\w*$/i,inside:{symbol:/\b\w+/,punctuation:/^<<[-~]?/}},interpolation:t,string:/[\s\S]+/}},{pattern:/<<[-~]?'([a-z_]\w*)'[\r\n](?:.*[\r\n])*?[\t ]*\1/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<[-~]?'[a-z_]\w*'|\b[a-z_]\w*$/i,inside:{symbol:/\b\w+/,punctuation:/^<<[-~]?'|'$/}},string:/[\s\S]+/}}],"command-literal":[{pattern:RegExp(/%x/.source+n),greedy:!0,inside:{interpolation:t,command:{pattern:/[\s\S]+/,alias:"string"}}},{pattern:/`(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|[^\\`#\r\n])*`/,greedy:!0,inside:{interpolation:t,command:{pattern:/[\s\S]+/,alias:"string"}}}]}),delete e.languages.ruby.string,e.languages.insertBefore("ruby","number",{builtin:/\b(?:Array|Bignum|Binding|Class|Continuation|Dir|Exception|FalseClass|File|Fixnum|Float|Hash|IO|Integer|MatchData|Method|Module|NilClass|Numeric|Object|Proc|Range|Regexp|Stat|String|Struct|Symbol|TMS|Thread|ThreadGroup|Time|TrueClass)\b/,constant:/\b[A-Z][A-Z0-9_]*(?:[?!]|\b)/}),e.languages.rb=e.languages.ruby}(s),window.Prism=a,s}(),o=e=>t=>t.options.get(e),l=o("codesample_languages"),u=o("codesample_global_prismjs"),c=e=>r.Prism&&u(e)?r.Prism:i,d=e=>t(e)&&"PRE"===e.nodeName&&-1!==e.className.indexOf("language-"),g=e=>{const t=e.selection?e.selection.getNode():null;return d(t)?a.some(t):a.none()},p=e=>{const t=(e=>l(e)||[{text:"HTML/XML",value:"markup"},{text:"JavaScript",value:"javascript"},{text:"CSS",value:"css"},{text:"PHP",value:"php"},{text:"Ruby",value:"ruby"},{text:"Python",value:"python"},{text:"Java",value:"java"},{text:"C",value:"c"},{text:"C#",value:"csharp"},{text:"C++",value:"cpp"}])(e),n=(r=t,((e,t)=>0""),(e=>e.value));var r;const i=((e,t)=>g(e).fold((()=>t),(e=>{const n=e.className.match(/language-(\w+)/);return n?n[1]:t})))(e,n),o=(e=>g(e).bind((e=>a.from(e.textContent))).getOr(""))(e);e.windowManager.open({title:"Insert/Edit Code Sample",size:"large",body:{type:"panel",items:[{type:"listbox",name:"language",label:"Language",items:t},{type:"textarea",name:"code",label:"Code view"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{language:i,code:o},onSubmit:t=>{const n=t.getData();((e,t,n)=>{const a=e.dom;e.undoManager.transact((()=>{const r=g(e);return n=s.DOM.encode(n),r.fold((()=>{e.insertContent('
                                  '+n+"
                                  ");const s=a.select("#__new")[0];a.setAttrib(s,"id",null),e.selection.select(s)}),(s=>{a.setAttrib(s,"class","language-"+t),s.innerHTML=n,c(e).highlightElement(s),e.selection.select(s)}))}))})(e,n.language,n.code),t.close()}})},b=(h=/^\s+|\s+$/g,e=>e.replace(h,""));var h,f=tinymce.util.Tools.resolve("tinymce.util.Tools");const m=(e,t=n)=>n=>{const a=()=>{n.setEnabled(e.selection.isEditable()),t(n)};return e.on("NodeChange",a),a(),()=>{e.off("NodeChange",a)}};e.add("codesample",(e=>{(e=>{const t=e.options.register;t("codesample_languages",{processor:"object[]"}),t("codesample_global_prismjs",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreProcess",(t=>{const n=e.dom,a=n.select("pre[contenteditable=false]",t.node);f.each(f.grep(a,d),(e=>{const t=e.textContent;let a;for(n.setAttrib(e,"class",b(n.getAttrib(e,"class"))),n.setAttrib(e,"contentEditable",null),n.setAttrib(e,"data-mce-highlighted",null);a=e.firstChild;)e.removeChild(a);n.add(e,"code").textContent=t}))})),e.on("SetContent",(()=>{const t=e.dom,n=f.grep(t.select("pre"),(e=>d(e)&&"true"!==t.getAttrib(e,"data-mce-highlighted")));n.length&&e.undoManager.transact((()=>{f.each(n,(n=>{var a;f.each(t.select("br",n),(n=>{t.replace(e.getDoc().createTextNode("\n"),n)})),n.innerHTML=t.encode(null!==(a=n.textContent)&&void 0!==a?a:""),c(e).highlightElement(n),t.setAttrib(n,"data-mce-highlighted",!0),n.className=b(n.className)}))}))})),e.on("PreInit",(()=>{e.parser.addNodeFilter("pre",(e=>{var t;for(let n=0,a=e.length;n{const t=()=>e.execCommand("codesample");e.ui.registry.addToggleButton("codesample",{icon:"code-sample",tooltip:"Insert/edit code sample",onAction:t,onSetup:m(e,(t=>{t.setActive((e=>{const t=e.selection.getStart();return e.dom.is(t,'pre[class*="language-"]')})(e))}))}),e.ui.registry.addMenuItem("codesample",{text:"Code sample...",icon:"code-sample",onAction:t,onSetup:m(e)})})(e),(e=>{e.addCommand("codesample",(()=>{const t=e.selection.getNode();e.selection.isCollapsed()||d(t)?p(e):e.formatter.toggle("code")}))})(e),e.on("dblclick",(t=>{d(t.target)&&p(e)}))}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/directionality/plugin.min.js b/public/libs/tinymce/plugins/directionality/plugin.min.js index c91d2bb2787..aa497696281 100644 --- a/public/libs/tinymce/plugins/directionality/plugin.min.js +++ b/public/libs/tinymce/plugins/directionality/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>e=>typeof e===t,o=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(o=r=t,(n=String).prototype.isPrototypeOf(o)||(null===(i=r.constructor)||void 0===i?void 0:i.name)===n.name)?"string":e;var o,r,n,i})(t),r=e("boolean"),n=t=>!(t=>null==t)(t),i=e("function"),s=e("number"),l=(!1,()=>false);class a{constructor(t,e){this.tag=t,this.value=e}static some(t){return new a(!0,t)}static none(){return a.singletonNone}fold(t,e){return this.tag?e(this.value):t()}isSome(){return this.tag}isNone(){return!this.tag}map(t){return this.tag?a.some(t(this.value)):a.none()}bind(t){return this.tag?t(this.value):a.none()}exists(t){return this.tag&&t(this.value)}forall(t){return!this.tag||t(this.value)}filter(t){return!this.tag||t(this.value)?this:a.none()}getOr(t){return this.tag?this.value:t}or(t){return this.tag?this:t}getOrThunk(t){return this.tag?this.value:t()}orThunk(t){return this.tag?this:t()}getOrDie(t){if(this.tag)return this.value;throw new Error(null!=t?t:"Called getOrDie on None")}static from(t){return n(t)?a.some(t):a.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(t){this.tag&&t(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}a.singletonNone=new a(!1);const u=(t,e)=>{for(let o=0,r=t.length;o{if(null==t)throw new Error("Node cannot be null or undefined");return{dom:t}},d=c,h=(t,e)=>{const o=t.dom;if(1!==o.nodeType)return!1;{const t=o;if(void 0!==t.matches)return t.matches(e);if(void 0!==t.msMatchesSelector)return t.msMatchesSelector(e);if(void 0!==t.webkitMatchesSelector)return t.webkitMatchesSelector(e);if(void 0!==t.mozMatchesSelector)return t.mozMatchesSelector(e);throw new Error("Browser lacks native selectors")}};"undefined"!=typeof window?window:Function("return this;")();const m=t=>e=>(t=>t.dom.nodeType)(e)===t,g=m(1),f=m(3),v=m(9),y=m(11),p=(t,e)=>{t.dom.removeAttribute(e)},w=i(Element.prototype.attachShadow)&&i(Node.prototype.getRootNode)?t=>d(t.dom.getRootNode()):t=>v(t)?t:d(t.dom.ownerDocument),b=t=>d(t.dom.host),N=t=>{const e=f(t)?t.dom.parentNode:t.dom;if(null==e||null===e.ownerDocument)return!1;const o=e.ownerDocument;return(t=>{const e=w(t);return y(o=e)&&n(o.dom.host)?a.some(e):a.none();var o})(d(e)).fold((()=>o.body.contains(e)),(r=N,i=b,t=>r(i(t))));var r,i},S=t=>"rtl"===((t,e)=>{const o=t.dom,r=window.getComputedStyle(o).getPropertyValue(e);return""!==r||N(t)?r:((t,e)=>(t=>void 0!==t.style&&i(t.style.getPropertyValue))(t)?t.style.getPropertyValue(e):"")(o,e)})(t,"direction")?"rtl":"ltr",A=(t,e)=>((t,o)=>((t,e)=>{const o=[];for(let r=0,n=t.length;r{const o=t.length,r=new Array(o);for(let n=0;nh(t,e))))(t),E=("li",t=>g(t)&&"li"===t.dom.nodeName.toLowerCase());const T=(t,e,n)=>{u(e,(e=>{const c=d(e),m=E(c),f=((t,e)=>{return(e?(o=t,r="ol,ul",((t,e,o)=>{let n=t.dom;const s=i(o)?o:l;for(;n.parentNode;){n=n.parentNode;const t=d(n);if(h(t,r))return a.some(t);if(s(t))break}return a.none()})(o,0,n)):a.some(t)).getOr(t);var o,r,n})(c,m);var v;(v=f,(t=>a.from(t.dom.parentNode).map(d))(v).filter(g)).each((e=>{if(t.setStyle(f.dom,"direction",null),S(e)===n?p(f,"dir"):((t,e,n)=>{((t,e,n)=>{if(!(o(n)||r(n)||s(n)))throw console.error("Invalid call to Attribute.set. Key ",e,":: Value ",n,":: Element ",t),new Error("Attribute value was not simple");t.setAttribute(e,n+"")})(t.dom,e,n)})(f,"dir",n),S(f)!==n&&t.setStyle(f.dom,"direction",n),m){const e=A(f,"li[dir],li[style]");u(e,(e=>{p(e,"dir"),t.setStyle(e.dom,"direction",null)}))}}))}))},C=(t,e)=>{t.selection.isEditable()&&(T(t.dom,t.selection.getSelectedBlocks(),e),t.nodeChanged())},D=(t,e)=>o=>{const r=r=>{const n=d(r.element);o.setActive(S(n)===e),o.setEnabled(t.selection.isEditable())};return t.on("NodeChange",r),o.setEnabled(t.selection.isEditable()),()=>t.off("NodeChange",r)};t.add("directionality",(t=>{(t=>{t.addCommand("mceDirectionLTR",(()=>{C(t,"ltr")})),t.addCommand("mceDirectionRTL",(()=>{C(t,"rtl")}))})(t),(t=>{t.ui.registry.addToggleButton("ltr",{tooltip:"Left to right",icon:"ltr",onAction:()=>t.execCommand("mceDirectionLTR"),onSetup:D(t,"ltr")}),t.ui.registry.addToggleButton("rtl",{tooltip:"Right to left",icon:"rtl",onAction:()=>t.execCommand("mceDirectionRTL"),onSetup:D(t,"rtl")})})(t)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/fullscreen/plugin.min.js b/public/libs/tinymce/plugins/fullscreen/plugin.min.js index c4d5cfc997e..9032e46bb46 100644 --- a/public/libs/tinymce/plugins/fullscreen/plugin.min.js +++ b/public/libs/tinymce/plugins/fullscreen/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";const e=e=>{let t=e;return{get:()=>t,set:e=>{t=e}}};var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const n=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=r=e,(o=String).prototype.isPrototypeOf(n)||(null===(s=r.constructor)||void 0===s?void 0:s.name)===o.name)?"string":t;var n,r,o,s})(t)===e,r=e=>t=>typeof t===e,o=e=>t=>e===t,s=n("string"),i=n("object"),l=n("array"),a=o(null),c=r("boolean"),u=o(void 0),d=e=>!(e=>null==e)(e),m=r("function"),h=r("number"),g=()=>{},p=e=>()=>e;function f(e,...t){return(...n)=>{const r=t.concat(n);return e.apply(null,r)}}const v=p(!1),w=p(!0);class b{constructor(e,t){this.tag=e,this.value=t}static some(e){return new b(!0,e)}static none(){return b.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?b.some(e(this.value)):b.none()}bind(e){return this.tag?e(this.value):b.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:b.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return d(e)?b.some(e):b.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}b.singletonNone=new b(!1);const y=Array.prototype.push,S=(e,t)=>{const n=e.length,r=new Array(n);for(let o=0;o{for(let n=0,r=e.length;n{const n=[];for(let r=0,o=e.length;r((e,t,n)=>{for(let r=0,o=e.length;r{const n=e(b.none()),r=()=>n.get().each(t);return{clear:()=>{r(),n.set(b.none())},isSet:()=>n.get().isSome(),get:()=>n.get(),set:e=>{r(),n.set(b.some(e))}}},k=()=>O((e=>e.unbind())),T=Object.keys,C="undefined"!=typeof window?window:Function("return this;")(),A=(e,t)=>((e,t)=>{let n=null!=t?t:C;for(let t=0;t{const t=A("ownerDocument.defaultView",e);return i(e)&&((e=>((e,t)=>{const n=((e,t)=>A(e,t))(e,t);if(null==n)throw new Error(e+" not available on this browser");return n})("HTMLElement",e))(t).prototype.isPrototypeOf(e)||/^HTML\w*Element$/.test(R(e).constructor.name))},M=e=>t=>(e=>e.dom.nodeType)(t)===e,P=M(1),D=M(3),N=M(9),H=M(11),W=(e,t)=>{const n=e.dom.getAttribute(t);return null===n?void 0:n},q=(e,t)=>{e.dom.removeAttribute(t)},I=(e,t,n=0,r)=>{const o=e.indexOf(t,n);return-1!==o&&(!!u(r)||o+t.length<=r)},V=e=>void 0!==e.style&&m(e.style.getPropertyValue),j=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},B=j,_=(e,t)=>{const n=e.dom;if(1!==n.nodeType)return!1;{const e=n;if(void 0!==e.matches)return e.matches(t);if(void 0!==e.msMatchesSelector)return e.msMatchesSelector(t);if(void 0!==e.webkitMatchesSelector)return e.webkitMatchesSelector(t);if(void 0!==e.mozMatchesSelector)return e.mozMatchesSelector(t);throw new Error("Browser lacks native selectors")}},z=e=>B(e.dom.ownerDocument),$=e=>S(e.dom.childNodes,B),U=m(Element.prototype.attachShadow)&&m(Node.prototype.getRootNode),K=p(U),X=U?e=>B(e.dom.getRootNode()):e=>N(e)?e:z(e),Y=e=>{const t=X(e);return H(n=t)&&d(n.dom.host)?b.some(t):b.none();var n},G=e=>B(e.dom.host),J=e=>{const t=D(e)?e.dom.parentNode:e.dom;if(null==t||null===t.ownerDocument)return!1;const n=t.ownerDocument;return Y(B(t)).fold((()=>n.body.contains(t)),(r=J,o=G,e=>r(o(e))));var r,o},Q=(e,t,n)=>{if(!s(n))throw console.error("Invalid call to CSS.set. Property ",t,":: Value ",n,":: Element ",e),new Error("CSS value must be a string: "+n);V(e)&&e.style.setProperty(t,n)},Z=(e,t,n)=>{const r=e.dom;Q(r,t,n)},ee=(e,t)=>{const n=e.dom;((e,t)=>{const n=T(e);for(let r=0,o=n.length;r{Q(n,t,e)}))},te=(e,t)=>{const n=e.dom,r=window.getComputedStyle(n).getPropertyValue(t);return""!==r||J(e)?r:ne(n,t)},ne=(e,t)=>V(e)?e.style.getPropertyValue(t):"",re=e=>{const t=B((e=>{if(K()&&d(e.target)){const t=B(e.target);if(P(t)&&d(t.dom.shadowRoot)&&e.composed&&e.composedPath){const t=e.composedPath();if(t)return((e,t)=>0e.stopPropagation(),r=()=>e.preventDefault(),o=(s=r,i=n,(...e)=>s(i.apply(null,e)));var s,i;return((e,t,n,r,o,s,i)=>({target:e,x:t,y:n,stop:r,prevent:o,kill:s,raw:i}))(t,e.clientX,e.clientY,n,r,o,e)},oe=(e,t,n,r)=>{e.dom.removeEventListener(t,n,r)},se=w,ie=(e,t,n)=>((e,t,n,r)=>((e,t,n,r,o)=>{const s=((e,t)=>n=>{e(n)&&t(re(n))})(n,r);return e.dom.addEventListener(t,s,o),{unbind:f(oe,e,t,s,o)}})(e,t,n,r,!1))(e,t,se,n),le=()=>ae(0,0),ae=(e,t)=>({major:e,minor:t}),ce={nu:ae,detect:(e,t)=>{const n=String(t).toLowerCase();return 0===e.length?le():((e,t)=>{const n=((e,t)=>{for(let n=0;nNumber(t.replace(n,"$"+e));return ae(r(1),r(2))})(e,n)},unknown:le},ue=(e,t)=>{const n=String(t).toLowerCase();return F(e,(e=>e.search(n)))},de=/.*?version\/\ ?([0-9]+)\.([0-9]+).*/,me=e=>t=>I(t,e),he=[{name:"Edge",versionRegexes:[/.*?edge\/ ?([0-9]+)\.([0-9]+)$/],search:e=>I(e,"edge/")&&I(e,"chrome")&&I(e,"safari")&&I(e,"applewebkit")},{name:"Chromium",brand:"Chromium",versionRegexes:[/.*?chrome\/([0-9]+)\.([0-9]+).*/,de],search:e=>I(e,"chrome")&&!I(e,"chromeframe")},{name:"IE",versionRegexes:[/.*?msie\ ?([0-9]+)\.([0-9]+).*/,/.*?rv:([0-9]+)\.([0-9]+).*/],search:e=>I(e,"msie")||I(e,"trident")},{name:"Opera",versionRegexes:[de,/.*?opera\/([0-9]+)\.([0-9]+).*/],search:me("opera")},{name:"Firefox",versionRegexes:[/.*?firefox\/\ ?([0-9]+)\.([0-9]+).*/],search:me("firefox")},{name:"Safari",versionRegexes:[de,/.*?cpu os ([0-9]+)_([0-9]+).*/],search:e=>(I(e,"safari")||I(e,"mobile/"))&&I(e,"applewebkit")}],ge=[{name:"Windows",search:me("win"),versionRegexes:[/.*?windows\ nt\ ?([0-9]+)\.([0-9]+).*/]},{name:"iOS",search:e=>I(e,"iphone")||I(e,"ipad"),versionRegexes:[/.*?version\/\ ?([0-9]+)\.([0-9]+).*/,/.*cpu os ([0-9]+)_([0-9]+).*/,/.*cpu iphone os ([0-9]+)_([0-9]+).*/]},{name:"Android",search:me("android"),versionRegexes:[/.*?android\ ?([0-9]+)\.([0-9]+).*/]},{name:"macOS",search:me("mac os x"),versionRegexes:[/.*?mac\ os\ x\ ?([0-9]+)_([0-9]+).*/]},{name:"Linux",search:me("linux"),versionRegexes:[]},{name:"Solaris",search:me("sunos"),versionRegexes:[]},{name:"FreeBSD",search:me("freebsd"),versionRegexes:[]},{name:"ChromeOS",search:me("cros"),versionRegexes:[/.*?chrome\/([0-9]+)\.([0-9]+).*/]}],pe={browsers:p(he),oses:p(ge)},fe="Edge",ve="Chromium",we="Opera",be="Firefox",ye="Safari",Se=e=>{const t=e.current,n=e.version,r=e=>()=>t===e;return{current:t,version:n,isEdge:r(fe),isChromium:r(ve),isIE:r("IE"),isOpera:r(we),isFirefox:r(be),isSafari:r(ye)}},xe=()=>Se({current:void 0,version:ce.unknown()}),Ee=Se,Fe=(p(fe),p(ve),p("IE"),p(we),p(be),p(ye),"Windows"),Oe="Android",ke="Linux",Te="macOS",Ce="Solaris",Ae="FreeBSD",Re="ChromeOS",Le=e=>{const t=e.current,n=e.version,r=e=>()=>t===e;return{current:t,version:n,isWindows:r(Fe),isiOS:r("iOS"),isAndroid:r(Oe),isMacOS:r(Te),isLinux:r(ke),isSolaris:r(Ce),isFreeBSD:r(Ae),isChromeOS:r(Re)}},Me=()=>Le({current:void 0,version:ce.unknown()}),Pe=Le,De=(p(Fe),p("iOS"),p(Oe),p(ke),p(Te),p(Ce),p(Ae),p(Re),(e,t,n)=>{const r=pe.browsers(),o=pe.oses(),s=t.bind((e=>((e,t)=>((e,t)=>{for(let n=0;n{const n=t.brand.toLowerCase();return F(e,(e=>{var t;return n===(null===(t=e.brand)||void 0===t?void 0:t.toLowerCase())})).map((e=>({current:e.name,version:ce.nu(parseInt(t.version,10),0)})))})))(r,e))).orThunk((()=>((e,t)=>ue(e,t).map((e=>{const n=ce.detect(e.versionRegexes,t);return{current:e.name,version:n}})))(r,e))).fold(xe,Ee),i=((e,t)=>ue(e,t).map((e=>{const n=ce.detect(e.versionRegexes,t);return{current:e.name,version:n}})))(o,e).fold(Me,Pe),l=((e,t,n,r)=>{const o=e.isiOS()&&!0===/ipad/i.test(n),s=e.isiOS()&&!o,i=e.isiOS()||e.isAndroid(),l=i||r("(pointer:coarse)"),a=o||!s&&i&&r("(min-device-width:768px)"),c=s||i&&!a,u=t.isSafari()&&e.isiOS()&&!1===/safari/i.test(n),d=!c&&!a&&!u;return{isiPad:p(o),isiPhone:p(s),isTablet:p(a),isPhone:p(c),isTouch:p(l),isAndroid:e.isAndroid,isiOS:e.isiOS,isWebView:p(u),isDesktop:p(d)}})(i,s,e,n);return{browser:s,os:i,deviceType:l}}),Ne=e=>window.matchMedia(e).matches;let He=(e=>{let t,n=!1;return(...r)=>(n||(n=!0,t=e.apply(null,r)),t)})((()=>De(navigator.userAgent,b.from(navigator.userAgentData),Ne)));const We=(e,t)=>({left:e,top:t,translate:(n,r)=>We(e+n,t+r)}),qe=We,Ie=e=>{const t=void 0===e?window:e;return He().browser.isFirefox()?b.none():b.from(t.visualViewport)},Ve=(e,t,n,r)=>({x:e,y:t,width:n,height:r,right:e+n,bottom:t+r}),je=e=>{const t=void 0===e?window:e,n=t.document,r=(e=>{const t=void 0!==e?e.dom:document,n=t.body.scrollLeft||t.documentElement.scrollLeft,r=t.body.scrollTop||t.documentElement.scrollTop;return qe(n,r)})(B(n));return Ie(t).fold((()=>{const e=t.document.documentElement,n=e.clientWidth,o=e.clientHeight;return Ve(r.left,r.top,n,o)}),(e=>Ve(Math.max(e.pageLeft,r.left),Math.max(e.pageTop,r.top),e.width,e.height)))},Be=(e,t,n)=>Ie(n).map((n=>{const r=e=>t(re(e));return n.addEventListener(e,r),{unbind:()=>n.removeEventListener(e,r)}})).getOrThunk((()=>({unbind:g})));var _e=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),ze=tinymce.util.Tools.resolve("tinymce.Env");const $e=(e,t)=>{e.dispatch("FullscreenStateChanged",{state:t}),e.dispatch("ResizeEditor")},Ue=("fullscreen_native",e=>e.options.get("fullscreen_native"));const Ke=e=>{return e.dom===(void 0!==(t=z(e).dom).fullscreenElement?t.fullscreenElement:void 0!==t.msFullscreenElement?t.msFullscreenElement:void 0!==t.webkitFullscreenElement?t.webkitFullscreenElement:null);var t},Xe=(e,t,n)=>((e,t,n)=>E(((e,t)=>{const n=m(t)?t:v;let r=e.dom;const o=[];for(;null!==r.parentNode&&void 0!==r.parentNode;){const e=r.parentNode,t=B(e);if(o.push(t),!0===n(t))break;r=e}return o})(e,n),t))(e,(e=>_(e,t)),n),Ye=(e,t)=>((e,n)=>{return E((e=>b.from(e.dom.parentNode).map(B))(r=e).map($).map((e=>E(e,(e=>{return t=e,!(r.dom===t.dom);var t})))).getOr([]),(e=>_(e,t)));var r})(e),Ge="data-ephox-mobile-fullscreen-style",Je="position:absolute!important;",Qe="top:0!important;left:0!important;margin:0!important;padding:0!important;width:100%!important;height:100%!important;overflow:visible!important;",Ze=ze.os.isAndroid(),et=(e,t,n)=>{const r=t=>n=>{const r=W(n,"style"),o=void 0===r?"no-styles":r.trim();o!==t&&(((e,t,n)=>{((e,t,n)=>{if(!(s(n)||c(n)||h(n)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",n,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,n+"")})(e.dom,t,n)})(n,Ge,o),ee(n,e.parseStyle(t)))},o=Xe(t,"*"),i=(e=>{const t=[];for(let n=0,r=e.length;nYe(e,"*:not(.tox-silver-sink)")))),a=(e=>{const t=te(e,"background-color");return void 0!==t&&""!==t?"background-color:"+t+"!important":"background-color:rgb(255,255,255)!important;"})(n);x(i,r("display:none!important;")),x(o,r(Je+Qe+a)),r((!0===Ze?"":Je)+Qe+a)(t)},tt=_e.DOM,nt=Ie().fold((()=>({bind:g,unbind:g})),(e=>{const t=(()=>{const e=O(g);return{...e,on:t=>e.get().each(t)}})(),n=k(),r=k(),o=((e,t)=>{let n=null;return{cancel:()=>{a(n)||(clearTimeout(n),n=null)},throttle:(...t)=>{a(n)&&(n=setTimeout((()=>{n=null,e.apply(null,t)}),50))}}})((()=>{document.body.scrollTop=0,document.documentElement.scrollTop=0,window.requestAnimationFrame((()=>{t.on((t=>ee(t,{top:e.offsetTop+"px",left:e.offsetLeft+"px",height:e.height+"px",width:e.width+"px"})))}))}));return{bind:e=>{t.set(e),o.throttle(),n.set(Be("resize",o.throttle)),r.set(Be("scroll",o.throttle))},unbind:()=>{t.on((()=>{n.clear(),r.clear()})),t.clear()}}})),rt=(e,t)=>{const n=document.body,r=document.documentElement,o=e.getContainer(),s=B(o),i=(l=s,b.from(l.dom.nextSibling).map(B)).filter((e=>(e=>P(e)&&L(e.dom))(e)&&((e,t)=>(e=>void 0!==e.dom.classList)(e)&&e.dom.classList.contains("tox-silver-sink"))(e)));var l;const a=(e=>{const t=B(e.getElement());return Y(t).map(G).getOrThunk((()=>(e=>{const t=e.dom.body;if(null==t)throw new Error("Body is not available yet");return B(t)})(z(t))))})(e),c=t.get(),u=B(e.getBody()),d=ze.deviceType.isTouch(),m=o.style,h=e.iframeElement,g=null==h?void 0:h.style,p=e=>{e(n,"tox-fullscreen"),e(r,"tox-fullscreen"),e(o,"tox-fullscreen"),Y(s).map((e=>G(e).dom)).each((t=>{e(t,"tox-fullscreen"),e(t,"tox-shadowhost")}))},f=()=>{d&&(e=>{const t=((e,t)=>{const n=document;return 1!==(r=n).nodeType&&9!==r.nodeType&&11!==r.nodeType||0===r.childElementCount?[]:S(n.querySelectorAll(e),B);var r})("["+Ge+"]");x(t,(t=>{const n=W(t,Ge);n&&"no-styles"!==n?ee(t,e.parseStyle(n)):q(t,"style"),q(t,Ge)}))})(e.dom),p(tt.removeClass),nt.unbind(),b.from(t.get()).each((e=>e.fullscreenChangeHandler.unbind()))};if(c)c.fullscreenChangeHandler.unbind(),Ue(e)&&Ke(a)&&(e=>{const t=e.dom;t.exitFullscreen?t.exitFullscreen():t.msExitFullscreen?t.msExitFullscreen():t.webkitCancelFullScreen&&t.webkitCancelFullScreen()})(z(a)),g.width=c.iframeWidth,g.height=c.iframeHeight,m.width=c.containerWidth,m.height=c.containerHeight,m.top=c.containerTop,m.left=c.containerLeft,w=i,y=c.sinkCssPosition,E=(e,t)=>{Z(e,"position",t)},w.isSome()&&y.isSome()?b.some(E(w.getOrDie(),y.getOrDie())):b.none(),f(),v=c.scrollPos,window.scrollTo(v.x,v.y),t.set(null),$e(e,!1),e.off("remove",f);else{const n=ie(z(a),void 0!==document.fullscreenElement?"fullscreenchange":void 0!==document.msFullscreenElement?"MSFullscreenChange":void 0!==document.webkitFullscreenElement?"webkitfullscreenchange":"fullscreenchange",(n=>{Ue(e)&&(Ke(a)||null===t.get()||rt(e,t))})),r={scrollPos:je(window),containerWidth:m.width,containerHeight:m.height,containerTop:m.top,containerLeft:m.left,iframeWidth:g.width,iframeHeight:g.height,fullscreenChangeHandler:n,sinkCssPosition:i.map((e=>te(e,"position")))};d&&et(e.dom,s,u),g.width=g.height="100%",m.width=m.height="",p(tt.addClass),i.each((e=>{Z(e,"position","fixed")})),nt.bind(s),e.on("remove",f),t.set(r),Ue(e)&&(e=>{const t=e.dom;t.requestFullscreen?t.requestFullscreen():t.msRequestFullscreen?t.msRequestFullscreen():t.webkitRequestFullScreen&&t.webkitRequestFullScreen()})(a),$e(e,!0)}var v,w,y,E},ot=(e,t)=>n=>{n.setActive(null!==t.get());const r=e=>n.setActive(e.state);return e.on("FullscreenStateChanged",r),()=>e.off("FullscreenStateChanged",r)};t.add("fullscreen",(t=>{const n=e(null);return t.inline||((e=>{(0,e.options.register)("fullscreen_native",{processor:"boolean",default:!1})})(t),((e,t)=>{e.addCommand("mceFullScreen",(()=>{rt(e,t)}))})(t,n),((e,t)=>{const n=()=>e.execCommand("mceFullScreen");e.ui.registry.addToggleMenuItem("fullscreen",{text:"Fullscreen",icon:"fullscreen",shortcut:"Meta+Shift+F",onAction:n,onSetup:ot(e,t)}),e.ui.registry.addToggleButton("fullscreen",{tooltip:"Fullscreen",icon:"fullscreen",onAction:n,onSetup:ot(e,t)})})(t,n),t.addShortcut("Meta+Shift+F","","mceFullScreen")),(e=>({isFullscreen:()=>null!==e.get()}))(n)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/image/plugin.min.js b/public/libs/tinymce/plugins/image/plugin.min.js index 0fa3f5f72dd..d8dfd216163 100644 --- a/public/libs/tinymce/plugins/image/plugin.min.js +++ b/public/libs/tinymce/plugins/image/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=Object.getPrototypeOf,a=(e,t,a)=>{var i;return!!a(e,t.prototype)||(null===(i=e.constructor)||void 0===i?void 0:i.name)===t.name},i=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&a(e,String,((e,t)=>t.isPrototypeOf(e)))?"string":t})(t)===e,s=e=>t=>typeof t===e,r=i("string"),o=i("object"),n=e=>((e,i)=>o(e)&&a(e,i,((e,a)=>t(e)===a)))(e,Object),l=i("array"),c=(null,e=>null===e);const m=s("boolean"),d=e=>!(e=>null==e)(e),g=s("function"),u=s("number"),p=()=>{};class h{constructor(e,t){this.tag=e,this.value=t}static some(e){return new h(!0,e)}static none(){return h.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?h.some(e(this.value)):h.none()}bind(e){return this.tag?e(this.value):h.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:h.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return d(e)?h.some(e):h.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}h.singletonNone=new h(!1);const b=Object.keys,v=Object.hasOwnProperty,y=(e,t)=>v.call(e,t),f=Array.prototype.push,w=e=>{const t=[];for(let a=0,i=e.length;a{((e,t,a)=>{if(!(r(a)||m(a)||u(a)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",a,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,a+"")})(e.dom,t,a)},D=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},_=D;var C=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),I=tinymce.util.Tools.resolve("tinymce.util.URI");const U=e=>e.length>0,x=e=>t=>t.options.get(e),S=x("image_dimensions"),N=x("image_advtab"),T=x("image_uploadtab"),O=x("image_prepend_url"),E=x("image_class_list"),L=x("image_description"),j=x("image_title"),M=x("image_caption"),R=x("image_list"),k=x("a11y_advanced_options"),z=x("automatic_uploads"),B=(e,t)=>Math.max(parseInt(e,10),parseInt(t,10)),P=e=>(e&&(e=e.replace(/px$/,"")),e),F=e=>(e.length>0&&/^[0-9]+$/.test(e)&&(e+="px"),e),H=e=>"IMG"===e.nodeName&&(e.hasAttribute("data-mce-object")||e.hasAttribute("data-mce-placeholder")),G=(e,t)=>{const a=e.options.get;return I.isDomSafe(t,"img",{allow_html_data_urls:a("allow_html_data_urls"),allow_script_urls:a("allow_script_urls"),allow_svg_data_urls:a("allow_svg_data_urls")})},W=C.DOM,$=e=>e.style.marginLeft&&e.style.marginRight&&e.style.marginLeft===e.style.marginRight?P(e.style.marginLeft):"",V=e=>e.style.marginTop&&e.style.marginBottom&&e.style.marginTop===e.style.marginBottom?P(e.style.marginTop):"",K=e=>e.style.borderWidth?P(e.style.borderWidth):"",Z=(e,t)=>{var a;return e.hasAttribute(t)&&null!==(a=e.getAttribute(t))&&void 0!==a?a:""},q=e=>null!==e.parentNode&&"FIGURE"===e.parentNode.nodeName,J=(e,t,a)=>{""===a||null===a?e.removeAttribute(t):e.setAttribute(t,a)},Q=(e,t)=>{const a=e.getAttribute("style"),i=t(null!==a?a:"");i.length>0?(e.setAttribute("style",i),e.setAttribute("data-mce-style",i)):e.removeAttribute("style")},X=(e,t)=>(e,a,i)=>{const s=e.style;s[a]?(s[a]=F(i),Q(e,t)):J(e,a,i)},Y=(e,t)=>e.style[t]?P(e.style[t]):Z(e,t),ee=(e,t)=>{const a=F(t);e.style.marginLeft=a,e.style.marginRight=a},te=(e,t)=>{const a=F(t);e.style.marginTop=a,e.style.marginBottom=a},ae=(e,t)=>{const a=F(t);e.style.borderWidth=a},ie=(e,t)=>{e.style.borderStyle=t},se=e=>{var t;return null!==(t=e.style.borderStyle)&&void 0!==t?t:""},re=e=>d(e)&&"FIGURE"===e.nodeName,oe=e=>0===W.getAttrib(e,"alt").length&&"presentation"===W.getAttrib(e,"role"),ne=e=>oe(e)?"":Z(e,"alt"),le=(e,t)=>{var a;const i=document.createElement("img");return J(i,"style",t.style),($(i)||""!==t.hspace)&&ee(i,t.hspace),(V(i)||""!==t.vspace)&&te(i,t.vspace),(K(i)||""!==t.border)&&ae(i,t.border),(se(i)||""!==t.borderStyle)&&ie(i,t.borderStyle),e(null!==(a=i.getAttribute("style"))&&void 0!==a?a:"")},ce=(e,t)=>({src:Z(t,"src"),alt:ne(t),title:Z(t,"title"),width:Y(t,"width"),height:Y(t,"height"),class:Z(t,"class"),style:e(Z(t,"style")),caption:q(t),hspace:$(t),vspace:V(t),border:K(t),borderStyle:se(t),isDecorative:oe(t)}),me=(e,t,a,i,s)=>{a[i]!==t[i]&&s(e,i,String(a[i]))},de=(e,t,a)=>{if(a){W.setAttrib(e,"role","presentation");const t=_(e);A(t,"alt","")}else{if(c(t)){"alt",_(e).dom.removeAttribute("alt")}else{const a=_(e);A(a,"alt",t)}"presentation"===W.getAttrib(e,"role")&&W.setAttrib(e,"role","")}},ge=(e,t)=>(a,i,s)=>{e(a,s),Q(a,t)},ue=(e,t,a)=>{const i=ce(e,a);me(a,i,t,"caption",((e,t,a)=>(e=>{q(e)?(e=>{const t=e.parentNode;d(t)&&(W.insertAfter(e,t),W.remove(t))})(e):(e=>{const t=W.create("figure",{class:"image"});W.insertAfter(t,e),t.appendChild(e),t.appendChild(W.create("figcaption",{contentEditable:"true"},"Caption")),t.contentEditable="false"})(e)})(e))),me(a,i,t,"src",J),me(a,i,t,"title",J),me(a,i,t,"width",X(0,e)),me(a,i,t,"height",X(0,e)),me(a,i,t,"class",J),me(a,i,t,"style",ge(((e,t)=>J(e,"style",t)),e)),me(a,i,t,"hspace",ge(ee,e)),me(a,i,t,"vspace",ge(te,e)),me(a,i,t,"border",ge(ae,e)),me(a,i,t,"borderStyle",ge(ie,e)),((e,t,a)=>{a.alt===t.alt&&a.isDecorative===t.isDecorative||de(e,a.alt,a.isDecorative)})(a,i,t)},pe=(e,t)=>{const a=(e=>{if(e.margin){const t=String(e.margin).split(" ");switch(t.length){case 1:e["margin-top"]=e["margin-top"]||t[0],e["margin-right"]=e["margin-right"]||t[0],e["margin-bottom"]=e["margin-bottom"]||t[0],e["margin-left"]=e["margin-left"]||t[0];break;case 2:e["margin-top"]=e["margin-top"]||t[0],e["margin-right"]=e["margin-right"]||t[1],e["margin-bottom"]=e["margin-bottom"]||t[0],e["margin-left"]=e["margin-left"]||t[1];break;case 3:e["margin-top"]=e["margin-top"]||t[0],e["margin-right"]=e["margin-right"]||t[1],e["margin-bottom"]=e["margin-bottom"]||t[2],e["margin-left"]=e["margin-left"]||t[1];break;case 4:e["margin-top"]=e["margin-top"]||t[0],e["margin-right"]=e["margin-right"]||t[1],e["margin-bottom"]=e["margin-bottom"]||t[2],e["margin-left"]=e["margin-left"]||t[3]}delete e.margin}return e})(e.dom.styles.parse(t)),i=e.dom.styles.parse(e.dom.styles.serialize(a));return e.dom.styles.serialize(i)},he=e=>{const t=e.selection.getNode(),a=e.dom.getParent(t,"figure.image");return a?e.dom.select("img",a)[0]:t&&("IMG"!==t.nodeName||H(t))?null:t},be=(e,t)=>{var a;const i=e.dom,s=((t,a)=>{const i={};var s;return((e,t,a,i)=>{((e,t)=>{const a=b(e);for(let i=0,s=a.length;i{(t(e,s)?a:i)(e,s)}))})(t,((t,a)=>!e.schema.isValidChild(a,"figure")),(s=i,(e,t)=>{s[t]=e}),p),i})(e.schema.getTextBlockElements()),r=i.getParent(t.parentNode,(e=>{return t=s,a=e.nodeName,y(t,a)&&void 0!==t[a]&&null!==t[a];var t,a}),e.getBody());return r&&null!==(a=i.split(r,t))&&void 0!==a?a:t},ve=(e,t)=>{const a=((t,a)=>{const i=document.createElement("img");if(ue((t=>pe(e,t)),{...a,caption:!1},i),de(i,a.alt,a.isDecorative),a.caption){const e=W.create("figure",{class:"image"});return e.appendChild(i),e.appendChild(W.create("figcaption",{contentEditable:"true"},"Caption")),e.contentEditable="false",e}return i})(0,t);e.dom.setAttrib(a,"data-mce-id","__mcenew"),e.focus(),e.selection.setContent(a.outerHTML);const i=e.dom.select('*[data-mce-id="__mcenew"]')[0];if(e.dom.setAttrib(i,"data-mce-id",null),re(i)){const t=be(e,i);e.selection.select(t)}else e.selection.select(i)},ye=(e,t)=>{const a=he(e);if(a){const i={...ce((t=>pe(e,t)),a),...t},s=((e,t)=>{const a=t.src;return{...t,src:G(e,a)?a:""}})(e,i);i.src?((e,t)=>{const a=he(e);if(a)if(ue((t=>pe(e,t)),t,a),((e,t)=>{e.dom.setAttrib(t,"src",t.getAttribute("src"))})(e,a),re(a.parentNode)){const t=a.parentNode;be(e,t),e.selection.select(a.parentNode)}else e.selection.select(a),((e,t,a)=>{const i=()=>{a.onload=a.onerror=null,e.selection&&(e.selection.select(a),e.nodeChanged())};a.onload=()=>{t.width||t.height||!S(e)||e.dom.setAttribs(a,{width:String(a.clientWidth),height:String(a.clientHeight)}),i()},a.onerror=i})(e,t,a)})(e,s):((e,t)=>{if(t){const a=e.dom.is(t.parentNode,"figure.image")?t.parentNode:t;e.dom.remove(a),e.focus(),e.nodeChanged(),e.dom.isEmpty(e.getBody())&&(e.setContent(""),e.selection.setCursorLocation())}})(e,a)}else t.src&&ve(e,{src:"",alt:"",title:"",width:"",height:"",class:"",style:"",caption:!1,hspace:"",vspace:"",border:"",borderStyle:"",isDecorative:!1,...t})},fe=(we=(e,t)=>n(e)&&n(t)?fe(e,t):t,(...e)=>{if(0===e.length)throw new Error("Can't merge zero objects");const t={};for(let a=0;ar(e.value)?e.value:"",Ce=(e,t)=>{const a=[];return De.each(e,(e=>{const i=(e=>r(e.text)?e.text:r(e.title)?e.title:"")(e);if(void 0!==e.menu){const s=Ce(e.menu,t);a.push({text:i,items:s})}else{const s=t(e);a.push({text:i,value:s})}})),a},Ie=(e=_e)=>t=>t?h.from(t).map((t=>Ce(t,e))):h.none(),Ue=(e,t)=>((e,a)=>{for(let a=0;ay(e,"items"))(i=e[a])?Ue(i.items,t):i.value===t?h.some(i):h.none();if(s.isSome())return s}var i;return h.none()})(e),xe=Ie,Se=(e,t)=>e.bind((e=>Ue(e,t))),Ne=e=>{const t=xe((t=>e.convertURL(t.value||t.url||"","src"))),a=new Promise((a=>{((e,t)=>{const a=R(e);r(a)?fetch(a).then((e=>{e.ok&&e.json().then(t)})):g(a)?a(t):t(a)})(e,(e=>{a(t(e).map((e=>w([[{text:"None",value:""}],e]))))}))})),i=(A=E(e),Ie(_e)(A)),s=N(e),o=T(e),n=(e=>U(e.options.get("images_upload_url")))(e),l=(e=>d(e.options.get("images_upload_handler")))(e),c=(e=>{const t=he(e);return t?ce((t=>pe(e,t)),t):{src:"",alt:"",title:"",width:"",height:"",class:"",style:"",caption:!1,hspace:"",vspace:"",border:"",borderStyle:"",isDecorative:!1}})(e),m=L(e),u=j(e),p=S(e),b=M(e),v=k(e),y=z(e),f=h.some(O(e)).filter((e=>r(e)&&e.length>0));var A;return a.then((e=>({image:c,imageList:e,classList:i,hasAdvTab:s,hasUploadTab:o,hasUploadUrl:n,hasUploadHandler:l,hasDescription:m,hasImageTitle:u,hasDimensions:p,hasImageCaption:b,prependURL:f,hasAccessibilityOptions:v,automaticUploads:y})))},Te=e=>{const t=e.imageList.map((e=>({name:"images",type:"listbox",label:"Image list",items:e}))),a={name:"alt",type:"input",label:"Alternative description",enabled:!(e.hasAccessibilityOptions&&e.image.isDecorative)},i=e.classList.map((e=>({name:"classes",type:"listbox",label:"Class",items:e})));return w([[{name:"src",type:"urlinput",filetype:"image",label:"Source",picker_text:"Browse files"}],t.toArray(),e.hasAccessibilityOptions&&e.hasDescription?[{type:"label",label:"Accessibility",items:[{name:"isDecorative",type:"checkbox",label:"Image is decorative"}]}]:[],e.hasDescription?[a]:[],e.hasImageTitle?[{name:"title",type:"input",label:"Image title"}]:[],e.hasDimensions?[{name:"dimensions",type:"sizeinput"}]:[],[{...(s=e.classList.isSome()&&e.hasImageCaption,s?{type:"grid",columns:2}:{type:"panel"}),items:w([i.toArray(),e.hasImageCaption?[{type:"label",label:"Caption",items:[{type:"checkbox",name:"caption",label:"Show caption"}]}]:[]])}]]);var s},Oe=e=>({title:"General",name:"general",items:Te(e)}),Ee=Te,Le=e=>({src:{value:e.src,meta:{}},images:e.src,alt:e.alt,title:e.title,dimensions:{width:e.width,height:e.height},classes:e.class,caption:e.caption,style:e.style,vspace:e.vspace,border:e.border,hspace:e.hspace,borderstyle:e.borderStyle,fileinput:[],isDecorative:e.isDecorative}),je=(e,t)=>({src:e.src.value,alt:null!==e.alt&&0!==e.alt.length||!t?e.alt:null,title:e.title,width:e.dimensions.width,height:e.dimensions.height,class:e.classes,style:e.style,caption:e.caption,hspace:e.hspace,vspace:e.vspace,border:e.border,borderStyle:e.borderstyle,isDecorative:e.isDecorative}),Me=(e,t,a,i)=>{((e,t)=>{const a=t.getData();((e,t)=>/^(?:[a-zA-Z]+:)?\/\//.test(t)?h.none():e.prependURL.bind((e=>t.substring(0,e.length)!==e?h.some(e+t):h.none())))(e,a.src.value).each((e=>{t.setData({src:{value:e,meta:a.src.meta}})}))})(t,i),((e,t)=>{const a=t.getData(),i=a.src.meta;if(void 0!==i){const s=fe({},a);((e,t,a)=>{e.hasDescription&&r(a.alt)&&(t.alt=a.alt),e.hasAccessibilityOptions&&(t.isDecorative=a.isDecorative||t.isDecorative||!1),e.hasImageTitle&&r(a.title)&&(t.title=a.title),e.hasDimensions&&(r(a.width)&&(t.dimensions.width=a.width),r(a.height)&&(t.dimensions.height=a.height)),r(a.class)&&Se(e.classList,a.class).each((e=>{t.classes=e.value})),e.hasImageCaption&&m(a.caption)&&(t.caption=a.caption),e.hasAdvTab&&(r(a.style)&&(t.style=a.style),r(a.vspace)&&(t.vspace=a.vspace),r(a.border)&&(t.border=a.border),r(a.hspace)&&(t.hspace=a.hspace),r(a.borderstyle)&&(t.borderstyle=a.borderstyle))})(e,s,i),t.setData(s)}})(t,i),((e,t,a,i)=>{const s=i.getData(),r=s.src.value,o=s.src.meta||{};o.width||o.height||!t.hasDimensions||(U(r)?e.imageSize(r).then((e=>{a.open&&i.setData({dimensions:e})})).catch((e=>console.error(e))):i.setData({dimensions:{width:"",height:""}}))})(e,t,a,i),((e,t,a)=>{const i=a.getData(),s=Se(e.imageList,i.src.value);t.prevImage=s,a.setData({images:s.map((e=>e.value)).getOr("")})})(t,a,i)},Re=(e,t,a,i)=>{const s=i.getData();var r;i.block("Uploading image"),(r=s.fileinput,((e,t)=>0{i.unblock()}),(s=>{const r=URL.createObjectURL(s),o=()=>{i.unblock(),URL.revokeObjectURL(r)},n=s=>{i.setData({src:{value:s,meta:{}}}),i.showTab("general"),Me(e,t,a,i)};var l;(l=s,new Promise(((e,t)=>{const a=new FileReader;a.onload=()=>{e(a.result)},a.onerror=()=>{var e;t(null===(e=a.error)||void 0===e?void 0:e.message)},a.readAsDataURL(l)}))).then((a=>{const l=e.createBlobCache(s,r,a);t.automaticUploads?e.uploadImage(l).then((e=>{n(e.url),o()})).catch((t=>{o(),e.alertErr(t)})):(e.addToBlobCache(l),n(l.blobUri()),i.unblock())}))}))},ke=(e,t,a)=>(i,s)=>{"src"===s.name?Me(e,t,a,i):"images"===s.name?((e,t,a,i)=>{const s=i.getData(),r=Se(t.imageList,s.images);r.each((e=>{const t=""===s.alt||a.prevImage.map((e=>e.text===s.alt)).getOr(!1);t?""===e.value?i.setData({src:e,alt:a.prevAlt}):i.setData({src:e,alt:e.text}):i.setData({src:e})})),a.prevImage=r,Me(e,t,a,i)})(e,t,a,i):"alt"===s.name?a.prevAlt=i.getData().alt:"fileinput"===s.name?Re(e,t,a,i):"isDecorative"===s.name&&i.setEnabled("alt",!i.getData().isDecorative)},ze=e=>()=>{e.open=!1},Be=e=>e.hasAdvTab||e.hasUploadUrl||e.hasUploadHandler?{type:"tabpanel",tabs:w([[Oe(e)],e.hasAdvTab?[{title:"Advanced",name:"advanced",items:[{type:"grid",columns:2,items:[{type:"input",label:"Vertical space",name:"vspace",inputMode:"numeric"},{type:"input",label:"Horizontal space",name:"hspace",inputMode:"numeric"},{type:"input",label:"Border width",name:"border",inputMode:"numeric"},{type:"listbox",name:"borderstyle",label:"Border style",items:[{text:"Select...",value:""},{text:"Solid",value:"solid"},{text:"Dotted",value:"dotted"},{text:"Dashed",value:"dashed"},{text:"Double",value:"double"},{text:"Groove",value:"groove"},{text:"Ridge",value:"ridge"},{text:"Inset",value:"inset"},{text:"Outset",value:"outset"},{text:"None",value:"none"},{text:"Hidden",value:"hidden"}]}]}]}]:[],e.hasUploadTab&&(e.hasUploadUrl||e.hasUploadHandler)?[{title:"Upload",name:"upload",items:[{type:"dropzone",name:"fileinput"}]}]:[]])}:{type:"panel",items:Ee(e)},Pe=(e,t,a)=>i=>{const s=fe(Le(t.image),i.getData()),r={...s,style:le(a.normalizeCss,je(s,!1))};e.execCommand("mceUpdateImage",!1,je(r,t.hasAccessibilityOptions)),e.editorUpload.uploadImagesAuto(),i.close()},Fe=e=>t=>G(e,t)?(e=>new Promise((t=>{const a=document.createElement("img"),i=e=>{a.onload=a.onerror=null,a.parentNode&&a.parentNode.removeChild(a),t(e)};a.onload=()=>{const e={width:B(a.width,a.clientWidth),height:B(a.height,a.clientHeight)};i(Promise.resolve(e))},a.onerror=()=>{i(Promise.reject(`Failed to get image dimensions for: ${e}`))};const s=a.style;s.visibility="hidden",s.position="fixed",s.bottom=s.left="0px",s.width=s.height="auto",document.body.appendChild(a),a.src=e})))(e.documentBaseURI.toAbsolute(t)).then((e=>({width:String(e.width),height:String(e.height)}))):Promise.resolve({width:"",height:""}),He=e=>(t,a,i)=>{var s;return e.editorUpload.blobCache.create({blob:t,blobUri:a,name:null===(s=t.name)||void 0===s?void 0:s.replace(/\.[^\.]+$/,""),filename:t.name,base64:i.split(",")[1]})},Ge=e=>t=>{e.editorUpload.blobCache.add(t)},We=e=>t=>{e.windowManager.alert(t)},$e=e=>t=>pe(e,t),Ve=e=>t=>e.dom.parseStyle(t),Ke=e=>(t,a)=>e.dom.serializeStyle(t,a),Ze=e=>t=>Ae(e).upload([t],!1).then((e=>{var t;return 0===e.length?Promise.reject("Failed to upload image"):!1===e[0].status?Promise.reject(null===(t=e[0].error)||void 0===t?void 0:t.message):e[0]})),qe=e=>{const t={imageSize:Fe(e),addToBlobCache:Ge(e),createBlobCache:He(e),alertErr:We(e),normalizeCss:$e(e),parseStyle:Ve(e),serializeStyle:Ke(e),uploadImage:Ze(e)};return{open:()=>{Ne(e).then((a=>{const i=(e=>({prevImage:Se(e.imageList,e.image.src),prevAlt:e.image.alt,open:!0}))(a);return{title:"Insert/Edit Image",size:"normal",body:Be(a),buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:Le(a.image),onSubmit:Pe(e,a,t),onChange:ke(t,a,i),onClose:ze(i)}})).then(e.windowManager.open)}}},Je=e=>{const t=e.attr("class");return d(t)&&/\bimage\b/.test(t)},Qe=e=>t=>{let a=t.length;const i=t=>{t.attr("contenteditable",e?"true":null)};for(;a--;){const s=t[a];Je(s)&&(s.attr("contenteditable",e?"false":null),De.each(s.getAll("figcaption"),i))}},Xe=e=>t=>{const a=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",a),a(),()=>{e.off("NodeChange",a)}};e.add("image",(e=>{(e=>{const t=e.options.register;t("image_dimensions",{processor:"boolean",default:!0}),t("image_advtab",{processor:"boolean",default:!1}),t("image_uploadtab",{processor:"boolean",default:!0}),t("image_prepend_url",{processor:"string",default:""}),t("image_class_list",{processor:"object[]"}),t("image_description",{processor:"boolean",default:!0}),t("image_title",{processor:"boolean",default:!1}),t("image_caption",{processor:"boolean",default:!1}),t("image_list",{processor:e=>{const t=!1===e||r(e)||((e,t)=>{if(l(e)){for(let a=0,i=e.length;a{e.on("PreInit",(()=>{e.parser.addNodeFilter("figure",Qe(!0)),e.serializer.addNodeFilter("figure",Qe(!1))}))})(e),(e=>{e.ui.registry.addToggleButton("image",{icon:"image",tooltip:"Insert/edit image",onAction:qe(e).open,onSetup:t=>{t.setActive(d(he(e)));const a=e.selection.selectorChangedWithUnbind("img:not([data-mce-object]):not([data-mce-placeholder]),figure.image",t.setActive).unbind,i=Xe(e)(t);return()=>{a(),i()}}}),e.ui.registry.addMenuItem("image",{icon:"image",text:"Image...",onAction:qe(e).open,onSetup:Xe(e)}),e.ui.registry.addContextMenu("image",{update:t=>e.selection.isEditable()&&(re(t)||"IMG"===t.nodeName&&!H(t))?["image"]:[]})})(e),(e=>{e.addCommand("mceImage",qe(e).open),e.addCommand("mceUpdateImage",((t,a)=>{e.undoManager.transact((()=>ye(e,a)))}))})(e)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/importcss/plugin.min.js b/public/libs/tinymce/plugins/importcss/plugin.min.js index 4b8006a4170..4175a248d04 100644 --- a/public/libs/tinymce/plugins/importcss/plugin.min.js +++ b/public/libs/tinymce/plugins/importcss/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(s=r=e,(o=String).prototype.isPrototypeOf(s)||(null===(n=r.constructor)||void 0===n?void 0:n.name)===o.name)?"string":t;var s,r,o,n})(t)===e,s=t("string"),r=t("object"),o=t("array"),n=("function",e=>"function"==typeof e);var c=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),i=tinymce.util.Tools.resolve("tinymce.EditorManager"),l=tinymce.util.Tools.resolve("tinymce.Env"),a=tinymce.util.Tools.resolve("tinymce.util.Tools");const p=e=>t=>t.options.get(e),u=p("importcss_merge_classes"),m=p("importcss_exclusive"),f=p("importcss_selector_converter"),y=p("importcss_selector_filter"),d=p("importcss_groups"),h=p("importcss_append"),_=p("importcss_file_filter"),g=p("skin"),v=p("skin_url"),b=Array.prototype.push,x=/^\.(?:ephox|tiny-pageembed|mce)(?:[.-]+\w+)+$/,T=e=>s(e)?t=>-1!==t.indexOf(e):e instanceof RegExp?t=>e.test(t):e,S=(e,t)=>{let s={};const r=/^(?:([a-z0-9\-_]+))?(\.[a-z0-9_\-\.]+)$/i.exec(t);if(!r)return;const o=r[1],n=r[2].substr(1).split(".").join(" "),c=a.makeMap("a,img");return r[1]?(s={title:t},e.schema.getTextBlockElements()[o]?s.block=o:e.schema.getBlockElements()[o]||c[o.toLowerCase()]?s.selector=o:s.inline=o):r[2]&&(s={inline:"span",title:t.substr(1),classes:n}),u(e)?s.classes=n:s.attributes={class:n},s},k=(e,t)=>null===t||m(e),w=e=>{e.on("init",(()=>{const t=(()=>{const e=[],t=[],s={};return{addItemToGroup:(e,r)=>{s[e]?s[e].push(r):(t.push(e),s[e]=[r])},addItem:t=>{e.push(t)},toFormats:()=>{return(r=t,n=e=>{const t=s[e];return 0===t.length?[]:[{title:e,items:t}]},(e=>{const t=[];for(let s=0,r=e.length;s{const s=e.length,r=new Array(s);for(let o=0;oa.map(e,(e=>a.extend({},e,{original:e,selectors:{},filter:T(e.filter)}))))(d(e)),u=(t,s)=>{if(((e,t,s,r)=>!(k(e,s)?t in r:t in s.selectors))(e,t,s,r)){((e,t,s,r)=>{k(e,s)?r[t]=!0:s.selectors[t]=!0})(e,t,s,r);const o=((e,t,s,r)=>{let o;const n=f(e);return o=r&&r.selector_converter?r.selector_converter:n||(()=>S(e,s)),o.call(t,s,r)})(e,e.plugins.importcss,t,s);if(o){const t=o.name||c.DOM.uniqueId();return e.formatter.register(t,o),{title:o.title,format:t}}}return null};a.each(((e,t,r)=>{const o=[],n={},c=(t,n)=>{let p,u=t.href;if(u=(e=>{const t=l.cacheSuffix;return s(e)&&(e=e.replace("?"+t,"").replace("&"+t,"")),e})(u),u&&(!r||r(u,n))&&!((e,t)=>{const s=g(e);if(s){const r=v(e),o=r?e.documentBaseURI.toAbsolute(r):i.baseURL+"/skins/ui/"+s,n=i.baseURL+"/skins/content/";return t===o+"/content"+(e.inline?".inline":"")+".min.css"||-1!==t.indexOf(n)}return!1})(e,u)){a.each(t.imports,(e=>{c(e,!0)}));try{p=t.cssRules||t.rules}catch(e){}a.each(p,(e=>{e.styleSheet&&e.styleSheet?c(e.styleSheet,!0):e.selectorText&&a.each(e.selectorText.split(","),(e=>{o.push(a.trim(e))}))}))}};a.each(e.contentCSS,(e=>{n[e]=!0})),r||(r=(e,t)=>t||n[e]);try{a.each(t.styleSheets,(e=>{c(e)}))}catch(e){}return o})(e,e.getDoc(),T(_(e))),(e=>{if(!x.test(e)&&(!n||n(e))){const s=((e,t)=>a.grep(e,(e=>!e.filter||e.filter(t))))(p,e);if(s.length>0)a.each(s,(s=>{const r=u(e,s);r&&t.addItemToGroup(s.title,r)}));else{const s=u(e,null);s&&t.addItem(s)}}}));const m=t.toFormats();e.dispatch("addStyleModifications",{items:m,replace:!h(e)})}))};e.add("importcss",(e=>((e=>{const t=e.options.register,o=e=>s(e)||n(e)||r(e);t("importcss_merge_classes",{processor:"boolean",default:!0}),t("importcss_exclusive",{processor:"boolean",default:!0}),t("importcss_selector_converter",{processor:"function"}),t("importcss_selector_filter",{processor:o}),t("importcss_file_filter",{processor:o}),t("importcss_groups",{processor:"object[]"}),t("importcss_append",{processor:"boolean",default:!1})})(e),w(e),(e=>({convertSelectorToFormat:t=>S(e,t)}))(e))))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/insertdatetime/plugin.min.js b/public/libs/tinymce/plugins/insertdatetime/plugin.min.js index 19294c925ce..801cfe9d986 100644 --- a/public/libs/tinymce/plugins/insertdatetime/plugin.min.js +++ b/public/libs/tinymce/plugins/insertdatetime/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),a=t("insertdatetime_dateformat"),n=t("insertdatetime_timeformat"),r=t("insertdatetime_formats"),s=t("insertdatetime_element"),i="Sun Mon Tue Wed Thu Fri Sat Sun".split(" "),o="Sunday Monday Tuesday Wednesday Thursday Friday Saturday Sunday".split(" "),l="Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),m="January February March April May June July August September October November December".split(" "),c=(e,t)=>{if((e=""+e).length(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=t.replace("%D","%m/%d/%Y")).replace("%r","%I:%M:%S %p")).replace("%Y",""+a.getFullYear())).replace("%y",""+a.getYear())).replace("%m",c(a.getMonth()+1,2))).replace("%d",c(a.getDate(),2))).replace("%H",""+c(a.getHours(),2))).replace("%M",""+c(a.getMinutes(),2))).replace("%S",""+c(a.getSeconds(),2))).replace("%I",""+((a.getHours()+11)%12+1))).replace("%p",a.getHours()<12?"AM":"PM")).replace("%B",""+e.translate(m[a.getMonth()]))).replace("%b",""+e.translate(l[a.getMonth()]))).replace("%A",""+e.translate(o[a.getDay()]))).replace("%a",""+e.translate(i[a.getDay()]))).replace("%%","%"),u=(e,t)=>{if(s(e)){const a=d(e,t);let n;n=/%[HMSIp]/.test(t)?d(e,"%Y-%m-%dT%H:%M"):d(e,"%Y-%m-%d");const r=e.dom.getParent(e.selection.getStart(),"time");r?((e,t,a,n)=>{const r=e.dom.create("time",{datetime:a},n);e.dom.replace(r,t),e.selection.select(r,!0),e.selection.collapse(!1)})(e,r,n,a):e.insertContent('")}else e.insertContent(d(e,t))};var p=tinymce.util.Tools.resolve("tinymce.util.Tools");const g=e=>t=>{const a=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",a),a(),()=>{e.off("NodeChange",a)}};e.add("insertdatetime",(e=>{(e=>{const t=e.options.register;t("insertdatetime_dateformat",{processor:"string",default:e.translate("%Y-%m-%d")}),t("insertdatetime_timeformat",{processor:"string",default:e.translate("%H:%M:%S")}),t("insertdatetime_formats",{processor:"string[]",default:["%H:%M:%S","%Y-%m-%d","%I:%M:%S %p","%D"]}),t("insertdatetime_element",{processor:"boolean",default:!1})})(e),(e=>{e.addCommand("mceInsertDate",((t,n)=>{u(e,null!=n?n:a(e))})),e.addCommand("mceInsertTime",((t,a)=>{u(e,null!=a?a:n(e))}))})(e),(e=>{const t=r(e),a=(e=>{let t=e;return{get:()=>t,set:e=>{t=e}}})((e=>{const t=r(e);return t.length>0?t[0]:n(e)})(e)),s=t=>e.execCommand("mceInsertDate",!1,t);e.ui.registry.addSplitButton("insertdatetime",{icon:"insert-time",tooltip:"Insert date/time",select:e=>e===a.get(),fetch:a=>{a(p.map(t,(t=>({type:"choiceitem",text:d(e,t),value:t}))))},onAction:e=>{s(a.get())},onItemAction:(e,t)=>{a.set(t),s(t)},onSetup:g(e)});const i=e=>()=>{a.set(e),s(e)};e.ui.registry.addNestedMenuItem("insertdatetime",{icon:"insert-time",text:"Date/time",getSubmenuItems:()=>p.map(t,(t=>({type:"menuitem",text:d(e,t),onAction:i(t)}))),onSetup:g(e)})})(e)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/link/plugin.min.js b/public/libs/tinymce/plugins/link/plugin.min.js index 51deeec63fc..ab5bfdbf984 100644 --- a/public/libs/tinymce/plugins/link/plugin.min.js +++ b/public/libs/tinymce/plugins/link/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(l=o.constructor)||void 0===l?void 0:l.name)===r.name)?"string":t;var n,o,r,l})(t)===e,n=e=>t=>typeof t===e,o=t("string"),r=t("object"),l=t("array"),i=(null,e=>null===e);const a=n("boolean"),s=e=>!(e=>null==e)(e),c=n("function"),u=(e,t)=>{if(l(e)){for(let n=0,o=e.length;n{},d=(e,t)=>e===t;class m{constructor(e,t){this.tag=e,this.value=t}static some(e){return new m(!0,e)}static none(){return m.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?m.some(e(this.value)):m.none()}bind(e){return this.tag?e(this.value):m.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:m.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return s(e)?m.some(e):m.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}m.singletonNone=new m(!1);const h=Array.prototype.indexOf,f=Array.prototype.push,p=e=>{const t=[];for(let n=0,o=e.length;n{for(let n=0;ne.exists((e=>n(e,t))),x=e=>{const t=[],n=e=>{t.push(e)};for(let t=0;te?m.some(t):m.none(),b=e=>t=>t.options.get(e),_=b("link_assume_external_targets"),w=b("link_context_toolbar"),C=b("link_list"),O=b("link_default_target"),N=b("link_default_protocol"),A=b("link_target_list"),S=b("link_rel_list"),E=b("link_class_list"),T=b("link_title"),R=b("allow_unsafe_link_target"),P=b("link_quicklink");var L=tinymce.util.Tools.resolve("tinymce.util.Tools");const M=e=>o(e.value)?e.value:"",D=(e,t)=>{const n=[];return L.each(e,(e=>{const r=(e=>o(e.text)?e.text:o(e.title)?e.title:"")(e);if(void 0!==e.menu){const o=D(e.menu,t);n.push({text:r,items:o})}else{const o=t(e);n.push({text:r,value:o})}})),n},B=(e=M)=>t=>m.from(t).map((t=>D(t,e))),I=e=>B(M)(e),j=B,K=(e,t)=>n=>({name:e,type:"listbox",label:t,items:n}),U=M,q=Object.keys,F=Object.hasOwnProperty,V=(e,t)=>F.call(e,t);var $=tinymce.util.Tools.resolve("tinymce.dom.TreeWalker"),z=tinymce.util.Tools.resolve("tinymce.util.URI");const G=e=>s(e)&&"a"===e.nodeName.toLowerCase(),H=e=>G(e)&&!!Q(e),J=(e,t)=>{if(e.collapsed)return[];{const n=e.cloneContents(),o=n.firstChild,r=new $(o,n),l=[];let i=o;do{t(i)&&l.push(i)}while(i=r.next());return l}},W=e=>/^\w+:/i.test(e),Q=e=>{var t,n;return null!==(n=null!==(t=e.getAttribute("data-mce-href"))&&void 0!==t?t:e.getAttribute("href"))&&void 0!==n?n:""},X=(e,t)=>{const n=["noopener"],o=e?e.split(/\s+/):[],r=e=>e.filter((e=>-1===L.inArray(n,e))),l=t?(e=>(e=r(e)).length>0?e.concat(n):n)(o):r(o);return l.length>0?(e=>L.trim(e.sort().join(" ")))(l):""},Y=(e,t)=>(t=t||te(e.selection.getRng())[0]||e.selection.getNode(),le(t)?m.from(e.dom.select("a[href]",t)[0]):m.from(e.dom.getParent(t,"a[href]"))),Z=(e,t)=>Y(e,t).isSome(),ee=(e,t)=>t.fold((()=>e.getContent({format:"text"})),(e=>e.innerText||e.textContent||"")).replace(/\uFEFF/g,""),te=e=>J(e,H),ne=e=>L.grep(e,H),oe=e=>ne(e).length>0,re=e=>{const t=e.schema.getTextInlineElements();if(Y(e).exists((e=>e.hasAttribute("data-mce-block"))))return!1;const n=e.selection.getRng();return!!n.collapsed||0===J(n,(e=>1===e.nodeType&&!G(e)&&!V(t,e.nodeName.toLowerCase()))).length},le=e=>s(e)&&"FIGURE"===e.nodeName&&/\bimage\b/i.test(e.className),ie=(e,t,n)=>{const o=e.selection.getNode(),r=Y(e,o),l=((e,t)=>{const n={...t};if(0===S(e).length&&!R(e)){const e=X(n.rel,"_blank"===n.target);n.rel=e||null}return m.from(n.target).isNone()&&!1===A(e)&&(n.target=O(e)),n.href=((e,t)=>"http"!==t&&"https"!==t||W(e)?e:t+"://"+e)(n.href,_(e)),n})(e,(e=>{return t=["title","rel","class","target"],n=(t,n)=>(e[n].each((e=>{t[n]=e.length>0?e:null})),t),o={href:e.href},((e,t)=>{for(let n=0,o=e.length;n{o=n(o,e)})),o;var t,n,o})(n));e.undoManager.transact((()=>{n.href===t.href&&t.attach(),r.fold((()=>{((e,t,n,o)=>{const r=e.dom;le(t)?ge(r,t,o):n.fold((()=>{e.execCommand("mceInsertLink",!1,o)}),(t=>{e.insertContent(r.createHTML("a",o,r.encode(t)))}))})(e,o,n.text,l)}),(t=>{e.focus(),((e,t,n,o)=>{n.each((e=>{V(t,"innerText")?t.innerText=e:t.textContent=e})),e.dom.setAttribs(t,o),e.selection.select(t)})(e,t,n.text,l)}))}))},ae=e=>{const{class:t,href:n,rel:o,target:r,text:l,title:a}=e;return((e,t)=>{const n={};var o;return((e,t,n,o)=>{((e,t)=>{const n=q(e);for(let o=0,r=n.length;o{(t(e,r)?n:o)(e,r)}))})(e,((e,t)=>!1===i(e)),(o=n,(e,t)=>{o[t]=e}),g),n})({class:t.getOrNull(),href:n,rel:o.getOrNull(),target:r.getOrNull(),text:l.getOrNull(),title:a.getOrNull()})},se=(e,t,n)=>{const o=((e,t)=>{const n=e.options.get,o={allow_html_data_urls:n("allow_html_data_urls"),allow_script_urls:n("allow_script_urls"),allow_svg_data_urls:n("allow_svg_data_urls")},r=t.href;return{...t,href:z.isDomSafe(r,"a",o)?r:""}})(e,n);e.hasPlugin("rtc",!0)?e.execCommand("createlink",!1,ae(o)):ie(e,t,o)},ce=e=>{e.hasPlugin("rtc",!0)?e.execCommand("unlink"):(e=>{e.undoManager.transact((()=>{const t=e.selection.getNode();le(t)?ue(e,t):(e=>{const t=e.dom,n=e.selection,o=n.getBookmark(),r=n.getRng().cloneRange(),l=t.getParent(r.startContainer,"a[href]",e.getBody()),i=t.getParent(r.endContainer,"a[href]",e.getBody());l&&r.setStartBefore(l),i&&r.setEndAfter(i),n.setRng(r),e.execCommand("unlink"),n.moveToBookmark(o)})(e),e.focus()}))})(e)},ue=(e,t)=>{var n;const o=e.dom.select("img",t)[0];if(o){const r=e.dom.getParents(o,"a[href]",t)[0];r&&(null===(n=r.parentNode)||void 0===n||n.insertBefore(o,r),e.dom.remove(r))}},ge=(e,t,n)=>{var o;const r=e.select("img",t)[0];if(r){const t=e.create("a",n);null===(o=r.parentNode)||void 0===o||o.insertBefore(t,r),t.appendChild(r)}},de=(e,t)=>k(t,(t=>(e=>{return V(t=e,n="items")&&void 0!==t[n]&&null!==t[n];var t,n})(t)?de(e,t.items):y(t.value===e,t))),me=(e,t)=>{const n={text:e.text,title:e.title},o=(e,o)=>{const r=(l=t,i=o,"link"===i?l.link:"anchor"===i?l.anchor:m.none()).getOr([]);var l,i;return((e,t,n,o)=>{const r=o[t],l=e.length>0;return void 0!==r?de(r,n).map((t=>({url:{value:t.value,meta:{text:l?e:t.text,attach:g}},text:l?e:t.text}))):m.none()})(n.text,o,r,e)};return{onChange:(e,t)=>{const r=t.name;return"url"===r?(e=>{const t=(o=e.url,y(n.text.length<=0,m.from(null===(r=o.meta)||void 0===r?void 0:r.text).getOr(o.value)));var o,r;const l=(e=>{var t;return y(n.title.length<=0,m.from(null===(t=e.meta)||void 0===t?void 0:t.title).getOr(""))})(e.url);return t.isSome()||l.isSome()?m.some({...t.map((e=>({text:e}))).getOr({}),...l.map((e=>({title:e}))).getOr({})}):m.none()})(e()):((e,t)=>h.call(e,t))(["anchor","link"],r)>-1?o(e(),r):"text"===r||"title"===r?(n[r]=e()[r],m.none()):m.none()}}};var he=tinymce.util.Tools.resolve("tinymce.util.Delay");const fe=e=>{const t=e.href;return t.indexOf("@")>0&&-1===t.indexOf("/")&&-1===t.indexOf("mailto:")?m.some({message:"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?",preprocess:e=>({...e,href:"mailto:"+t})}):m.none()},pe=(e,t)=>n=>{const o=n.href;return 1===e&&!W(o)||0===e&&/^\s*www(\.|\d\.)/i.test(o)?m.some({message:`The URL you entered seems to be an external link. Do you want to add the required ${t}:// prefix?`,preprocess:e=>({...e,href:t+"://"+o})}):m.none()},ke=e=>{const t=e.dom.select("a:not([href])"),n=p(((e,t)=>{const n=e.length,o=new Array(n);for(let r=0;r{const t=e.name||e.id;return t?[{text:t,value:"#"+t}]:[]})));return n.length>0?m.some([{text:"None",value:""}].concat(n)):m.none()},ve=e=>{const t=E(e);return t.length>0?I(t):m.none()},xe=e=>{try{return m.some(JSON.parse(e))}catch(e){return m.none()}},ye=(e,t)=>{const n=S(e);if(n.length>0){const o=v(t,"_blank"),r=e=>X(U(e),o);return(!1===R(e)?j(r):I)(n)}return m.none()},be=[{text:"Current window",value:""},{text:"New window",value:"_blank"}],_e=e=>{const t=A(e);return l(t)?I(t).orThunk((()=>m.some(be))):!1===t?m.none():m.some(be)},we=(e,t,n)=>{const o=e.getAttrib(t,n);return null!==o&&o.length>0?m.some(o):m.none()},Ce=(e,t)=>(e=>{const t=t=>e.convertURL(t.value||t.url||"","href"),n=C(e);return new Promise((e=>{o(n)?fetch(n).then((e=>e.ok?e.text().then(xe):Promise.reject())).then(e,(()=>e(m.none()))):c(n)?n((t=>e(m.some(t)))):e(m.from(n))})).then((e=>e.bind(j(t)).map((e=>e.length>0?[{text:"None",value:""}].concat(e):e))))})(e).then((n=>{const o=((e,t)=>{const n=e.dom,o=re(e)?m.some(ee(e.selection,t)):m.none(),r=t.bind((e=>m.from(n.getAttrib(e,"href")))),l=t.bind((e=>m.from(n.getAttrib(e,"target")))),i=t.bind((e=>we(n,e,"rel"))),a=t.bind((e=>we(n,e,"class")));return{url:r,text:o,title:t.bind((e=>we(n,e,"title"))),target:l,rel:i,linkClass:a}})(e,t);return{anchor:o,catalogs:{targets:_e(e),rels:ye(e,o.target),classes:ve(e),anchor:ke(e),link:n},optNode:t,flags:{titleEnabled:T(e)}}})),Oe=e=>{const t=(e=>{const t=Y(e);return Ce(e,t)})(e);t.then((t=>{const n=((e,t)=>n=>{const o=n.getData();if(!o.url.value)return ce(e),void n.close();const r=e=>m.from(o[e]).filter((n=>!v(t.anchor[e],n))),l={href:o.url.value,text:r("text"),target:r("target"),rel:r("rel"),class:r("linkClass"),title:r("title")},i={href:o.url.value,attach:void 0!==o.url.meta&&o.url.meta.attach?o.url.meta.attach:g};((e,t)=>k([fe,pe(_(e),N(e))],(e=>e(t))).fold((()=>Promise.resolve(t)),(n=>new Promise((o=>{((e,t,n)=>{const o=e.selection.getRng();he.setEditorTimeout(e,(()=>{e.windowManager.confirm(t,(t=>{e.selection.setRng(o),n(t)}))}))})(e,n.message,(e=>{o(e?n.preprocess(t):t)}))})))))(e,l).then((t=>{se(e,i,t)})),n.close()})(e,t);return((e,t,n)=>{const o=e.anchor.text.map((()=>({name:"text",type:"input",label:"Text to display"}))).toArray(),r=e.flags.titleEnabled?[{name:"title",type:"input",label:"Title"}]:[],l=((e,t)=>{const n=e.anchor,o=n.url.getOr("");return{url:{value:o,meta:{original:{value:o}}},text:n.text.getOr(""),title:n.title.getOr(""),anchor:o,link:o,rel:n.rel.getOr(""),target:n.target.or(t).getOr(""),linkClass:n.linkClass.getOr("")}})(e,m.from(O(n))),i=e.catalogs,a=me(l,i);return{title:"Insert/Edit Link",size:"normal",body:{type:"panel",items:p([[{name:"url",type:"urlinput",filetype:"file",label:"URL",picker_text:"Browse links"}],o,r,x([i.anchor.map(K("anchor","Anchors")),i.rels.map(K("rel","Rel")),i.targets.map(K("target","Open link in...")),i.link.map(K("link","Link list")),i.classes.map(K("linkClass","Class"))])])},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:l,onChange:(e,{name:t})=>{a.onChange(e.getData,{name:t}).each((t=>{e.setData(t)}))},onSubmit:t}})(t,n,e)})).then((t=>{e.windowManager.open(t)}))};var Ne=tinymce.util.Tools.resolve("tinymce.util.VK");const Ae=(e,t)=>e.dom.getParent(t,"a[href]"),Se=e=>Ae(e,e.selection.getStart()),Ee=(e,t)=>{if(t){const n=Q(t);if(/^#/.test(n)){const t=e.dom.select(n);t.length&&e.selection.scrollIntoView(t[0],!0)}else(e=>{const t=document.createElement("a");t.target="_blank",t.href=e,t.rel="noreferrer noopener";const n=document.createEvent("MouseEvents");n.initMouseEvent("click",!0,!0,window,0,0,0,0,0,!1,!1,!1,!1,0,null),((e,t)=>{document.body.appendChild(e),e.dispatchEvent(t),document.body.removeChild(e)})(t,n)})(t.href)}},Te=e=>()=>{e.execCommand("mceLink",!1,{dialog:!0})},Re=e=>()=>{Ee(e,Se(e))},Pe=(e,t)=>(e.on("NodeChange",t),()=>e.off("NodeChange",t)),Le=e=>t=>{const n=()=>{t.setActive(!e.mode.isReadOnly()&&Z(e,e.selection.getNode())),t.setEnabled(e.selection.isEditable())};return n(),Pe(e,n)},Me=e=>t=>{const n=()=>{t.setEnabled(e.selection.isEditable())};return n(),Pe(e,n)},De=e=>t=>{const n=()=>t.setEnabled((e=>1===(e.selection.isCollapsed()?ne(e.dom.getParents(e.selection.getStart())):te(e.selection.getRng())).length)(e));return n(),Pe(e,n)},Be=e=>t=>{const n=e.dom.getParents(e.selection.getStart()),o=n=>{t.setEnabled((t=>{return oe(t)||(n=e.selection.getRng(),te(n).length>0);var n})(n)&&e.selection.isEditable())};return o(n),Pe(e,(e=>o(e.parents)))};e.add("link",(e=>{(e=>{const t=e.options.register;t("link_assume_external_targets",{processor:e=>{const t=o(e)||a(e);return t?!0===e?{value:1,valid:t}:"http"===e||"https"===e?{value:e,valid:t}:{value:0,valid:t}:{valid:!1,message:"Must be a string or a boolean."}},default:!1}),t("link_context_toolbar",{processor:"boolean",default:!1}),t("link_list",{processor:e=>o(e)||c(e)||u(e,r)}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"}),t("link_target_list",{processor:e=>a(e)||u(e,r),default:!0}),t("link_rel_list",{processor:"object[]",default:[]}),t("link_class_list",{processor:"object[]",default:[]}),t("link_title",{processor:"boolean",default:!0}),t("allow_unsafe_link_target",{processor:"boolean",default:!1}),t("link_quicklink",{processor:"boolean",default:!1})})(e),(e=>{e.ui.registry.addToggleButton("link",{icon:"link",tooltip:"Insert/edit link",onAction:Te(e),onSetup:Le(e)}),e.ui.registry.addButton("openlink",{icon:"new-tab",tooltip:"Open link",onAction:Re(e),onSetup:De(e)}),e.ui.registry.addButton("unlink",{icon:"unlink",tooltip:"Remove link",onAction:()=>ce(e),onSetup:Be(e)})})(e),(e=>{e.ui.registry.addMenuItem("openlink",{text:"Open link",icon:"new-tab",onAction:Re(e),onSetup:De(e)}),e.ui.registry.addMenuItem("link",{icon:"link",text:"Link...",shortcut:"Meta+K",onSetup:Me(e),onAction:Te(e)}),e.ui.registry.addMenuItem("unlink",{icon:"unlink",text:"Remove link",onAction:()=>ce(e),onSetup:Be(e)})})(e),(e=>{e.ui.registry.addContextMenu("link",{update:t=>e.dom.isEditable(t)?oe(e.dom.getParents(t,"a"))?"link unlink openlink":"link":""})})(e),(e=>{const t=t=>{const n=e.selection.getNode();return t.setEnabled(Z(e,n)),g};e.ui.registry.addContextForm("quicklink",{launch:{type:"contextformtogglebutton",icon:"link",tooltip:"Link",onSetup:Le(e)},label:"Link",predicate:t=>w(e)&&Z(e,t),initValue:()=>Y(e).fold((()=>""),Q),commands:[{type:"contextformtogglebutton",icon:"link",tooltip:"Link",primary:!0,onSetup:t=>{const n=e.selection.getNode();return t.setActive(Z(e,n)),Le(e)(t)},onAction:t=>{const n=t.getValue(),o=(t=>{const n=Y(e),o=re(e);if(n.isNone()&&o){const o=ee(e.selection,n);return y(0===o.length,t)}return m.none()})(n);se(e,{href:n,attach:g},{href:n,text:o,title:m.none(),rel:m.none(),target:m.none(),class:m.none()}),(e=>{e.selection.collapse(!1)})(e),t.hide()}},{type:"contextformbutton",icon:"unlink",tooltip:"Remove link",onSetup:t,onAction:t=>{ce(e),t.hide()}},{type:"contextformbutton",icon:"new-tab",tooltip:"Open link",onSetup:t,onAction:t=>{Re(e)(),t.hide()}}]})})(e),(e=>{e.on("click",(t=>{const n=Ae(e,t.target);n&&Ne.metaKeyPressed(t)&&(t.preventDefault(),Ee(e,n))})),e.on("keydown",(t=>{if(!t.isDefaultPrevented()&&13===t.keyCode&&(e=>!0===e.altKey&&!1===e.shiftKey&&!1===e.ctrlKey&&!1===e.metaKey)(t)){const n=Se(e);n&&(t.preventDefault(),Ee(e,n))}}))})(e),(e=>{e.addCommand("mceLink",((t,n)=>{!0!==(null==n?void 0:n.dialog)&&P(e)?e.dispatch("contexttoolbar-show",{toolbarKey:"quicklink"}):Oe(e)}))})(e),(e=>{e.addShortcut("Meta+K","",(()=>{e.execCommand("mceLink")}))})(e)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/lists/plugin.min.js b/public/libs/tinymce/plugins/lists/plugin.min.js index 5d3b73577ad..33edd00017d 100644 --- a/public/libs/tinymce/plugins/lists/plugin.min.js +++ b/public/libs/tinymce/plugins/lists/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===r.name)?"string":t;var n,o,r,s})(t)===e,n=e=>t=>typeof t===e,o=t("string"),r=t("object"),s=t("array"),i=n("boolean"),l=e=>!(e=>null==e)(e),a=n("function"),d=n("number"),c=()=>{},m=e=>()=>e,u=(e,t)=>e===t,p=e=>t=>!e(t),g=m(!1);class h{constructor(e,t){this.tag=e,this.value=t}static some(e){return new h(!0,e)}static none(){return h.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?h.some(e(this.value)):h.none()}bind(e){return this.tag?e(this.value):h.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:h.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return l(e)?h.some(e):h.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}h.singletonNone=new h(!1);const f=Array.prototype.slice,y=Array.prototype.indexOf,v=Array.prototype.push,C=(e,t)=>{return n=e,o=t,y.call(n,o)>-1;var n,o},b=(e,t)=>{for(let n=0,o=e.length;n{const n=e.length,o=new Array(n);for(let r=0;r{for(let n=0,o=e.length;n{const n=[];for(let o=0,r=e.length;o(S(e,((e,o)=>{n=t(n,e,o)})),n),A=(e,t,n)=>{for(let o=0,r=e.length;oA(e,t,g),x=(e,t)=>(e=>{const t=[];for(let n=0,o=e.length;n{const t=f.call(e,0);return t.reverse(),t},w=(e,t)=>t>=0&&tw(e,0),D=e=>w(e,e.length-1),B=(e,t)=>{const n=[],o=a(t)?e=>b(n,(n=>t(n,e))):e=>C(n,e);for(let t=0,r=e.length;te.exists((e=>n(e,t))),P=(e,t,n)=>e.isSome()&&t.isSome()?h.some(n(e.getOrDie(),t.getOrDie())):h.none(),I=e=>{if(null==e)throw new Error("Node cannot be null or undefined");return{dom:e}},R=(e,t)=>{const n=(t||document).createElement("div");if(n.innerHTML=e,!n.hasChildNodes()||n.childNodes.length>1){const t="HTML does not have a single root node";throw console.error(t,e),new Error(t)}return I(n.childNodes[0])},U=(e,t)=>{const n=(t||document).createElement(e);return I(n)},$=I,_=(e,t)=>{const n=e.dom;if(1!==n.nodeType)return!1;{const e=n;if(void 0!==e.matches)return e.matches(t);if(void 0!==e.msMatchesSelector)return e.msMatchesSelector(t);if(void 0!==e.webkitMatchesSelector)return e.webkitMatchesSelector(t);if(void 0!==e.mozMatchesSelector)return e.mozMatchesSelector(t);throw new Error("Browser lacks native selectors")}},H=(e,t)=>e.dom===t.dom,F=_,V="undefined"!=typeof window?window:Function("return this;")(),j=(e,t)=>((e,t)=>{let n=null!=t?t:V;for(let t=0;t{const t=j("ownerDocument.defaultView",e);return r(e)&&((e=>((e,t)=>{const n=((e,t)=>j(e,t))(e,t);if(null==n)throw new Error(e+" not available on this browser");return n})("HTMLElement",e))(t).prototype.isPrototypeOf(e)||/^HTML\w*Element$/.test(K(e).constructor.name))},Q=e=>e.dom.nodeName.toLowerCase(),W=e=>e.dom.nodeType,q=e=>t=>W(t)===e,Z=e=>G(e)&&z(e.dom),G=q(1),J=q(3),X=q(9),Y=q(11),ee=e=>t=>G(t)&&Q(t)===e,te=e=>h.from(e.dom.parentNode).map($),ne=e=>N(e.dom.childNodes,$),oe=(e,t)=>{const n=e.dom.childNodes;return h.from(n[t]).map($)},re=e=>oe(e,0),se=e=>oe(e,e.dom.childNodes.length-1),ie=a(Element.prototype.attachShadow)&&a(Node.prototype.getRootNode)?e=>$(e.dom.getRootNode()):e=>X(e)?e:$(e.dom.ownerDocument),le=e=>$(e.dom.host),ae=e=>{const t=J(e)?e.dom.parentNode:e.dom;if(null==t||null===t.ownerDocument)return!1;const n=t.ownerDocument;return(e=>{const t=ie(e);return Y(n=t)&&l(n.dom.host)?h.some(t):h.none();var n})($(t)).fold((()=>n.body.contains(t)),(o=ae,r=le,e=>o(r(e))));var o,r};var de=(e,t,n,o,r)=>e(n,o)?h.some(n):a(r)&&r(n)?h.none():t(n,o,r);const ce=(e,t,n)=>{let o=e.dom;const r=a(n)?n:g;for(;o.parentNode;){o=o.parentNode;const e=$(o);if(t(e))return h.some(e);if(r(e))break}return h.none()},me=(e,t,n)=>de(((e,t)=>t(e)),ce,e,t,n),ue=(e,t,n)=>ce(e,(e=>_(e,t)),n),pe=(e,t)=>{te(e).each((n=>{n.dom.insertBefore(t.dom,e.dom)}))},ge=(e,t)=>{e.dom.appendChild(t.dom)},he=(e,t)=>{S(t,(t=>{ge(e,t)}))},fe=e=>{e.dom.textContent="",S(ne(e),(e=>{ye(e)}))},ye=e=>{const t=e.dom;null!==t.parentNode&&t.parentNode.removeChild(t)};var ve=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),Ce=tinymce.util.Tools.resolve("tinymce.dom.TreeWalker"),be=tinymce.util.Tools.resolve("tinymce.util.VK");const Ne=e=>N(e,$),Se=Object.keys,Le=(e,t)=>{const n=Se(e);for(let o=0,r=n.length;o{const n=e.dom;Le(t,((e,t)=>{((e,t,n)=>{if(!(o(n)||i(n)||d(n)))throw console.error("Invalid call to Attribute.set. Key ",t,":: Value ",n,":: Element ",e),new Error("Attribute value was not simple");e.setAttribute(t,n+"")})(n,t,e)}))},Ae=e=>O(e.dom.attributes,((e,t)=>(e[t.name]=t.value,e)),{}),Te=e=>((e,t)=>$(e.dom.cloneNode(!0)))(e),xe=(e,t)=>{const n=((e,t)=>{const n=U(t),o=Ae(e);return Oe(n,o),n})(e,t);var o,r;r=n,(e=>h.from(e.dom.nextSibling).map($))(o=e).fold((()=>{te(o).each((e=>{ge(e,r)}))}),(e=>{pe(e,r)}));const s=ne(e);return he(n,s),ye(e),n};var Ee=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),we=tinymce.util.Tools.resolve("tinymce.util.Tools");const ke=e=>t=>l(t)&&t.nodeName.toLowerCase()===e,De=e=>t=>l(t)&&e.test(t.nodeName),Be=e=>l(e)&&3===e.nodeType,Me=e=>l(e)&&1===e.nodeType,Pe=De(/^(OL|UL|DL)$/),Ie=De(/^(OL|UL)$/),Re=ke("ol"),Ue=De(/^(LI|DT|DD)$/),$e=De(/^(DT|DD)$/),_e=De(/^(TH|TD)$/),He=ke("br"),Fe=(e,t)=>l(t)&&t.nodeName in e.schema.getTextBlockElements(),Ve=(e,t)=>l(e)&&e.nodeName in t,je=(e,t)=>l(t)&&t.nodeName in e.schema.getVoidElements(),Ke=(e,t,n)=>{const o=e.isEmpty(t);return!(n&&e.select("span[data-mce-type=bookmark]",t).length>0)&&o},ze=(e,t)=>e.isChildOf(t,e.getRoot()),Qe=e=>t=>t.options.get(e),We=Qe("lists_indent_on_tab"),qe=Qe("forced_root_block"),Ze=Qe("forced_root_block_attrs"),Ge=(e,t)=>{const n=e.dom,o=e.schema.getBlockElements(),r=n.createFragment(),s=qe(e),i=Ze(e);let l,a,d=!1;for(a=n.create(s,i),Ve(t.firstChild,o)||r.appendChild(a);l=t.firstChild;){const e=l.nodeName;d||"SPAN"===e&&"bookmark"===l.getAttribute("data-mce-type")||(d=!0),Ve(l,o)?(r.appendChild(l),a=null):(a||(a=n.create(s,i),r.appendChild(a)),a.appendChild(l))}return!d&&a&&a.appendChild(n.create("br",{"data-mce-bogus":"1"})),r},Je=Ee.DOM,Xe=ee("dd"),Ye=ee("dt"),et=(e,t)=>{var n;Xe(t)?xe(t,"dt"):Ye(t)&&(n=t,h.from(n.dom.parentElement).map($)).each((n=>((e,t,n)=>{const o=Je.select('span[data-mce-type="bookmark"]',t),r=Ge(e,n),s=Je.createRng();s.setStartAfter(n),s.setEndAfter(t);const i=s.extractContents();for(let t=i.firstChild;t;t=t.firstChild)if("LI"===t.nodeName&&e.dom.isEmpty(t)){Je.remove(t);break}e.dom.isEmpty(i)||Je.insertAfter(i,t),Je.insertAfter(r,t);const l=n.parentElement;l&&Ke(e.dom,l)&&(e=>{const t=e.parentNode;t&&we.each(o,(e=>{t.insertBefore(e,n.parentNode)})),Je.remove(e)})(l),Je.remove(n),Ke(e.dom,t)&&Je.remove(t)})(e,n.dom,t.dom)))},tt=e=>{Ye(e)&&xe(e,"dd")},nt=(e,t)=>{if(Be(e))return{container:e,offset:t};const n=ve.getNode(e,t);return Be(n)?{container:n,offset:t>=e.childNodes.length?n.data.length:0}:n.previousSibling&&Be(n.previousSibling)?{container:n.previousSibling,offset:n.previousSibling.data.length}:n.nextSibling&&Be(n.nextSibling)?{container:n.nextSibling,offset:0}:{container:e,offset:t}},ot=e=>{const t=e.cloneRange(),n=nt(e.startContainer,e.startOffset);t.setStart(n.container,n.offset);const o=nt(e.endContainer,e.endOffset);return t.setEnd(o.container,o.offset),t},rt=["OL","UL","DL"],st=rt.join(","),it=(e,t)=>{const n=t||e.selection.getStart(!0);return e.dom.getParent(n,st,dt(e,n))},lt=e=>{const t=e.selection.getSelectedBlocks();return L(((e,t)=>{const n=we.map(t,(t=>e.dom.getParent(t,"li,dd,dt",dt(e,t))||t));return B(n)})(e,t),Ue)},at=(e,t)=>{const n=e.dom.getParents(t,"TD,TH");return n.length>0?n[0]:e.getBody()},dt=(e,t)=>{const n=e.dom.getParents(t,e.dom.isBlock),o=T(n,(t=>{return n=e.schema,!Pe(o=t)&&!Ue(o)&&b(rt,(e=>n.isValidChild(o.nodeName,e)));var n,o}));return o.getOr(e.getBody())},ct=(e,t)=>{const n=e.dom.getParents(t,"ol,ul",dt(e,t));return D(n)},mt=(e,t)=>{const n=N(t,(t=>ct(e,t).getOr(t)));return B(n)},ut=e=>/\btox\-/.test(e.className),pt=(e,t)=>A(e,Pe,_e).exists((e=>e.nodeName===t&&!ut(e))),gt=(e,t)=>null!==t&&!e.dom.isEditable(t),ht=(e,t)=>{const n=e.dom.getParent(t,"ol,ul,dl");return gt(e,n)},ft=(e,t)=>{const n=e.selection.getNode();return t({parents:e.dom.getParents(n),element:n}),e.on("NodeChange",t),()=>e.off("NodeChange",t)},yt=(e,t)=>{const n=(t||document).createDocumentFragment();return S(e,(e=>{n.appendChild(e.dom)})),$(n)},vt=(e,t,n)=>e.dispatch("ListMutation",{action:t,element:n}),Ct=(bt=/^\s+|\s+$/g,e=>e.replace(bt,""));var bt;const Nt=(e,t,n)=>{((e,t,n)=>{if(!o(n))throw console.error("Invalid call to CSS.set. Property ",t,":: Value ",n,":: Element ",e),new Error("CSS value must be a string: "+n);(e=>void 0!==e.style&&a(e.style.getPropertyValue))(e)&&e.style.setProperty(t,n)})(e.dom,t,n)},St=e=>F(e,"OL,UL"),Lt=e=>re(e).exists(St),Ot=e=>"listAttributes"in e,At=e=>"isComment"in e,Tt=e=>e.depth>0,xt=e=>e.isSelected,Et=e=>{const t=ne(e),n=se(e).exists(St)?t.slice(0,-1):t;return N(n,Te)},wt=(e,t)=>{ge(e.item,t.list)},kt=(e,t)=>{const n={list:U(t,e),item:U("li",e)};return ge(n.list,n.item),n},Dt=(e,t,n)=>{const o=t.slice(0,n.depth);return D(o).each((t=>{if(Ot(n)){const o=((e,t,n)=>{const o=U("li",e);return Oe(o,t),he(o,n),o})(e,n.itemAttributes,n.content);((e,t)=>{ge(e.list,t),e.item=t})(t,o),((e,t)=>{Q(e.list)!==t.listType&&(e.list=xe(e.list,t.listType)),Oe(e.list,t.listAttributes)})(t,n)}else if((e=>"isFragment"in e)(n))he(t.item,n.content);else{const e=R(`\x3c!--${n.content}--\x3e`);ge(t.list,e)}})),o},Bt=(e,t)=>{let n=h.none();const o=O(t,((t,o,r)=>At(o)?0===r?(n=h.some(o),t):Dt(e,t,o):o.depth>t.length?((e,t,n)=>{const o=((e,t,n)=>{const o=[];for(let r=0;r{for(let t=1;t{for(let t=0;t{Ot(t)&&(Oe(e.list,t.listAttributes),Oe(e.item,t.itemAttributes)),he(e.item,t.content)}))})(o,n),r=o,P(D(t),k(r),wt),t.concat(o)})(e,t,o):Dt(e,t,o)),[]);return n.each((e=>{const t=R(`\x3c!--${e.content}--\x3e`);k(o).each((e=>{((e,t)=>{re(e).fold((()=>{ge(e,t)}),(n=>{e.dom.insertBefore(t.dom,n.dom)}))})(e.list,t)}))})),k(o).map((e=>e.list))},Mt=e=>(S(e,((t,n)=>{((e,t)=>{const n=e[t].depth,o=e=>e.depth===n&&!e.dirty,r=e=>e.depthA(e.slice(t+1),o,r)))})(e,n).fold((()=>{t.dirty&&Ot(t)&&(e=>{e.listAttributes=((e,t)=>{const n={};var o;return((e,t,n,o)=>{Le(e,((e,r)=>{(t(e,r)?n:o)(e,r)}))})(e,t,(o=n,(e,t)=>{o[t]=e}),c),n})(e.listAttributes,((e,t)=>"start"!==t))})(t)}),(e=>{return o=e,void(Ot(n=t)&&Ot(o)&&(n.listType=o.listType,n.listAttributes={...o.listAttributes}));var n,o}))})),e),Pt=(e,t,n,o)=>{var r,s;if(8===W(s=o)||"#comment"===Q(s))return[{depth:e+1,content:null!==(r=o.dom.nodeValue)&&void 0!==r?r:"",dirty:!1,isSelected:!1,isComment:!0}];t.each((e=>{H(e.start,o)&&n.set(!0)}));const i=((e,t,n)=>te(e).filter(G).map((o=>({depth:t,dirty:!1,isSelected:n,content:Et(e),itemAttributes:Ae(e),listAttributes:Ae(o),listType:Q(o),isInPreviousLi:!1}))))(o,e,n.get());t.each((e=>{H(e.end,o)&&n.set(!1)}));const l=se(o).filter(St).map((o=>Rt(e,t,n,o))).getOr([]);return i.toArray().concat(l)},It=(e,t,n,o)=>re(o).filter(St).fold((()=>Pt(e,t,n,o)),(r=>{const s=O(ne(o),((o,s,i)=>{if(0===i)return o;if(F(s,"LI"))return o.concat(Pt(e,t,n,s));{const t={isFragment:!0,depth:e,content:[s],isSelected:!1,dirty:!1,parentListType:Q(r)};return o.concat(t)}}),[]);return Rt(e,t,n,r).concat(s)})),Rt=(e,t,n,o)=>x(ne(o),(o=>(St(o)?Rt:It)(e+1,t,n,o))),Ut=(e,t,n)=>{const o=((e,t)=>{const n=(e=>{let t=!1;return{get:()=>t,set:e=>{t=e}}})();return N(e,(e=>({sourceList:e,entries:Rt(0,t,n,e)})))})(t,(e=>{const t=N(lt(e),$);return P(T(t,p(Lt)),T(E(t),p(Lt)),((e,t)=>({start:e,end:t})))})(e));S(o,(t=>{((e,t)=>{S(L(e,xt),(e=>((e,t)=>{switch(e){case"Indent":t.depth++;break;case"Outdent":t.depth--;break;case"Flatten":t.depth=0}t.dirty=!0})(t,e)))})(t.entries,n);const o=((e,t)=>x(((e,t)=>{if(0===e.length)return[];{let n=t(e[0]);const o=[];let r=[];for(let s=0,i=e.length;sk(t).exists(Tt)?((e,t)=>{const n=Mt(t);return Bt(e.contentDocument,n).toArray()})(e,t):((e,t)=>{const n=Mt(t);return N(n,(t=>{const n=At(t)?yt([R(`\x3c!--${t.content}--\x3e`)]):yt(t.content);return $(Ge(e,n.dom))}))})(e,t))))(e,t.entries);var r;S(o,(t=>{vt(e,"Indent"===n?"IndentList":"OutdentList",t.dom)})),r=t.sourceList,S(o,(e=>{pe(r,e)})),ye(t.sourceList)}))},$t=(e,t)=>{const n=Ne((e=>{const t=(e=>{const t=ct(e,e.selection.getStart()),n=L(e.selection.getSelectedBlocks(),Ie);return t.toArray().concat(n)})(e),n=(e=>{const t=e.selection.getStart();return e.dom.getParents(t,"ol,ul",dt(e,t))})(e);return T(n,(e=>{return t=$(e),te(t).exists((e=>Ue(e.dom)&&re(e).exists((e=>!Pe(e.dom)))&&se(e).exists((e=>!Pe(e.dom)))));var t})).fold((()=>mt(e,t)),(e=>[e]))})(e)),o=Ne((e=>L(lt(e),$e))(e));let r=!1;if(n.length||o.length){const s=e.selection.getBookmark();Ut(e,n,t),((e,t,n)=>{S(n,"Indent"===t?tt:t=>et(e,t))})(e,t,o),e.selection.moveToBookmark(s),e.selection.setRng(ot(e.selection.getRng())),e.nodeChanged(),r=!0}return r},_t=(e,t)=>!(e=>{const t=it(e);return gt(e,t)})(e)&&$t(e,t),Ht=e=>_t(e,"Indent"),Ft=e=>_t(e,"Outdent"),Vt=e=>_t(e,"Flatten"),jt=e=>"\ufeff"===e;var Kt=tinymce.util.Tools.resolve("tinymce.dom.BookmarkManager");const zt=Ee.DOM,Qt=e=>{const t={},n=n=>{let o=e[n?"startContainer":"endContainer"],r=e[n?"startOffset":"endOffset"];if(Me(o)){const e=zt.create("span",{"data-mce-type":"bookmark"});o.hasChildNodes()?(r=Math.min(r,o.childNodes.length-1),n?o.insertBefore(e,o.childNodes[r]):zt.insertAfter(e,o.childNodes[r])):o.appendChild(e),o=e,r=0}t[n?"startContainer":"endContainer"]=o,t[n?"startOffset":"endOffset"]=r};return n(!0),e.collapsed||n(),t},Wt=e=>{const t=t=>{let n=e[t?"startContainer":"endContainer"],o=e[t?"startOffset":"endOffset"];if(n){if(Me(n)&&n.parentNode){const e=n;o=(e=>{var t;let n=null===(t=e.parentNode)||void 0===t?void 0:t.firstChild,o=0;for(;n;){if(n===e)return o;Me(n)&&"bookmark"===n.getAttribute("data-mce-type")||o++,n=n.nextSibling}return-1})(n),n=n.parentNode,zt.remove(e),!n.hasChildNodes()&&zt.isBlock(n)&&n.appendChild(zt.create("br"))}e[t?"startContainer":"endContainer"]=n,e[t?"startOffset":"endOffset"]=o}};t(!0),t();const n=zt.createRng();return n.setStart(e.startContainer,e.startOffset),e.endContainer&&n.setEnd(e.endContainer,e.endOffset),ot(n)},qt=e=>{switch(e){case"UL":return"ToggleUlList";case"OL":return"ToggleOlList";case"DL":return"ToggleDLList"}},Zt=(e,t)=>{we.each(t,((t,n)=>{e.setAttribute(n,t)}))},Gt=(e,t,n)=>{((e,t,n)=>{const o=n["list-style-type"]?n["list-style-type"]:null;e.setStyle(t,"list-style-type",o)})(e,t,n),((e,t,n)=>{Zt(t,n["list-attributes"]),we.each(e.select("li",t),(e=>{Zt(e,n["list-item-attributes"])}))})(e,t,n)},Jt=(e,t)=>l(t)&&!Ve(t,e.schema.getBlockElements()),Xt=(e,t,n,o)=>{let r=t[n?"startContainer":"endContainer"];const s=t[n?"startOffset":"endOffset"];Me(r)&&(r=r.childNodes[Math.min(s,r.childNodes.length-1)]||r),!n&&He(r.nextSibling)&&(r=r.nextSibling);const i=(t,n)=>{var r;const s=new Ce(t,(t=>{for(;!e.dom.isBlock(t)&&t.parentNode&&o!==t;)t=t.parentNode;return t})(t)),i=n?"next":"prev";let l;for(;l=s[i]();)if(!je(e,l)&&!jt(l.textContent)&&0!==(null===(r=l.textContent)||void 0===r?void 0:r.length))return h.some(l);return h.none()};if(n&&Be(r))if(jt(r.textContent))r=i(r,!1).getOr(r);else for(null!==r.parentNode&&Jt(e,r.parentNode)&&(r=r.parentNode);null!==r.previousSibling&&(Jt(e,r.previousSibling)||Be(r.previousSibling));)r=r.previousSibling;if(!n&&Be(r))if(jt(r.textContent))r=i(r,!0).getOr(r);else for(null!==r.parentNode&&Jt(e,r.parentNode)&&(r=r.parentNode);null!==r.nextSibling&&(Jt(e,r.nextSibling)||Be(r.nextSibling));)r=r.nextSibling;for(;r.parentNode!==o;){const t=r.parentNode;if(Fe(e,r))return r;if(/^(TD|TH)$/.test(t.nodeName))return r;r=t}return r},Yt=(e,t,n)=>{const o=e.selection.getRng();let r="LI";const s=dt(e,((e,t)=>{const n=e.selection.getStart(!0),o=Xt(e,t,!0,e.getBody());return r=$(o),s=$(t.commonAncestorContainer),i=r,l=function(e,...t){return(...n)=>{const o=t.concat(n);return e.apply(null,o)}}(H,s),ce(i,l,void 0).isSome()?t.commonAncestorContainer:n;var r,s,i,l})(e,o)),i=e.dom;if("false"===i.getContentEditable(e.selection.getNode()))return;"DL"===(t=t.toUpperCase())&&(r="DT");const l=Qt(o),a=L(((e,t,n)=>{const o=[],r=e.dom,s=Xt(e,t,!0,n),i=Xt(e,t,!1,n);let l;const a=[];for(let e=s;e&&(a.push(e),e!==i);e=e.nextSibling);return we.each(a,(t=>{var s;if(Fe(e,t))return o.push(t),void(l=null);if(r.isBlock(t)||He(t))return He(t)&&r.remove(t),void(l=null);const i=t.nextSibling;Kt.isBookmarkNode(t)&&(Pe(i)||Fe(e,i)||!i&&t.parentNode===n)?l=null:(l||(l=r.create("p"),null===(s=t.parentNode)||void 0===s||s.insertBefore(l,t),o.push(l)),l.appendChild(t))})),o})(e,o,s),e.dom.isEditable);we.each(a,(o=>{let s;const l=o.previousSibling,a=o.parentNode;Ue(a)||(l&&Pe(l)&&l.nodeName===t&&((e,t,n)=>{const o=e.getStyle(t,"list-style-type");let r=n?n["list-style-type"]:"";return r=null===r?"":r,o===r})(i,l,n)?(s=l,o=i.rename(o,r),l.appendChild(o)):(s=i.create(t),a.insertBefore(s,o),s.appendChild(o),o=i.rename(o,r)),((e,t,n)=>{we.each(["margin","margin-right","margin-bottom","margin-left","margin-top","padding","padding-right","padding-bottom","padding-left","padding-top"],(n=>e.setStyle(t,n,"")))})(i,o),Gt(i,s,n),tn(e.dom,s))})),e.selection.setRng(Wt(l))},en=(e,t,n)=>{return((e,t)=>Pe(e)&&e.nodeName===(null==t?void 0:t.nodeName))(t,n)&&((e,t,n)=>e.getStyle(t,"list-style-type",!0)===e.getStyle(n,"list-style-type",!0))(e,t,n)&&(o=n,t.className===o.className);var o},tn=(e,t)=>{let n,o=t.nextSibling;if(en(e,t,o)){const r=o;for(;n=r.firstChild;)t.appendChild(n);e.remove(r)}if(o=t.previousSibling,en(e,t,o)){const r=o;for(;n=r.lastChild;)t.insertBefore(n,t.firstChild);e.remove(r)}},nn=(e,t,n,o)=>{if(t.nodeName!==n){const r=e.dom.rename(t,n);Gt(e.dom,r,o),vt(e,qt(n),r)}else Gt(e.dom,t,o),vt(e,qt(n),t)},on=(e,t,n,o)=>{if(t.classList.forEach(((e,n,o)=>{e.startsWith("tox-")&&(o.remove(e),0===o.length&&t.removeAttribute("class"))})),t.nodeName!==n){const r=e.dom.rename(t,n);Gt(e.dom,r,o),vt(e,qt(n),r)}else Gt(e.dom,t,o),vt(e,qt(n),t)},rn=e=>"list-style-type"in e,sn=(e,t,n)=>{const o=it(e);if(ht(e,o))return;const s=(e=>{const t=it(e),n=e.selection.getSelectedBlocks();return((e,t)=>l(e)&&1===t.length&&t[0]===e)(t,n)?(e=>L(e.querySelectorAll(st),Pe))(t):L(n,(e=>Pe(e)&&t!==e))})(e),i=r(n)?n:{};s.length>0?((e,t,n,o,r)=>{const s=Pe(t);if(!s||t.nodeName!==o||rn(r)||ut(t)){Yt(e,o,r);const i=Qt(e.selection.getRng()),l=s?[t,...n]:n,a=s&&ut(t)?on:nn;we.each(l,(t=>{a(e,t,o,r)})),e.selection.setRng(Wt(i))}else Vt(e)})(e,o,s,t,i):((e,t,n,o)=>{if(t!==e.getBody())if(t)if(t.nodeName!==n||rn(o)||ut(t)){const r=Qt(e.selection.getRng());ut(t)&&t.classList.forEach(((e,n,o)=>{e.startsWith("tox-")&&(o.remove(e),0===o.length&&t.removeAttribute("class"))})),Gt(e.dom,t,o);const s=e.dom.rename(t,n);tn(e.dom,s),e.selection.setRng(Wt(r)),Yt(e,n,o),vt(e,qt(n),s)}else Vt(e);else Yt(e,n,o),vt(e,qt(n),t)})(e,o,t,i)},ln=Ee.DOM,an=(e,t)=>{const n=we.grep(e.select("ol,ul",t));we.each(n,(t=>{((e,t)=>{const n=t.parentElement;if(n&&"LI"===n.nodeName&&n.firstChild===t){const o=n.previousSibling;o&&"LI"===o.nodeName?(o.appendChild(t),Ke(e,n)&&ln.remove(n)):ln.setStyle(n,"listStyleType","none")}if(Pe(n)){const e=n.previousSibling;e&&"LI"===e.nodeName&&e.appendChild(t)}})(e,t)}))},dn=(e,t,n,o)=>{let r=t.startContainer;const s=t.startOffset;if(Be(r)&&(n?s0))return r;const i=e.schema.getNonEmptyElements();Me(r)&&(r=ve.getNode(r,s));const l=new Ce(r,o);n&&((e,t)=>!!He(t)&&e.isBlock(t.nextSibling)&&!He(t.previousSibling))(e.dom,r)&&l.next();const a=n?l.next.bind(l):l.prev2.bind(l);for(;r=a();){if("LI"===r.nodeName&&!r.hasChildNodes())return r;if(i[r.nodeName])return r;if(Be(r)&&r.data.length>0)return r}return null},cn=(e,t)=>{const n=t.childNodes;return 1===n.length&&!Pe(n[0])&&e.isBlock(n[0])},mn=e=>h.from(e).map($).filter(Z).exists((e=>((e,t=!1)=>{return ae(e)?e.dom.isContentEditable:(n=e,de(((e,t)=>_(e,t)),ue,n,"[contenteditable]",void 0)).fold(m(t),(e=>"true"===(e=>e.dom.contentEditable)(e)));var n})(e)&&!C(["details"],Q(e)))),un=(e,t,n)=>{let o;const r=cn(e,n)?n.firstChild:n;if(((e,t)=>{cn(e,t)&&mn(t.firstChild)&&e.remove(t.firstChild,!0)})(e,t),!Ke(e,t,!0))for(;o=t.firstChild;)r.appendChild(o)},pn=(e,t,n)=>{let o;const r=t.parentNode;if(!ze(e,t)||!ze(e,n))return;Pe(n.lastChild)&&(o=n.lastChild),r===n.lastChild&&He(r.previousSibling)&&e.remove(r.previousSibling);const s=n.lastChild;s&&He(s)&&t.hasChildNodes()&&e.remove(s),Ke(e,n,!0)&&fe($(n)),un(e,t,n),o&&n.appendChild(o);const i=((e,t)=>{const n=e.dom,o=t.dom;return n!==o&&n.contains(o)})($(n),$(t))?e.getParents(t,Pe,n):[];e.remove(t),S(i,(t=>{Ke(e,t)&&t!==e.getRoot()&&e.remove(t)}))},gn=(e,t)=>{const n=e.dom,o=e.selection,r=o.getStart(),s=at(e,r),i=n.getParent(o.getStart(),"LI",s);if(i){const r=i.parentElement;if(r===e.getBody()&&Ke(n,r))return!0;const l=ot(o.getRng()),a=n.getParent(dn(e,l,t,s),"LI",s),d=a&&(t?n.isChildOf(i,a):n.isChildOf(a,i));if(a&&a!==i&&!d)return e.undoManager.transact((()=>{var n,o;t?((e,t,n,o)=>{const r=e.dom;if(r.isEmpty(o))((e,t,n)=>{fe($(n)),pn(e.dom,t,n),e.selection.setCursorLocation(n,0)})(e,n,o);else{const s=Qt(t);pn(r,n,o),e.selection.setRng(Wt(s))}})(e,l,a,i):(null===(o=(n=i).parentNode)||void 0===o?void 0:o.firstChild)===n?Ft(e):((e,t,n,o)=>{const r=Qt(t);pn(e.dom,n,o);const s=Wt(r);e.selection.setRng(s)})(e,l,i,a)})),!0;if(d&&!t&&a!==i)return e.undoManager.transact((()=>{if(l.commonAncestorContainer.parentElement){const t=Qt(l),o=l.commonAncestorContainer.parentElement;un(n,l.commonAncestorContainer.parentElement,a),o.remove();const r=Wt(t);e.selection.setRng(r)}})),!0;if(!a&&!t&&0===l.startOffset&&0===l.endOffset)return e.undoManager.transact((()=>{Vt(e)})),!0}return!1},hn=e=>{const t=e.selection.getStart(),n=at(e,t);return e.dom.getParent(t,"LI,DT,DD",n)||lt(e).length>0},fn=(e,t)=>{const n=e.selection;return!ht(e,n.getNode())&&(n.isCollapsed()?((e,t)=>gn(e,t)||((e,t)=>{const n=e.dom,o=e.selection.getStart(),r=at(e,o),s=n.getParent(o,n.isBlock,r);if(s&&n.isEmpty(s)){const o=ot(e.selection.getRng()),i=n.getParent(dn(e,o,t,r),"LI",r);if(i){const l=e=>C(["td","th","caption"],Q(e)),a=e=>e.dom===r;return!!((e,t,n=u)=>P(e,t,n).getOr(e.isNone()&&t.isNone()))(me($(i),l,a),me($(o.startContainer),l,a),H)&&(e.undoManager.transact((()=>{const o=i.parentNode;((e,t,n)=>{const o=e.getParent(t.parentNode,e.isBlock,n);e.remove(t),o&&e.isEmpty(o)&&e.remove(o)})(n,s,r),tn(n,o),e.selection.select(i,!0),e.selection.collapse(t)})),!0)}}return!1})(e,t))(e,t):(e=>!!hn(e)&&(e.undoManager.transact((()=>{e.execCommand("Delete"),an(e.dom,e.getBody())})),!0))(e))},yn=e=>{const t=E(Ct(e).split("")),n=N(t,((e,t)=>{const n=e.toUpperCase().charCodeAt(0)-"A".charCodeAt(0)+1;return Math.pow(26,t)*n}));return O(n,((e,t)=>e+t),0)},vn=e=>{if(--e<0)return"";{const t=e%26,n=Math.floor(e/26);return vn(n)+String.fromCharCode("A".charCodeAt(0)+t)}},Cn=e=>{const t=parseInt(e.start,10);return M(e.listStyleType,"upper-alpha")?vn(t):M(e.listStyleType,"lower-alpha")?vn(t).toLowerCase():e.start},bn=(e,t)=>()=>{const n=it(e);return l(n)&&n.nodeName===t},Nn=e=>{e.addCommand("mceListProps",(()=>{(e=>{const t=it(e);Re(t)&&!ht(e,t)&&e.windowManager.open({title:"List Properties",body:{type:"panel",items:[{type:"input",name:"start",label:"Start list at number",inputMode:"numeric"}]},initialData:{start:Cn({start:e.dom.getAttrib(t,"start","1"),listStyleType:h.from(e.dom.getStyle(t,"list-style-type"))})},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],onSubmit:t=>{(e=>{switch((e=>/^[0-9]+$/.test(e)?2:/^[A-Z]+$/.test(e)?0:/^[a-z]+$/.test(e)?1:e.length>0?4:3)(e)){case 2:return h.some({listStyleType:h.none(),start:e});case 0:return h.some({listStyleType:h.some("upper-alpha"),start:yn(e).toString()});case 1:return h.some({listStyleType:h.some("lower-alpha"),start:yn(e).toString()});case 3:return h.some({listStyleType:h.none(),start:""});case 4:return h.none()}})(t.getData().start).each((t=>{e.execCommand("mceListUpdate",!1,{attrs:{start:"1"===t.start?"":t.start},styles:{"list-style-type":t.listStyleType.getOr("")}})})),t.close()}})})(e)}))};var Sn=tinymce.util.Tools.resolve("tinymce.html.Node");const Ln=e=>3===e.type,On=e=>0===e.length,An=e=>{const t=(t,n)=>{const o=Sn.create("li");S(t,(e=>o.append(e))),n?e.insert(o,n,!0):e.append(o)},n=O(e.children(),((e,n)=>Ln(n)?[...e,n]:On(e)||Ln(n)?e:(t(e,n),[])),[]);On(n)||t(n)},Tn=(e,t)=>n=>(n.setEnabled(e.selection.isEditable()),ft(e,(o=>{n.setActive(pt(o.parents,t)),n.setEnabled(!ht(e,o.element)&&e.selection.isEditable())}))),xn=(e,t)=>n=>ft(e,(o=>n.setEnabled(pt(o.parents,t)&&!ht(e,o.element))));e.add("lists",(e=>((e=>{(0,e.options.register)("lists_indent_on_tab",{processor:"boolean",default:!0})})(e),(e=>{e.on("PreInit",(()=>{const{parser:t}=e;t.addNodeFilter("ul,ol",(e=>S(e,An)))}))})(e),e.hasPlugin("rtc",!0)?Nn(e):((e=>{We(e)&&(e=>{e.on("keydown",(t=>{t.keyCode!==be.TAB||be.metaKeyPressed(t)||e.undoManager.transact((()=>{(t.shiftKey?Ft(e):Ht(e))&&t.preventDefault()}))}))})(e),(e=>{e.on("ExecCommand",(t=>{const n=t.command.toLowerCase();"delete"!==n&&"forwarddelete"!==n||!hn(e)||an(e.dom,e.getBody())})),e.on("keydown",(t=>{t.keyCode===be.BACKSPACE?fn(e,!1)&&t.preventDefault():t.keyCode===be.DELETE&&fn(e,!0)&&t.preventDefault()}))})(e)})(e),(e=>{e.on("BeforeExecCommand",(t=>{const n=t.command.toLowerCase();"indent"===n?Ht(e):"outdent"===n&&Ft(e)})),e.addCommand("InsertUnorderedList",((t,n)=>{sn(e,"UL",n)})),e.addCommand("InsertOrderedList",((t,n)=>{sn(e,"OL",n)})),e.addCommand("InsertDefinitionList",((t,n)=>{sn(e,"DL",n)})),e.addCommand("RemoveList",(()=>{Vt(e)})),Nn(e),e.addCommand("mceListUpdate",((t,n)=>{r(n)&&((e,t)=>{const n=it(e);null===n||ht(e,n)||e.undoManager.transact((()=>{r(t.styles)&&e.dom.setStyles(n,t.styles),r(t.attrs)&&Le(t.attrs,((t,o)=>e.dom.setAttrib(n,o,t)))}))})(e,n)})),e.addQueryStateHandler("InsertUnorderedList",bn(e,"UL")),e.addQueryStateHandler("InsertOrderedList",bn(e,"OL")),e.addQueryStateHandler("InsertDefinitionList",bn(e,"DL"))})(e)),(e=>{const t=t=>()=>e.execCommand(t);e.hasPlugin("advlist")||(e.ui.registry.addToggleButton("numlist",{icon:"ordered-list",active:!1,tooltip:"Numbered list",onAction:t("InsertOrderedList"),onSetup:Tn(e,"OL")}),e.ui.registry.addToggleButton("bullist",{icon:"unordered-list",active:!1,tooltip:"Bullet list",onAction:t("InsertUnorderedList"),onSetup:Tn(e,"UL")}))})(e),(e=>{const t={text:"List properties...",icon:"ordered-list",onAction:()=>e.execCommand("mceListProps"),onSetup:xn(e,"OL")};e.ui.registry.addMenuItem("listprops",t),e.ui.registry.addContextMenu("lists",{update:t=>{const n=it(e,t);return Re(n)?["listprops"]:[]}})})(e),(e=>({backspaceDelete:t=>{fn(e,t)}}))(e))))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/media/plugin.min.js b/public/libs/tinymce/plugins/media/plugin.min.js index 498cefafcf1..28cc7bdae32 100644 --- a/public/libs/tinymce/plugins/media/plugin.min.js +++ b/public/libs/tinymce/plugins/media/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(r=o=e,(a=String).prototype.isPrototypeOf(r)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===a.name)?"string":t;var r,o,a,s})(t)===e,r=t("string"),o=t("object"),a=t("array"),s=e=>!(e=>null==e)(e);class i{constructor(e,t){this.tag=e,this.value=t}static some(e){return new i(!0,e)}static none(){return i.singletonNone}fold(e,t){return this.tag?t(this.value):e()}isSome(){return this.tag}isNone(){return!this.tag}map(e){return this.tag?i.some(e(this.value)):i.none()}bind(e){return this.tag?e(this.value):i.none()}exists(e){return this.tag&&e(this.value)}forall(e){return!this.tag||e(this.value)}filter(e){return!this.tag||e(this.value)?this:i.none()}getOr(e){return this.tag?this.value:e}or(e){return this.tag?this:e}getOrThunk(e){return this.tag?this.value:e()}orThunk(e){return this.tag?this:e()}getOrDie(e){if(this.tag)return this.value;throw new Error(null!=e?e:"Called getOrDie on None")}static from(e){return s(e)?i.some(e):i.none()}getOrNull(){return this.tag?this.value:null}getOrUndefined(){return this.value}each(e){this.tag&&e(this.value)}toArray(){return this.tag?[this.value]:[]}toString(){return this.tag?`some(${this.value})`:"none()"}}i.singletonNone=new i(!1);const n=Array.prototype.push,l=(e,t)=>{for(let r=0,o=e.length;r{const t=[];for(let r=0,o=e.length;rh(e,t)?i.from(e[t]):i.none(),h=(e,t)=>u.call(e,t),p=e=>t=>t.options.get(e),g=p("audio_template_callback"),b=p("video_template_callback"),w=p("iframe_template_callback"),v=p("media_live_embeds"),f=p("media_filter_html"),y=p("media_url_resolver"),x=p("media_alt_source"),_=p("media_poster"),k=p("media_dimensions");var j=tinymce.util.Tools.resolve("tinymce.util.Tools"),O=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),A=tinymce.util.Tools.resolve("tinymce.html.DomParser");const S=O.DOM,$=e=>e.replace(/px$/,""),C=e=>{const t=e.attr("style"),r=t?S.parseStyle(t):{};return{type:"ephox-embed-iri",source:e.attr("data-ephox-embed-iri"),altsource:"",poster:"",width:d(r,"max-width").map($).getOr(""),height:d(r,"max-height").map($).getOr("")}},T=(e,t)=>{let r={};for(let o=A({validate:!1,forced_root_block:!1},t).parse(e);o;o=o.walk())if(1===o.type){const e=o.name;if(o.attr("data-ephox-embed-iri")){r=C(o);break}r.source||"param"!==e||(r.source=o.attr("movie")),"iframe"!==e&&"object"!==e&&"embed"!==e&&"video"!==e&&"audio"!==e||(r.type||(r.type=e),r=j.extend(o.attributes.map,r)),"source"===e&&(r.source?r.altsource||(r.altsource=o.attr("src")):r.source=o.attr("src")),"img"!==e||r.poster||(r.poster=o.attr("src"))}return r.source=r.source||r.src||"",r.altsource=r.altsource||"",r.poster=r.poster||"",r},z=e=>{var t;const r=null!==(t=e.toLowerCase().split(".").pop())&&void 0!==t?t:"";return d({mp3:"audio/mpeg",m4a:"audio/x-m4a",wav:"audio/wav",mp4:"video/mp4",webm:"video/webm",ogg:"video/ogg",swf:"application/x-shockwave-flash"},r).getOr("")};var D=tinymce.util.Tools.resolve("tinymce.html.Node"),F=tinymce.util.Tools.resolve("tinymce.html.Serializer");const M=(e,t={})=>A({forced_root_block:!1,validate:!1,allow_conditional_comments:!0,...t},e),N=O.DOM,R=e=>/^[0-9.]+$/.test(e)?e+"px":e,E=(e,t)=>{const r=t.attr("style"),o=r?N.parseStyle(r):{};s(e.width)&&(o["max-width"]=R(e.width)),s(e.height)&&(o["max-height"]=R(e.height)),t.attr("style",N.serializeStyle(o))},U=["source","altsource"],P=(e,t,r,o)=>{let a=0,s=0;const i=M(o);i.addNodeFilter("source",(e=>a=e.length));const n=i.parse(e);for(let e=n;e;e=e.walk())if(1===e.type){const o=e.name;if(e.attr("data-ephox-embed-iri")){E(t,e);break}switch(o){case"video":case"object":case"embed":case"img":case"iframe":void 0!==t.height&&void 0!==t.width&&(e.attr("width",t.width),e.attr("height",t.height))}if(r)switch(o){case"video":e.attr("poster",t.poster),e.attr("src",null);for(let r=a;r<2;r++)if(t[U[r]]){const o=new D("source",1);o.attr("src",t[U[r]]),o.attr("type",t[U[r]+"mime"]||null),e.append(o)}break;case"iframe":e.attr("src",t.source);break;case"object":const r=e.getAll("img").length>0;if(t.poster&&!r){e.attr("src",t.poster);const r=new D("img",1);r.attr("src",t.poster),r.attr("width",t.width),r.attr("height",t.height),e.append(r)}break;case"source":if(s<2&&(e.attr("src",t[U[s]]),e.attr("type",t[U[s]+"mime"]||null),!t[U[s]])){e.remove();continue}s++;break;case"img":t.poster||e.remove()}}return F({},o).serialize(n)},L=[{regex:/youtu\.be\/([\w\-_\?&=.]+)/i,type:"iframe",w:560,h:314,url:"www.youtube.com/embed/$1",allowFullscreen:!0},{regex:/youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?/i,type:"iframe",w:560,h:314,url:"www.youtube.com/embed/$2?$4",allowFullscreen:!0},{regex:/youtube.com\/embed\/([a-z0-9\?&=\-_]+)/i,type:"iframe",w:560,h:314,url:"www.youtube.com/embed/$1",allowFullscreen:!0},{regex:/vimeo\.com\/([0-9]+)\?h=(\w+)/,type:"iframe",w:425,h:350,url:"player.vimeo.com/video/$1?h=$2&title=0&byline=0&portrait=0&color=8dc7dc",allowFullscreen:!0},{regex:/vimeo\.com\/(.*)\/([0-9]+)\?h=(\w+)/,type:"iframe",w:425,h:350,url:"player.vimeo.com/video/$2?h=$3&title=0&byline=0",allowFullscreen:!0},{regex:/vimeo\.com\/([0-9]+)/,type:"iframe",w:425,h:350,url:"player.vimeo.com/video/$1?title=0&byline=0&portrait=0&color=8dc7dc",allowFullscreen:!0},{regex:/vimeo\.com\/(.*)\/([0-9]+)/,type:"iframe",w:425,h:350,url:"player.vimeo.com/video/$2?title=0&byline=0",allowFullscreen:!0},{regex:/maps\.google\.([a-z]{2,3})\/maps\/(.+)msid=(.+)/,type:"iframe",w:425,h:350,url:'maps.google.com/maps/ms?msid=$2&output=embed"',allowFullscreen:!1},{regex:/dailymotion\.com\/video\/([^_]+)/,type:"iframe",w:480,h:270,url:"www.dailymotion.com/embed/video/$1",allowFullscreen:!0},{regex:/dai\.ly\/([^_]+)/,type:"iframe",w:480,h:270,url:"www.dailymotion.com/embed/video/$1",allowFullscreen:!0}],I=(e,t)=>{const r=(e=>{const t=e.match(/^(https?:\/\/|www\.)(.+)$/i);return t&&t.length>1?"www."===t[1]?"https://":t[1]:"https://"})(t),o=e.regex.exec(t);let a=r+e.url;if(s(o))for(let e=0;eo[e]?o[e]:""));return a.replace(/\?$/,"")},B=e=>{const t=L.filter((t=>t.regex.test(e)));return t.length>0?j.extend({},t[0],{url:I(t[0],e)}):null},G=(e,t)=>{var r;const o=j.extend({},t);if(!o.source&&(j.extend(o,T(null!==(r=o.embed)&&void 0!==r?r:"",e.schema)),!o.source))return"";o.altsource||(o.altsource=""),o.poster||(o.poster=""),o.source=e.convertURL(o.source,"source"),o.altsource=e.convertURL(o.altsource,"source"),o.sourcemime=z(o.source),o.altsourcemime=z(o.altsource),o.poster=e.convertURL(o.poster,"poster");const a=B(o.source);if(a&&(o.source=a.url,o.type=a.type,o.allowfullscreen=a.allowFullscreen,o.width=o.width||String(a.w),o.height=o.height||String(a.h)),o.embed)return P(o.embed,o,!0,e.schema);{const t=g(e),r=b(e),a=w(e);return o.width=o.width||"300",o.height=o.height||"150",j.each(o,((t,r)=>{o[r]=e.dom.encode(""+t)})),"iframe"===o.type?((e,t)=>{if(t)return t(e);{const t=e.allowfullscreen?' allowFullscreen="1"':"";return'"}})(o,a):"application/x-shockwave-flash"===o.sourcemime?(e=>{let t='';return e.poster&&(t+=''),t+="",t})(o):-1!==o.sourcemime.indexOf("audio")?((e,t)=>t?t(e):'")(o,t):((e,t)=>t?t(e):'")(o,r)}},W=e=>e.hasAttribute("data-mce-object")||e.hasAttribute("data-ephox-embed-iri"),q={},H=e=>t=>G(e,t),J=(e,t)=>{const r=y(e);return r?((e,t,r)=>new Promise(((o,a)=>{const s=r=>(r.html&&(q[e.source]=r),o({url:e.source,html:r.html?r.html:t(e)}));q[e.source]?s(q[e.source]):r({url:e.source},s,a)})))(t,H(e),r):((e,t)=>Promise.resolve({html:t(e),url:e.source}))(t,H(e))},K=(e,t)=>{const r={};return d(e,"dimensions").each((e=>{l(["width","height"],(o=>{d(t,o).orThunk((()=>d(e,o))).each((e=>r[o]=e))}))})),r},Q=(e,t)=>{const r=t&&"dimensions"!==t?((e,t)=>d(t,e).bind((e=>d(e,"meta"))))(t,e).getOr({}):{},a=((e,t,r)=>a=>{const s=()=>d(e,a),n=()=>d(t,a),l=e=>d(e,"value").bind((e=>e.length>0?i.some(e):i.none()));return{[a]:(a===r?s().bind((e=>o(e)?l(e).orThunk(n):n().orThunk((()=>i.from(e))))):n().orThunk((()=>s().bind((e=>o(e)?l(e):i.from(e)))))).getOr("")}})(e,r,t);return{...a("source"),...a("altsource"),...a("poster"),...a("embed"),...K(e,r)}},V=e=>{const t={...e,source:{value:d(e,"source").getOr("")},altsource:{value:d(e,"altsource").getOr("")},poster:{value:d(e,"poster").getOr("")}};return l(["width","height"],(r=>{d(e,r).each((e=>{const o=t.dimensions||{};o[r]=e,t.dimensions=o}))})),t},X=e=>t=>{const r=t&&t.msg?"Media embed handler error: "+t.msg:"Media embed handler threw unknown error.";e.notificationManager.open({type:"error",text:r})},Y=(e,t)=>o=>{if(r(o.url)&&o.url.trim().length>0){const r=o.html,a={...T(r,t.schema),source:o.url,embed:r};e.setData(V(a))}},Z=(e,t)=>{const r=e.dom.select("*[data-mce-object]");e.insertContent(t),((e,t)=>{const r=e.dom.select("*[data-mce-object]");for(let e=0;e=0;o--)t[e]===r[o]&&r.splice(o,1);e.selection.select(r[0])})(e,r),e.nodeChanged()},ee=(e,t)=>s(t)&&"ephox-embed-iri"===t&&s(B(e)),te=(e,t)=>((e,t)=>e.width!==t.width||e.height!==t.height)(e,t)&&ee(t.source,e.type),re=e=>{const t=(e=>{const t=e.selection.getNode(),r=W(t)?e.serializer.serialize(t,{selection:!0}):"",o=T(r,e.schema),a=(()=>{if(ee(o.source,o.type)){const r=e.dom.getRect(t);return{width:r.w.toString().replace(/px$/,""),height:r.h.toString().replace(/px$/,"")}}return{}})();return{embed:r,...o,...a}})(e),r=(e=>{let t=e;return{get:()=>t,set:e=>{t=e}}})(t),o=V(t),a=k(e)?[{type:"sizeinput",name:"dimensions",label:"Constrain proportions",constrain:!0}]:[],s={title:"General",name:"general",items:c([[{name:"source",type:"urlinput",filetype:"media",label:"Source",picker_text:"Browse files"}],a])},i=[];x(e)&&i.push({name:"altsource",type:"urlinput",filetype:"media",label:"Alternative source URL"}),_(e)&&i.push({name:"poster",type:"urlinput",filetype:"image",label:"Media poster (Image URL)"});const n={title:"Advanced",name:"advanced",items:i},l=[s,{title:"Embed",items:[{type:"textarea",name:"embed",label:"Paste your embed code below:"}]}];i.length>0&&l.push(n);const m={type:"tabpanel",tabs:l},u=e.windowManager.open({title:"Insert/Edit Media",size:"normal",body:m,buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],onSubmit:t=>{const o=Q(t.getData());((e,t,r)=>{var o,a;t.embed=te(e,t)&&k(r)?G(r,{...t,embed:""}):P(null!==(o=t.embed)&&void 0!==o?o:"",t,!1,r.schema),t.embed&&(e.source===t.source||(a=t.source,h(q,a)))?Z(r,t.embed):J(r,t).then((e=>{Z(r,e.html)})).catch(X(r))})(r.get(),o,e),t.close()},onChange:(t,o)=>{switch(o.name){case"source":((t,r)=>{const o=Q(r.getData(),"source");t.source!==o.source&&(Y(u,e)({url:o.source,html:""}),J(e,o).then(Y(u,e)).catch(X(e)))})(r.get(),t);break;case"embed":(t=>{var r;const o=Q(t.getData()),a=T(null!==(r=o.embed)&&void 0!==r?r:"",e.schema);t.setData(V(a))})(t);break;case"dimensions":case"altsource":case"poster":((t,r,o)=>{const a=Q(t.getData(),r),s=te(o,a)&&k(e)?{...a,embed:""}:a,i=G(e,s);t.setData(V({...s,embed:i}))})(t,o.name,r.get())}r.set(Q(t.getData()))},initialData:o})};var oe=tinymce.util.Tools.resolve("tinymce.Env");const ae=e=>{const t=e.name;return"iframe"===t||"video"===t||"audio"===t},se=(e,t,r,o=null)=>{const a=e.attr(r);return s(a)?a:h(t,r)?null:o},ie=(e,t,r)=>{const o="img"===t.name||"video"===e.name,a=o?"300":null,s="audio"===e.name?"30":"150",i=o?s:null;t.attr({width:se(e,r,"width",a),height:se(e,r,"height",i)})},ne=(e,t)=>{const r=t.name,o=new D("img",1);return ce(e,t,o),ie(t,o,{}),o.attr({style:t.attr("style"),src:oe.transparentSrc,"data-mce-object":r,class:"mce-object mce-object-"+r}),o},le=(e,t)=>{var r;const o=t.name,a=new D("span",1);a.attr({contentEditable:"false",style:t.attr("style"),"data-mce-object":o,class:"mce-preview-object mce-object-"+o}),ce(e,t,a);const i=e.dom.parseStyle(null!==(r=t.attr("style"))&&void 0!==r?r:""),n=new D(o,1);if(ie(t,n,i),n.attr({src:t.attr("src"),style:t.attr("style"),class:t.attr("class")}),"iframe"===o)n.attr({allowfullscreen:t.attr("allowfullscreen"),frameborder:"0",sandbox:t.attr("sandbox")});else{l(["controls","crossorigin","currentTime","loop","muted","poster","preload"],(e=>{n.attr(e,t.attr(e))}));const r=a.attr("data-mce-html");s(r)&&((e,t,r,o)=>{const a=M(e.schema).parse(o,{context:t});for(;a.firstChild;)r.append(a.firstChild)})(e,o,n,unescape(r))}const c=new D("span",1);return c.attr("class","mce-shim"),a.append(n),a.append(c),a},ce=(e,t,r)=>{var o;const a=null!==(o=t.attributes)&&void 0!==o?o:[];let s=a.length;for(;s--;){const t=a[s].name;let o=a[s].value;"width"===t||"height"===t||"style"===t||(n="data-mce-",(i=t).length>=9&&i.substr(0,9)===n)||("data"!==t&&"src"!==t||(o=e.convertURL(o,t)),r.attr("data-mce-p-"+t,o))}var i,n;const c=F({inner:!0},e.schema),m=new D("div",1);l(t.children(),(e=>m.append(e)));const u=c.serialize(m);u&&(r.attr("data-mce-html",escape(u)),r.empty())},me=e=>{const t=e.attr("class");return r(t)&&/\btiny-pageembed\b/.test(t)},ue=e=>{let t=e;for(;t=t.parent;)if(t.attr("data-ephox-embed-iri")||me(t))return!0;return!1},de=(e,t,r)=>{const o=(0,e.options.get)("xss_sanitization"),a=f(e);return M(e.schema,{sanitize:o,validate:a}).parse(r,{context:t})},he=e=>t=>{const r=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",r),r(),()=>{e.off("NodeChange",r)}};e.add("media",(e=>((e=>{const t=e.options.register;t("audio_template_callback",{processor:"function"}),t("video_template_callback",{processor:"function"}),t("iframe_template_callback",{processor:"function"}),t("media_live_embeds",{processor:"boolean",default:!0}),t("media_filter_html",{processor:"boolean",default:!0}),t("media_url_resolver",{processor:"function"}),t("media_alt_source",{processor:"boolean",default:!0}),t("media_poster",{processor:"boolean",default:!0}),t("media_dimensions",{processor:"boolean",default:!0})})(e),(e=>{e.addCommand("mceMedia",(()=>{re(e)}))})(e),(e=>{const t=()=>e.execCommand("mceMedia");e.ui.registry.addToggleButton("media",{tooltip:"Insert/edit media",icon:"embed",onAction:t,onSetup:t=>{const r=e.selection;t.setActive(W(r.getNode()));const o=r.selectorChangedWithUnbind("img[data-mce-object],span[data-mce-object],div[data-ephox-embed-iri]",t.setActive).unbind,a=he(e)(t);return()=>{o(),a()}}}),e.ui.registry.addMenuItem("media",{icon:"embed",text:"Media...",onAction:t,onSetup:he(e)})})(e),(e=>{e.on("ResolveName",(e=>{let t;1===e.target.nodeType&&(t=e.target.getAttribute("data-mce-object"))&&(e.name=t)}))})(e),(e=>{e.on("PreInit",(()=>{const{schema:t,serializer:r,parser:o}=e,a=t.getBoolAttrs();l("webkitallowfullscreen mozallowfullscreen".split(" "),(e=>{a[e]={}})),((e,t)=>{const r=m(e);for(let o=0,a=r.length;o{const o=t.getElementRule(r);o&&l(e,(e=>{o.attributes[e]={},o.attributesOrder.push(e)}))})),o.addNodeFilter("iframe,video,audio,object,embed",(e=>t=>{let r,o=t.length;for(;o--;)r=t[o],r.parent&&(r.parent.attr("data-mce-object")||(ae(r)&&v(e)?ue(r)||r.replace(le(e,r)):ue(r)||r.replace(ne(e,r))))})(e)),r.addAttributeFilter("data-mce-object",((t,r)=>{var o;let a=t.length;for(;a--;){const s=t[a];if(!s.parent)continue;const i=s.attr(r),n=new D(i,1);if("audio"!==i){const e=s.attr("class");e&&-1!==e.indexOf("mce-preview-object")&&s.firstChild?n.attr({width:s.firstChild.attr("width"),height:s.firstChild.attr("height")}):n.attr({width:s.attr("width"),height:s.attr("height")})}n.attr({style:s.attr("style")});const c=null!==(o=s.attributes)&&void 0!==o?o:[];let m=c.length;for(;m--;){const e=c[m].name;0===e.indexOf("data-mce-p-")&&n.attr(e.substr(11),c[m].value)}const u=s.attr("data-mce-html");if(u){const t=de(e,i,unescape(u));l(t.children(),(e=>n.append(e)))}s.replace(n)}}))})),e.on("SetContent",(()=>{const t=e.dom;l(t.select("span.mce-preview-object"),(e=>{0===t.select("span.mce-shim",e).length&&t.add(e,"span",{class:"mce-shim"})}))}))})(e),(e=>{e.on("click keyup touchend",(()=>{const t=e.selection.getNode();t&&e.dom.hasClass(t,"mce-preview-object")&&e.dom.getAttrib(t,"data-mce-selected")&&t.setAttribute("data-mce-selected","2")})),e.on("ObjectResized",(t=>{const r=t.target;if(r.getAttribute("data-mce-object")){let o=r.getAttribute("data-mce-html");o&&(o=unescape(o),r.setAttribute("data-mce-html",escape(P(o,{width:String(t.width),height:String(t.height)},!1,e.schema))))}}))})(e),(e=>({showDialog:()=>{re(e)}}))(e))))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/nonbreaking/plugin.min.js b/public/libs/tinymce/plugins/nonbreaking/plugin.min.js index 6112af0efdb..4baee129054 100644 --- a/public/libs/tinymce/plugins/nonbreaking/plugin.min.js +++ b/public/libs/tinymce/plugins/nonbreaking/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var n=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=n=>e=>typeof e===n,o=e("boolean"),a=e("number"),t=n=>e=>e.options.get(n),i=t("nonbreaking_force_tab"),s=t("nonbreaking_wrap"),r=(n,e)=>{let o="";for(let a=0;a{const o=s(n)||n.plugins.visualchars?`${r(" ",e)}`:r(" ",e);n.undoManager.transact((()=>n.insertContent(o)))};var l=tinymce.util.Tools.resolve("tinymce.util.VK");const u=n=>e=>{const o=()=>{e.setEnabled(n.selection.isEditable())};return n.on("NodeChange",o),o(),()=>{n.off("NodeChange",o)}};n.add("nonbreaking",(n=>{(n=>{const e=n.options.register;e("nonbreaking_force_tab",{processor:n=>o(n)?{value:n?3:0,valid:!0}:a(n)?{value:n,valid:!0}:{valid:!1,message:"Must be a boolean or number."},default:!1}),e("nonbreaking_wrap",{processor:"boolean",default:!0})})(n),(n=>{n.addCommand("mceNonBreaking",(()=>{c(n,1)}))})(n),(n=>{const e=()=>n.execCommand("mceNonBreaking");n.ui.registry.addButton("nonbreaking",{icon:"non-breaking",tooltip:"Nonbreaking space",onAction:e,onSetup:u(n)}),n.ui.registry.addMenuItem("nonbreaking",{icon:"non-breaking",text:"Nonbreaking space",onAction:e,onSetup:u(n)})})(n),(n=>{const e=i(n);e>0&&n.on("keydown",(o=>{if(o.keyCode===l.TAB&&!o.isDefaultPrevented()){if(o.shiftKey)return;o.preventDefault(),o.stopImmediatePropagation(),c(n,e)}}))})(n)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/pagebreak/plugin.min.js b/public/libs/tinymce/plugins/pagebreak/plugin.min.js index 1621d834d32..6ed62f51ed9 100644 --- a/public/libs/tinymce/plugins/pagebreak/plugin.min.js +++ b/public/libs/tinymce/plugins/pagebreak/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),a=tinymce.util.Tools.resolve("tinymce.Env");const t=e=>a=>a.options.get(e),n=t("pagebreak_separator"),o=t("pagebreak_split_block"),r="mce-pagebreak",s=e=>{const t=``;return e?`

                                  ${t}

                                  `:t},c=e=>a=>{const t=()=>{a.setEnabled(e.selection.isEditable())};return e.on("NodeChange",t),t(),()=>{e.off("NodeChange",t)}};e.add("pagebreak",(e=>{(e=>{const a=e.options.register;a("pagebreak_separator",{processor:"string",default:"\x3c!-- pagebreak --\x3e"}),a("pagebreak_split_block",{processor:"boolean",default:!1})})(e),(e=>{e.addCommand("mcePageBreak",(()=>{e.insertContent(s(o(e)))}))})(e),(e=>{const a=()=>e.execCommand("mcePageBreak");e.ui.registry.addButton("pagebreak",{icon:"page-break",tooltip:"Page break",onAction:a,onSetup:c(e)}),e.ui.registry.addMenuItem("pagebreak",{text:"Page break",icon:"page-break",onAction:a,onSetup:c(e)})})(e),(e=>{const a=n(e),t=()=>o(e),c=new RegExp(a.replace(/[\?\.\*\[\]\(\)\{\}\+\^\$\:]/g,(e=>"\\"+e)),"gi");e.on("BeforeSetContent",(e=>{e.content=e.content.replace(c,s(t()))})),e.on("PreInit",(()=>{e.serializer.addNodeFilter("img",(n=>{let o,s,c=n.length;for(;c--;)if(o=n[c],s=o.attr("class"),s&&-1!==s.indexOf(r)){const n=o.parent;if(n&&e.schema.getBlockElements()[n.name]&&t()){n.type=3,n.value=a,n.raw=!0,o.remove();continue}o.type=3,o.value=a,o.raw=!0}}))}))})(e),(e=>{e.on("ResolveName",(a=>{"IMG"===a.target.nodeName&&e.dom.hasClass(a.target,r)&&(a.name="pagebreak")}))})(e)}))}(); \ No newline at end of file diff --git a/public/libs/tinymce/plugins/preview/plugin.min.js b/public/libs/tinymce/plugins/preview/plugin.min.js index 76885123d1d..14b0af4beb0 100644 --- a/public/libs/tinymce/plugins/preview/plugin.min.js +++ b/public/libs/tinymce/plugins/preview/plugin.min.js @@ -1,4 +1,4 @@ /** - * TinyMCE version 6.8.3 (2024-02-08) + * TinyMCE version 6.8.4 (2024-06-19) */ !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=e=>t=>t.options.get(e),i=n("content_style"),s=n("content_css_cors"),c=n("body_class"),r=n("body_id");e.add("preview",(e=>{(e=>{e.addCommand("mcePreview",(()=>{(e=>{const n=(e=>{var n;let l="";const a=e.dom.encode,d=null!==(n=i(e))&&void 0!==n?n:"";l+='';const m=s(e)?' crossorigin="anonymous"':"";o.each(e.contentCSS,(t=>{l+='"})),d&&(l+='");const y=r(e),u=c(e),v=''); - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $resp->assertDontSee('window.donkey'); - $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); - } - } - - public function test_page_export_with_deleted_creator_and_updater() - { - $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); - $page = $this->entities->page(); - $page->created_by = $user->id; - $page->updated_by = $user->id; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee('ExportWizardTheFifth'); - - $user->delete(); - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertDontSee('ExportWizardTheFifth'); - } - - public function test_page_pdf_export_converts_iframes_to_links() - { - $page = Page::query()->first()->forceFill([ - 'html' => '', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringNotContainsString('iframe>', $pdfHtml); - $this->assertStringContainsString('

                                  https://www.youtube.com/embed/ShqUjt33uOs

                                  ', $pdfHtml); - } - - public function test_page_pdf_export_opens_details_blocks() - { - $page = $this->entities->page()->forceFill([ - 'html' => '
                                  Hello

                                  Content!

                                  ', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringContainsString('
                                  entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_page_markdown_export_uses_existing_markdown_if_apparent() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '# A header', - 'html' => '

                                  Dogcat

                                  ', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee('A header'); - $resp->assertDontSee('Dogcat'); - } - - public function test_page_markdown_export_converts_html_where_no_markdown() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '', - 'html' => '

                                  Dogcat

                                  Some bold text

                                  ', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee("# Dogcat\n\nSome **bold** text"); - } - - public function test_chapter_markdown_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export_concats_immediate_pages_with_newlines() - { - /** @var Book $book */ - $book = Book::query()->whereHas('pages')->first(); - - $this->asEditor()->get($book->getUrl('/create-page')); - $this->get($book->getUrl('/create-page')); - - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); - $pageA->html = '

                                  hello tester

                                  '; - $pageA->save(); - $pageB->name = 'The second page in this test'; - $pageB->save(); - - $resp = $this->get($book->getUrl('/export/markdown')); - $resp->assertDontSee('hello tester# The second page in this test'); - $resp->assertSee("hello tester\n\n# The second page in this test"); - } - - public function test_export_option_only_visible_and_accessible_with_permission() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $entities = [$book, $chapter, $page]; - $user = $this->users->viewer(); - $this->actingAs($user); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertSee('/export/pdf'); - } - - $this->permissions->removeUserRolePermissions($user, ['content-export']); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertDontSee('/export/pdf'); - $resp = $this->get($entity->getUrl('/export/pdf')); - $this->assertPermissionError($resp); - } - } - - public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true() - { - $page = $this->entities->page(); - - config()->set('exports.snappy.pdf_binary', '/abc123'); - config()->set('app.allow_untrusted_server_fetching', false); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. - - config()->set('app.allow_untrusted_server_fetching', true); - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(500); // Bad response indicates wkhtml usage - } - - public function test_pdf_command_option_used_if_set() - { - $page = $this->entities->page(); - $command = 'cp {input_html_path} {output_pdf_path}'; - config()->set('exports.pdf_command', $command); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $download = $resp->getContent(); - - $this->assertStringContainsString(e($page->name), $download); - $this->assertStringContainsString('set('exports.pdf_command', $command); - - $this->assertThrows(function () use ($page) { - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - }, PdfExportException::class); - } - - public function test_pdf_command_option_errors_if_command_returns_error_status() - { - $page = $this->entities->page(); - $command = 'exit 1'; - config()->set('exports.pdf_command', $command); - - $this->assertThrows(function () use ($page) { - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - }, PdfExportException::class); - } - - public function test_pdf_command_timout_option_limits_export_time() - { - $page = $this->entities->page(); - $command = 'php -r \'sleep(4);\''; - config()->set('exports.pdf_command', $command); - config()->set('exports.pdf_command_timeout', 1); - - $this->assertThrows(function () use ($page) { - $start = time(); - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - - $this->assertTrue(time() < ($start + 3)); - }, PdfExportException::class, - "PDF Export via command failed due to timeout at 1 second(s)"); - } - - public function test_html_exports_contain_csp_meta_tag() - { - $entities = [ - $this->entities->page(), - $this->entities->book(), - $this->entities->chapter(), - ]; - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); - } - } - - public function test_html_exports_contain_body_classes_for_export_identification() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); - } -} diff --git a/tests/Exports/ExportUiTest.php b/tests/Exports/ExportUiTest.php new file mode 100644 index 00000000000..77b26ad8929 --- /dev/null +++ b/tests/Exports/ExportUiTest.php @@ -0,0 +1,33 @@ +whereHas('pages')->whereHas('chapters')->first(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $entities = [$book, $chapter, $page]; + $user = $this->users->viewer(); + $this->actingAs($user); + + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $resp->assertSee('/export/pdf'); + } + + $this->permissions->removeUserRolePermissions($user, ['content-export']); + + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $resp->assertDontSee('/export/pdf'); + $resp = $this->get($entity->getUrl('/export/pdf')); + $this->assertPermissionError($resp); + } + } +} diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php new file mode 100644 index 00000000000..069cf280167 --- /dev/null +++ b/tests/Exports/HtmlExportTest.php @@ -0,0 +1,253 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_book_html_export() + { + $page = $this->entities->page(); + $book = $page->book; + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + } + + public function test_book_html_export_shows_html_descriptions() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $book->description_html = '

                                  A description with HTML within!

                                  '; + $chapter->description_html = '

                                  A chapter description with HTML within!

                                  '; + $book->save(); + $chapter->save(); + + $resp = $this->asEditor()->get($book->getUrl('/export/html')); + $resp->assertSee($book->description_html, false); + $resp->assertSee($chapter->description_html, false); + } + + public function test_chapter_html_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages[0]; + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + } + + public function test_chapter_html_export_shows_html_descriptions() + { + $chapter = $this->entities->chapter(); + $chapter->description_html = '

                                  A description with HTML within!

                                  '; + $chapter->save(); + + $resp = $this->asEditor()->get($chapter->getUrl('/export/html')); + $resp->assertSee($chapter->description_html, false); + } + + public function test_page_html_export_contains_custom_head_if_set() + { + $page = $this->entities->page(); + + $customHeadContent = ''; + $this->setSettings(['app-custom-head' => $customHeadContent]); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee($customHeadContent, false); + } + + public function test_page_html_export_does_not_break_with_only_comments_in_custom_head() + { + $page = $this->entities->page(); + + $customHeadContent = ''; + $this->setSettings(['app-custom-head' => $customHeadContent]); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($customHeadContent, false); + } + + public function test_page_html_export_use_absolute_dates() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertDontSee($page->created_at->diffForHumans()); + $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertDontSee($page->updated_at->diffForHumans()); + } + + public function test_page_export_does_not_include_user_or_revision_links() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee($page->getUrl('/revisions')); + $resp->assertDontSee($page->createdBy->getProfileUrl()); + $resp->assertSee($page->createdBy->name); + } + + public function test_page_export_sets_right_data_type_for_svg_embeds() + { + $page = $this->entities->page(); + Storage::disk('local')->makeDirectory('uploads/images/gallery'); + Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); + $page->html = ''; + $page->save(); + + $this->asEditor(); + $resp = $this->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + + $resp->assertStatus(200); + $resp->assertSee(''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); + } + + public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() + { + $page = $this->entities->page(); + $page->html = '' + . '' + . ''; + $storageDisk = Storage::disk('local'); + $storageDisk->makeDirectory('uploads/images/gallery'); + $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); + $storageDisk->put('uploads/svg_test.svg', 'bad'); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + + $storageDisk->delete('uploads/images/gallery/svg_test.svg'); + $storageDisk->delete('uploads/svg_test.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); + $resp->assertSee('http://localhost/uploads/svg_test.svg'); + $resp->assertSee('src="/uploads/svg_test.svg"', false); + } + + public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() + { + $contents = file_get_contents(public_path('.htaccess')); + config()->set('filesystems.images', 'local'); + + $page = $this->entities->page(); + $page->html = ''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee(base64_encode($contents)); + } + + public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure() + { + $testFilePath = storage_path('logs/test.txt'); + config()->set('filesystems.images', 'local_secure'); + file_put_contents($testFilePath, 'I am a cat'); + + $page = $this->entities->page(); + $page->html = ''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee(base64_encode('I am a cat')); + unlink($testFilePath); + } + + public function test_exports_removes_scripts_from_custom_head() + { + $entities = [ + Page::query()->first(), Chapter::query()->first(), Book::query()->first(), + ]; + setting()->put('app-custom-head', ''); + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $resp->assertDontSee('window.donkey'); + $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); + } + } + + public function test_page_export_with_deleted_creator_and_updater() + { + $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); + $page = $this->entities->page(); + $page->created_by = $user->id; + $page->updated_by = $user->id; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee('ExportWizardTheFifth'); + + $user->delete(); + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertDontSee('ExportWizardTheFifth'); + } + + public function test_html_exports_contain_csp_meta_tag() + { + $entities = [ + $this->entities->page(), + $this->entities->book(), + $this->entities->chapter(), + ]; + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); + } + } + + public function test_html_exports_contain_body_classes_for_export_identification() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); + } +} diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php new file mode 100644 index 00000000000..05ebbc68d75 --- /dev/null +++ b/tests/Exports/MarkdownExportTest.php @@ -0,0 +1,85 @@ +entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + } + + public function test_page_markdown_export_uses_existing_markdown_if_apparent() + { + $page = $this->entities->page()->forceFill([ + 'markdown' => '# A header', + 'html' => '

                                  Dogcat

                                  ', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee('A header'); + $resp->assertDontSee('Dogcat'); + } + + public function test_page_markdown_export_converts_html_where_no_markdown() + { + $page = $this->entities->page()->forceFill([ + 'markdown' => '', + 'html' => '

                                  Dogcat

                                  Some bold text

                                  ', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee("# Dogcat\n\nSome **bold** text"); + } + + public function test_chapter_markdown_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $page->name); + } + + public function test_book_markdown_export() + { + $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $page->name); + } + + public function test_book_markdown_export_concats_immediate_pages_with_newlines() + { + /** @var Book $book */ + $book = Book::query()->whereHas('pages')->first(); + + $this->asEditor()->get($book->getUrl('/create-page')); + $this->get($book->getUrl('/create-page')); + + [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); + $pageA->html = '

                                  hello tester

                                  '; + $pageA->save(); + $pageB->name = 'The second page in this test'; + $pageB->save(); + + $resp = $this->get($book->getUrl('/export/markdown')); + $resp->assertDontSee('hello tester# The second page in this test'); + $resp->assertSee("hello tester\n\n# The second page in this test"); + } +} diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php new file mode 100644 index 00000000000..9d85c69e23a --- /dev/null +++ b/tests/Exports/PdfExportTest.php @@ -0,0 +1,146 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } + + public function test_book_pdf_export() + { + $page = $this->entities->page(); + $book = $page->book; + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + } + + public function test_chapter_pdf_export() + { + $chapter = $this->entities->chapter(); + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + } + + + public function test_page_pdf_export_converts_iframes_to_links() + { + $page = Page::query()->first()->forceFill([ + 'html' => '', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringNotContainsString('iframe>', $pdfHtml); + $this->assertStringContainsString('

                                  https://www.youtube.com/embed/ShqUjt33uOs

                                  ', $pdfHtml); + } + + public function test_page_pdf_export_opens_details_blocks() + { + $page = $this->entities->page()->forceFill([ + 'html' => '
                                  Hello

                                  Content!

                                  ', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringContainsString('
                                  entities->page(); + + config()->set('exports.snappy.pdf_binary', '/abc123'); + config()->set('app.allow_untrusted_server_fetching', false); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. + + config()->set('app.allow_untrusted_server_fetching', true); + $resp = $this->get($page->getUrl('/export/pdf')); + $resp->assertStatus(500); // Bad response indicates wkhtml usage + } + + public function test_pdf_command_option_used_if_set() + { + $page = $this->entities->page(); + $command = 'cp {input_html_path} {output_pdf_path}'; + config()->set('exports.pdf_command', $command); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $download = $resp->getContent(); + + $this->assertStringContainsString(e($page->name), $download); + $this->assertStringContainsString('set('exports.pdf_command', $command); + + $this->assertThrows(function () use ($page) { + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + }, PdfExportException::class); + } + + public function test_pdf_command_option_errors_if_command_returns_error_status() + { + $page = $this->entities->page(); + $command = 'exit 1'; + config()->set('exports.pdf_command', $command); + + $this->assertThrows(function () use ($page) { + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + }, PdfExportException::class); + } + + public function test_pdf_command_timout_option_limits_export_time() + { + $page = $this->entities->page(); + $command = 'php -r \'sleep(4);\''; + config()->set('exports.pdf_command', $command); + config()->set('exports.pdf_command_timeout', 1); + + $this->assertThrows(function () use ($page) { + $start = time(); + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + + $this->assertTrue(time() < ($start + 3)); + }, PdfExportException::class, + "PDF Export via command failed due to timeout at 1 second(s)"); + } +} diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php new file mode 100644 index 00000000000..c593a6585cb --- /dev/null +++ b/tests/Exports/TextExportTest.php @@ -0,0 +1,88 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_book_text_export() + { + $book = $this->entities->bookHasChaptersAndPages(); + $directPage = $book->directPages()->first(); + $chapter = $book->chapters()->first(); + $chapterPage = $chapter->pages()->first(); + $this->entities->updatePage($directPage, ['html' => '

                                  My awesome page

                                  ']); + $this->entities->updatePage($chapterPage, ['html' => '

                                  My little nested page

                                  ']); + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertSee($chapterPage->name); + $resp->assertSee($chapter->name); + $resp->assertSee($directPage->name); + $resp->assertSee('My awesome page'); + $resp->assertSee('My little nested page'); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + } + + public function test_book_text_export_format() + { + $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); + $this->entities->updatePage($entities['page'], ['html' => '

                                  My great page

                                  Full of great stuff

                                  ', 'name' => 'My wonderful page!']); + $entities['chapter']->name = 'Export chapter'; + $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; + $entities['book']->name = 'Export Book'; + $entities['book']->description = "This is a book with stuff to export"; + $entities['chapter']->save(); + $entities['book']->save(); + + $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); + + $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; + $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $resp->assertSee($expected); + } + + public function test_chapter_text_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages[0]; + $this->entities->updatePage($page, ['html' => '

                                  This is content within the page!

                                  ']); + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertSee($page->name); + $resp->assertSee('This is content within the page!'); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + } + + public function test_chapter_text_export_format() + { + $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); + $this->entities->updatePage($entities['page'], ['html' => '

                                  My great page

                                  Full of great stuff

                                  ', 'name' => 'My wonderful page!']); + $entities['chapter']->name = 'Export chapter'; + $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; + $entities['chapter']->save(); + + $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); + + $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; + $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $resp->assertSee($expected); + } +} From 21ccfa97ddfe6309ff735aafbc8138cf34782563 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 19 Oct 2024 15:41:07 +0100 Subject: [PATCH 177/800] ZIP Export: Expanded page & added base attachment handling --- .../Controllers/PageExportController.php | 13 ++++ app/Exports/ZipExportBuilder.php | 66 +++++++++++++++++-- app/Exports/ZipExportFiles.php | 58 ++++++++++++++++ app/Uploads/AttachmentService.php | 11 +--- lang/en/entities.php | 1 + .../views/entities/export-menu.blade.php | 1 + routes/web.php | 1 + tests/Exports/ZipExportTest.php | 15 +++++ 8 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 app/Exports/ZipExportFiles.php create mode 100644 tests/Exports/ZipExportTest.php diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index a4e7aae879d..01611fd2121 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; @@ -74,4 +75,16 @@ public function markdown(string $bookSlug, string $pageSlug) return $this->download()->directly($pageText, $pageSlug . '.md'); } + + /** + * Export a page to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $zip = $builder->buildForPage($page); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/ZipExportBuilder.php b/app/Exports/ZipExportBuilder.php index d1a7b6bd461..2b8b45d0d2a 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -2,24 +2,70 @@ namespace BookStack\Exports; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ZipExportException; +use BookStack\Uploads\Attachment; use ZipArchive; class ZipExportBuilder { protected array $data = []; + public function __construct( + protected ZipExportFiles $files + ) { + } + /** * @throws ZipExportException */ public function buildForPage(Page $page): string { - $this->data['page'] = [ - 'id' => $page->id, + $this->data['page'] = $this->convertPage($page); + return $this->build(); + } + + protected function convertPage(Page $page): array + { + $tags = array_map($this->convertTag(...), $page->tags()->get()->all()); + $attachments = array_map($this->convertAttachment(...), $page->attachments()->get()->all()); + + return [ + 'id' => $page->id, + 'name' => $page->name, + 'html' => '', // TODO + 'markdown' => '', // TODO + 'priority' => $page->priority, + 'attachments' => $attachments, + 'images' => [], // TODO + 'tags' => $tags, ]; + } - return $this->build(); + protected function convertAttachment(Attachment $attachment): array + { + $data = [ + 'name' => $attachment->name, + 'order' => $attachment->order, + ]; + + if ($attachment->external) { + $data['link'] = $attachment->path; + } else { + $data['file'] = $this->files->referenceForAttachment($attachment); + } + + return $data; + } + + protected function convertTag(Tag $tag): array + { + return [ + 'name' => $tag->name, + 'value' => $tag->value, + 'order' => $tag->order, + ]; } /** @@ -29,7 +75,7 @@ protected function build(): string { $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ - 'version' => trim(file_get_contents(base_path('version'))), + 'version' => trim(file_get_contents(base_path('version'))), 'id_ciphertext' => encrypt('bookstack'), ]; @@ -43,6 +89,18 @@ protected function build(): string $zip->addFromString('data.json', json_encode($this->data)); $zip->addEmptyDir('files'); + $toRemove = []; + $this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) { + $zip->addFile($filePath, "files/$fileRef"); + $toRemove[] = $filePath; + }); + + $zip->close(); + + foreach ($toRemove as $file) { + unlink($file); + } + return $zipFile; } } diff --git a/app/Exports/ZipExportFiles.php b/app/Exports/ZipExportFiles.php new file mode 100644 index 00000000000..d3ee70e93f2 --- /dev/null +++ b/app/Exports/ZipExportFiles.php @@ -0,0 +1,58 @@ + + */ + protected array $attachmentRefsById = []; + + public function __construct( + protected AttachmentService $attachmentService, + ) { + } + + /** + * Gain a reference to the given attachment instance. + * This is expected to be a file-based attachment that the user + * has visibility of, no permission/access checks are performed here. + */ + public function referenceForAttachment(Attachment $attachment): string + { + if (isset($this->attachmentRefsById[$attachment->id])) { + return $this->attachmentRefsById[$attachment->id]; + } + + do { + $fileName = Str::random(20) . '.' . $attachment->extension; + } while (in_array($fileName, $this->attachmentRefsById)); + + $this->attachmentRefsById[$attachment->id] = $fileName; + + return $fileName; + } + + /** + * Extract each of the ZIP export tracked files. + * Calls the given callback for each tracked file, passing a temporary + * file reference of the file contents, and the zip-local tracked reference. + */ + public function extractEach(callable $callback): void + { + foreach ($this->attachmentRefsById as $attachmentId => $ref) { + $attachment = Attachment::query()->find($attachmentId); + $stream = $this->attachmentService->streamAttachmentFromStorage($attachment); + $tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-'); + $tmpFileStream = fopen($tmpFile, 'w'); + stream_copy_to_stream($stream, $tmpFileStream); + $callback($tmpFile, $ref); + } + } +} diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index bd319fbd795..227649d8f00 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -13,14 +13,9 @@ class AttachmentService { - protected FilesystemManager $fileSystem; - - /** - * AttachmentService constructor. - */ - public function __construct(FilesystemManager $fileSystem) - { - $this->fileSystem = $fileSystem; + public function __construct( + protected FilesystemManager $fileSystem + ) { } /** diff --git a/lang/en/entities.php b/lang/en/entities.php index 35e6f050bb8..7e5a708ef62 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -39,6 +39,7 @@ 'export_pdf' => 'PDF File', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', diff --git a/resources/views/entities/export-menu.blade.php b/resources/views/entities/export-menu.blade.php index a55ab56d199..e58c842ba42 100644 --- a/resources/views/entities/export-menu.blade.php +++ b/resources/views/entities/export-menu.blade.php @@ -18,6 +18,7 @@ class="icon-list-item"
                                • {{ trans('entities.export_pdf') }}.pdf
                                • {{ trans('entities.export_text') }}.txt
                                • {{ trans('entities.export_md') }}.md
                                • +
                                • {{ trans('entities.export_zip') }}.zip
                                diff --git a/routes/web.php b/routes/web.php index 5220684c0b0..6ae70983d52 100644 --- a/routes/web.php +++ b/routes/web.php @@ -91,6 +91,7 @@ Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']); Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']); Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']); Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']); diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php new file mode 100644 index 00000000000..d8ce00be33f --- /dev/null +++ b/tests/Exports/ZipExportTest.php @@ -0,0 +1,15 @@ +entities->page(); + // TODO + } +} From 7c39dd5cba7b72184adb96a8236ca1a3c99f03e3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 20 Oct 2024 19:56:56 +0100 Subject: [PATCH 178/800] ZIP Export: Started building link/ref handling --- app/Exports/ZipExportBuilder.php | 54 +++---------- .../ZipExportModels/ZipExportAttachment.php | 37 +++++++++ .../ZipExportModels/ZipExportImage.php | 11 +++ .../ZipExportModels/ZipExportModel.php | 11 +++ app/Exports/ZipExportModels/ZipExportPage.php | 39 ++++++++++ app/Exports/ZipExportModels/ZipExportTag.php | 27 +++++++ app/Exports/ZipExportReferences.php | 55 ++++++++++++++ app/Exports/ZipReferenceParser.php | 75 +++++++++++++++++++ dev/docs/portable-zip-file-format.md | 1 + 9 files changed, 265 insertions(+), 45 deletions(-) create mode 100644 app/Exports/ZipExportModels/ZipExportAttachment.php create mode 100644 app/Exports/ZipExportModels/ZipExportImage.php create mode 100644 app/Exports/ZipExportModels/ZipExportModel.php create mode 100644 app/Exports/ZipExportModels/ZipExportPage.php create mode 100644 app/Exports/ZipExportModels/ZipExportTag.php create mode 100644 app/Exports/ZipExportReferences.php create mode 100644 app/Exports/ZipReferenceParser.php diff --git a/app/Exports/ZipExportBuilder.php b/app/Exports/ZipExportBuilder.php index 2b8b45d0d2a..720b4997d4a 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -2,10 +2,9 @@ namespace BookStack\Exports; -use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ZipExportException; -use BookStack\Uploads\Attachment; +use BookStack\Exports\ZipExportModels\ZipExportPage; use ZipArchive; class ZipExportBuilder @@ -13,7 +12,8 @@ class ZipExportBuilder protected array $data = []; public function __construct( - protected ZipExportFiles $files + protected ZipExportFiles $files, + protected ZipExportReferences $references, ) { } @@ -22,50 +22,12 @@ public function __construct( */ public function buildForPage(Page $page): string { - $this->data['page'] = $this->convertPage($page); - return $this->build(); - } + $exportPage = ZipExportPage::fromModel($page, $this->files); + $this->data['page'] = $exportPage; - protected function convertPage(Page $page): array - { - $tags = array_map($this->convertTag(...), $page->tags()->get()->all()); - $attachments = array_map($this->convertAttachment(...), $page->attachments()->get()->all()); - - return [ - 'id' => $page->id, - 'name' => $page->name, - 'html' => '', // TODO - 'markdown' => '', // TODO - 'priority' => $page->priority, - 'attachments' => $attachments, - 'images' => [], // TODO - 'tags' => $tags, - ]; - } - - protected function convertAttachment(Attachment $attachment): array - { - $data = [ - 'name' => $attachment->name, - 'order' => $attachment->order, - ]; + $this->references->addPage($exportPage); - if ($attachment->external) { - $data['link'] = $attachment->path; - } else { - $data['file'] = $this->files->referenceForAttachment($attachment); - } - - return $data; - } - - protected function convertTag(Tag $tag): array - { - return [ - 'name' => $tag->name, - 'value' => $tag->value, - 'order' => $tag->order, - ]; + return $this->build(); } /** @@ -73,6 +35,8 @@ protected function convertTag(Tag $tag): array */ protected function build(): string { + $this->references->buildReferences(); + $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ 'version' => trim(file_get_contents(base_path('version'))), diff --git a/app/Exports/ZipExportModels/ZipExportAttachment.php b/app/Exports/ZipExportModels/ZipExportAttachment.php new file mode 100644 index 00000000000..d6d674a913f --- /dev/null +++ b/app/Exports/ZipExportModels/ZipExportAttachment.php @@ -0,0 +1,37 @@ +id = $model->id; + $instance->name = $model->name; + + if ($model->external) { + $instance->link = $model->path; + } else { + $instance->file = $files->referenceForAttachment($model); + } + + return $instance; + } + + public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Attachment $attachment) use ($files) { + return self::fromModel($attachment, $files); + }, $attachmentArray)); + } +} diff --git a/app/Exports/ZipExportModels/ZipExportImage.php b/app/Exports/ZipExportModels/ZipExportImage.php new file mode 100644 index 00000000000..73fe3bbf594 --- /dev/null +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -0,0 +1,11 @@ +id = $model->id; + $instance->name = $model->name; + $instance->html = (new PageContent($model))->render(); + + if (!empty($model->markdown)) { + $instance->markdown = $model->markdown; + } + + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + $instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files); + + return $instance; + } +} diff --git a/app/Exports/ZipExportModels/ZipExportTag.php b/app/Exports/ZipExportModels/ZipExportTag.php new file mode 100644 index 00000000000..636c9ff6dc4 --- /dev/null +++ b/app/Exports/ZipExportModels/ZipExportTag.php @@ -0,0 +1,27 @@ +name = $model->name; + $instance->value = $model->value; + $instance->order = $model->order; + + return $instance; + } + + public static function fromModelArray(array $tagArray): array + { + return array_values(array_map(self::fromModel(...), $tagArray)); + } +} diff --git a/app/Exports/ZipExportReferences.php b/app/Exports/ZipExportReferences.php new file mode 100644 index 00000000000..89deb7edad3 --- /dev/null +++ b/app/Exports/ZipExportReferences.php @@ -0,0 +1,55 @@ +id) { + $this->pages[$page->id] = $page; + } + + foreach ($page->attachments as $attachment) { + if ($attachment->id) { + $this->attachments[$attachment->id] = $attachment; + } + } + } + + public function buildReferences(): void + { + // TODO - References to images, attachments, other entities + + // TODO - Parse page MD & HTML + foreach ($this->pages as $page) { + $page->html = $this->parser->parse($page->html ?? '', function (Model $model): ?string { + // TODO - Handle found link to $model + // - Validate we can see/access $model, or/and that it's + // part of the export in progress. + return '[CAT]'; + }); + // TODO - markdown + } + + // TODO - Parse chapter desc html + // TODO - Parse book desc html + } +} diff --git a/app/Exports/ZipReferenceParser.php b/app/Exports/ZipReferenceParser.php new file mode 100644 index 00000000000..6ca826bc3f4 --- /dev/null +++ b/app/Exports/ZipReferenceParser.php @@ -0,0 +1,75 @@ +modelResolvers = [ + new PagePermalinkModelResolver($queries->pages), + new PageLinkModelResolver($queries->pages), + new ChapterLinkModelResolver($queries->chapters), + new BookLinkModelResolver($queries->books), + // TODO - Image + // TODO - Attachment + ]; + } + + /** + * Parse and replace references in the given content. + * @param callable(Model):(string|null) $handler + */ + public function parse(string $content, callable $handler): string + { + $escapedBase = preg_quote(url('/'), '/'); + $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#]/"; + $matches = []; + preg_match_all($linkRegex, $content, $matches); + + if (count($matches) < 2) { + return $content; + } + + foreach ($matches[1] as $link) { + $model = $this->linkToModel($link); + if ($model) { + $result = $handler($model); + if ($result !== null) { + $content = str_replace($link, $result, $content); + } + } + } + + return $content; + } + + + /** + * Attempt to resolve the given link to a model using the instance model resolvers. + */ + protected function linkToModel(string $link): ?Model + { + foreach ($this->modelResolvers as $resolver) { + $model = $resolver->resolve($link); + if (!is_null($model)) { + return $model; + } + } + + return null; + } +} diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index d5635bd398d..7a99563d14b 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -128,6 +128,7 @@ File must be an image type accepted by BookStack (png, jpg, gif, webp) #### Attachment +- `id` - Number, optional, original ID for the attachment from exported system. - `name` - String, required, name of attachment. - `link` - String, semi-optional, URL of attachment. - `file` - String reference, semi-optional, reference to attachment file. From 3e656efb0088b180665e224a68adde061e86786b Mon Sep 17 00:00:00 2001 From: Rashad Date: Mon, 21 Oct 2024 02:42:49 +0530 Subject: [PATCH 179/800] Added include func for search api --- app/Api/ApiEntityListFormatter.php | 42 ++++++++++- app/Search/SearchApiController.php | 69 ++++++++++++++--- dev/api/requests/search-all.http | 2 +- dev/api/responses/search-all.json | 7 +- tests/Api/SearchApiTest.php | 117 ++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 20 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 436d66d598e..23fa8e6ea72 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -3,6 +3,7 @@ namespace BookStack\Api; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; class ApiEntityListFormatter { @@ -12,6 +13,11 @@ class ApiEntityListFormatter */ protected array $list = []; + /** + * Whether to include related titles in the response. + */ + protected bool $includeRelatedTitles = false; + /** * The fields to show in the formatted data. * Can be a plain string array item for a direct model field (If existing on model). @@ -20,8 +26,16 @@ class ApiEntityListFormatter * @var array */ protected array $fields = [ - 'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft', - 'template', 'priority', 'created_at', 'updated_at', + 'id', + 'name', + 'slug', + 'book_id', + 'chapter_id', + 'draft', + 'template', + 'priority', + 'created_at', + 'updated_at', ]; public function __construct(array $list) @@ -62,6 +76,30 @@ public function withTags(): self return $this; } + /** + * Enable the inclusion of related book and chapter titles in the response. + */ + public function withRelatedTitles(): self + { + $this->includeRelatedTitles = true; + + $this->withField('book_title', function (Entity $entity) { + if (method_exists($entity, 'book')) { + return $entity->book?->name; + } + return null; + }); + + $this->withField('chapter_title', function (Entity $entity) { + if ($entity instanceof Page && $entity->chapter_id) { + return optional($entity->getAttribute('chapter'))->name; + } + return null; + }); + + return $this; + } + /** * Format the data and return an array of formatted content. * @return array[] diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index d1619e11867..5072bd3b463 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -14,12 +14,23 @@ class SearchApiController extends ApiController protected $rules = [ 'all' => [ - 'query' => ['required'], - 'page' => ['integer', 'min:1'], - 'count' => ['integer', 'min:1', 'max:100'], + 'query' => ['required'], + 'page' => ['integer', 'min:1'], + 'count' => ['integer', 'min:1', 'max:100'], + 'include' => ['string', 'regex:/^[a-zA-Z,]*$/'], ], ]; + /** + * Valid include parameters and their corresponding formatter methods. + * These parameters allow for additional related data, like titles or tags, + * to be included in the search results when requested via the API. + */ + protected const VALID_INCLUDES = [ + 'titles' => 'withRelatedTitles', + 'tags' => 'withTags', + ]; + public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) { $this->searchRunner = $searchRunner; @@ -33,6 +44,13 @@ public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $ * for a full list of search term options. Results contain a 'type' property to distinguish * between: bookshelf, book, chapter & page. * + * This method now supports the 'include' parameter, which allows API clients to specify related + * fields (such as titles or tags) that should be included in the search results. + * + * The 'include' parameter is a comma-separated string. For example, adding `include=titles,tags` + * will include both titles and tags in the API response. If the parameter is not provided, only + * basic entity data will be returned. + * * The paging parameters and response format emulates a standard listing endpoint * but standard sorting and filtering cannot be done on this endpoint. If a count value * is provided this will only be taken as a suggestion. The results in the response @@ -45,22 +63,49 @@ public function all(Request $request) $options = SearchOptions::fromString($request->get('query') ?? ''); $page = intval($request->get('page', '0')) ?: 1; $count = min(intval($request->get('count', '0')) ?: 20, 100); + $includes = $this->parseIncludes($request->get('include', '')); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $this->resultsFormatter->format($results['results']->all(), $options); - $data = (new ApiEntityListFormatter($results['results']->all())) - ->withType()->withTags() - ->withField('preview_html', function (Entity $entity) { - return [ - 'name' => (string) $entity->getAttribute('preview_name'), - 'content' => (string) $entity->getAttribute('preview_content'), - ]; - })->format(); + $formatter = new ApiEntityListFormatter($results['results']->all()); + $formatter->withType(); // Always include type as it's essential for search results + + foreach ($includes as $include) { + if (isset(self::VALID_INCLUDES[$include])) { + $method = self::VALID_INCLUDES[$include]; + $formatter->$method(); + } + } + + $formatter->withField('preview_html', function (Entity $entity) { + return [ + 'name' => (string) $entity->getAttribute('preview_name'), + 'content' => (string) $entity->getAttribute('preview_content'), + ]; + }); return response()->json([ - 'data' => $data, + 'data' => $formatter->format(), 'total' => $results['total'], ]); } + + /** + * Parse and validate the include parameter. + * + * @param string $includeString Comma-separated list of includes + * @return array + */ + protected function parseIncludes(string $includeString): array + { + if (empty($includeString)) { + return []; + } + + return array_filter( + explode(',', strtolower($includeString)), + fn($include) => isset (self::VALID_INCLUDES[$include]) + ); + } } diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http index ee522381641..7fa1a304e21 100644 --- a/dev/api/requests/search-all.http +++ b/dev/api/requests/search-all.http @@ -1 +1 @@ -GET /api/search?query=cats+{created_by:me}&page=1&count=2 \ No newline at end of file +GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index 2c7584e3fd0..bb45b795903 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -9,6 +9,7 @@ "updated_at": "2021-11-14T15:57:35.000000Z", "type": "chapter", "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", + "book_title": "Cats", "preview_html": { "name": "A chapter for cats", "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" @@ -27,6 +28,8 @@ "updated_at": "2021-11-14T15:56:49.000000Z", "type": "page", "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", + "book_title": "Cats", + "chapter_title": "A chapter for cats", "preview_html": { "name": "The hows and whys of cats", "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." @@ -56,6 +59,8 @@ "updated_at": "2021-11-14T16:02:39.000000Z", "type": "page", "url": "https://example.com/books/my-book/page/how-advanced-are-cats", + "book_title": "Cats", + "chapter_title": "A chapter for cats", "preview_html": { "name": "How advanced are cats?", "content": "cats are some of the most advanced animals in the world." @@ -64,4 +69,4 @@ } ], "total": 3 -} \ No newline at end of file +} diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 2a186e8d632..b80ed4530ba 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; @@ -45,7 +46,7 @@ public function test_all_endpoint_returns_entity_url() $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ 'type' => 'page', - 'url' => $page->getUrl(), + 'url' => $page->getUrl(), ]); } @@ -57,10 +58,10 @@ public function test_all_endpoint_returns_items_with_preview_html() $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ - 'type' => 'book', - 'url' => $book->getUrl(), + 'type' => 'book', + 'url' => $book->getUrl(), 'preview_html' => [ - 'name' => 'name with superuniquevalue within', + 'name' => 'name with superuniquevalue within', 'content' => 'Description with superuniquevalue within', ], ]); @@ -74,4 +75,112 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } + + public function test_all_endpoint_includes_book_and_chapter_titles_when_requested() + { + $this->actingAsApiEditor(); + + $book = $this->entities->book(); + $chapter = $this->entities->chapter(); + $page = $this->entities->newPage(); + + $book->name = 'My Test Book'; + $book->save(); + + $chapter->name = 'My Test Chapter'; + $chapter->book_id = $book->id; + $chapter->save(); + + $page->name = 'My Test Page With UniqueSearchTerm'; + $page->book_id = $book->id; + $page->chapter_id = $chapter->id; + $page->save(); + + $page->indexForSearch(); + + // Test without include parameter + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); + $resp->assertOk(); + $resp->assertDontSee('book_title'); + $resp->assertDontSee('chapter_title'); + + // Test with include parameter + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=titles'); + $resp->assertOk(); + $resp->assertJsonFragment([ + 'name' => 'My Test Page With UniqueSearchTerm', + 'book_title' => 'My Test Book', + 'chapter_title' => 'My Test Chapter', + 'type' => 'page' + ]); + } + + public function test_all_endpoint_validates_include_parameter() + { + $this->actingAsApiEditor(); + + // Test invalid include value + $resp = $this->getJson($this->baseEndpoint . '?query=test&include=invalid'); + $resp->assertOk(); + $resp->assertDontSee('book_title'); + + // Test SQL injection attempt + $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles;DROP TABLE users'); + $resp->assertStatus(422); + + // Test multiple includes + $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles,tags'); + $resp->assertOk(); + } + + public function test_all_endpoint_includes_tags_when_requested() + { + $this->actingAsApiEditor(); + + // Create a page and give it a unique name for search + $page = $this->entities->page(); + $page->name = 'Page With UniqueSearchTerm'; + $page->save(); + + // Save tags to the page using the existing saveTagsToEntity method + $tags = [ + ['name' => 'SampleTag', 'value' => 'SampleValue'] + ]; + app(\BookStack\Activity\TagRepo::class)->saveTagsToEntity($page, $tags); + + // Ensure the page is indexed for search + $page->indexForSearch(); + + // Test without the "tags" include + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); + $resp->assertOk(); + $resp->assertDontSee('tags'); + + // Test with the "tags" include + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=tags'); + $resp->assertOk(); + + // Assert that tags are included in the response + $resp->assertJsonFragment([ + 'name' => 'SampleTag', + 'value' => 'SampleValue', + ]); + + // Optionally: check the structure to match the tag order as well + $resp->assertJsonStructure([ + 'data' => [ + '*' => [ + 'tags' => [ + '*' => [ + 'name', + 'value', + 'order', + ], + ], + ], + ], + ]); + } + + } From 90a80705180d003a1e896bc307d3abc1720366dc Mon Sep 17 00:00:00 2001 From: Rashad Date: Mon, 21 Oct 2024 03:01:33 +0530 Subject: [PATCH 180/800] Eager loading for titles --- app/Api/ApiEntityListFormatter.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 23fa8e6ea72..2fd9b7c5507 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -106,6 +106,10 @@ public function withRelatedTitles(): self */ public function format(): array { + if ($this->includeRelatedTitles) { + $this->loadRelatedTitles(); + } + $results = []; foreach ($this->list as $item) { @@ -115,6 +119,23 @@ public function format(): array return $results; } + /** + * Eager load the related book and chapter data when needed. + */ + protected function loadRelatedTitles(): void + { + $pages = collect($this->list)->filter(fn($item) => $item instanceof Page); + + foreach ($this->list as $entity) { + if (method_exists($entity, 'book')) { + $entity->load('book'); + } + if ($entity instanceof Page && $entity->chapter_id) { + $entity->load('chapter'); + } + } + } + /** * Format a single entity item to a plain array. */ From 06ffd8ee721a74dea9c584002b2793cc68c873a0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Oct 2024 12:13:41 +0100 Subject: [PATCH 181/800] Zip Exports: Added attachment/image link resolving & JSON null handling --- .../ZipExportModels/ZipExportAttachment.php | 2 +- .../ZipExportModels/ZipExportImage.php | 2 +- .../ZipExportModels/ZipExportModel.php | 17 +++++++--- app/Exports/ZipExportModels/ZipExportPage.php | 2 +- app/Exports/ZipExportModels/ZipExportTag.php | 2 +- app/Exports/ZipExportReferences.php | 3 ++ app/Exports/ZipReferenceParser.php | 6 ++-- .../AttachmentModelResolver.php | 22 +++++++++++++ .../ModelResolvers/ImageModelResolver.php | 33 +++++++++++++++++++ 9 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 app/References/ModelResolvers/AttachmentModelResolver.php create mode 100644 app/References/ModelResolvers/ImageModelResolver.php diff --git a/app/Exports/ZipExportModels/ZipExportAttachment.php b/app/Exports/ZipExportModels/ZipExportAttachment.php index d6d674a913f..d79a16cc109 100644 --- a/app/Exports/ZipExportModels/ZipExportAttachment.php +++ b/app/Exports/ZipExportModels/ZipExportAttachment.php @@ -5,7 +5,7 @@ use BookStack\Exports\ZipExportFiles; use BookStack\Uploads\Attachment; -class ZipExportAttachment implements ZipExportModel +class ZipExportAttachment extends ZipExportModel { public ?int $id = null; public string $name; diff --git a/app/Exports/ZipExportModels/ZipExportImage.php b/app/Exports/ZipExportModels/ZipExportImage.php index 73fe3bbf594..540d3d4e55d 100644 --- a/app/Exports/ZipExportModels/ZipExportImage.php +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -4,7 +4,7 @@ use BookStack\Activity\Models\Tag; -class ZipExportImage implements ZipExportModel +class ZipExportImage extends ZipExportModel { public string $name; public string $file; diff --git a/app/Exports/ZipExportModels/ZipExportModel.php b/app/Exports/ZipExportModels/ZipExportModel.php index e1cb616de52..26b994c018e 100644 --- a/app/Exports/ZipExportModels/ZipExportModel.php +++ b/app/Exports/ZipExportModels/ZipExportModel.php @@ -2,10 +2,19 @@ namespace BookStack\Exports\ZipExportModels; -use BookStack\App\Model; -use BookStack\Exports\ZipExportFiles; +use JsonSerializable; -interface ZipExportModel +abstract class ZipExportModel implements JsonSerializable { -// public static function fromModel(Model $model, ZipExportFiles $files): self; + /** + * Handle the serialization to JSON. + * For these exports, we filter out optional (represented as nullable) fields + * just to clean things up and prevent confusion to avoid null states in the + * resulting export format itself. + */ + public function jsonSerialize(): array + { + $publicProps = get_object_vars(...)->__invoke($this); + return array_filter($publicProps, fn ($value) => $value !== null); + } } diff --git a/app/Exports/ZipExportModels/ZipExportPage.php b/app/Exports/ZipExportModels/ZipExportPage.php index 6589ce60ae1..c7a9503546f 100644 --- a/app/Exports/ZipExportModels/ZipExportPage.php +++ b/app/Exports/ZipExportModels/ZipExportPage.php @@ -6,7 +6,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exports\ZipExportFiles; -class ZipExportPage implements ZipExportModel +class ZipExportPage extends ZipExportModel { public ?int $id = null; public string $name; diff --git a/app/Exports/ZipExportModels/ZipExportTag.php b/app/Exports/ZipExportModels/ZipExportTag.php index 636c9ff6dc4..09ae9f06cbe 100644 --- a/app/Exports/ZipExportModels/ZipExportTag.php +++ b/app/Exports/ZipExportModels/ZipExportTag.php @@ -4,7 +4,7 @@ use BookStack\Activity\Models\Tag; -class ZipExportTag implements ZipExportModel +class ZipExportTag extends ZipExportModel { public string $name; public ?string $value = null; diff --git a/app/Exports/ZipExportReferences.php b/app/Exports/ZipExportReferences.php index 89deb7edad3..76a7fedbec4 100644 --- a/app/Exports/ZipExportReferences.php +++ b/app/Exports/ZipExportReferences.php @@ -44,11 +44,14 @@ public function buildReferences(): void // TODO - Handle found link to $model // - Validate we can see/access $model, or/and that it's // part of the export in progress. + + // TODO - Add images after the above to files return '[CAT]'; }); // TODO - markdown } +// dd('end'); // TODO - Parse chapter desc html // TODO - Parse book desc html } diff --git a/app/Exports/ZipReferenceParser.php b/app/Exports/ZipReferenceParser.php index 6ca826bc3f4..820920da28d 100644 --- a/app/Exports/ZipReferenceParser.php +++ b/app/Exports/ZipReferenceParser.php @@ -4,9 +4,11 @@ use BookStack\App\Model; use BookStack\Entities\Queries\EntityQueries; +use BookStack\References\ModelResolvers\AttachmentModelResolver; use BookStack\References\ModelResolvers\BookLinkModelResolver; use BookStack\References\ModelResolvers\ChapterLinkModelResolver; use BookStack\References\ModelResolvers\CrossLinkModelResolver; +use BookStack\References\ModelResolvers\ImageModelResolver; use BookStack\References\ModelResolvers\PageLinkModelResolver; use BookStack\References\ModelResolvers\PagePermalinkModelResolver; @@ -24,8 +26,8 @@ public function __construct(EntityQueries $queries) new PageLinkModelResolver($queries->pages), new ChapterLinkModelResolver($queries->chapters), new BookLinkModelResolver($queries->books), - // TODO - Image - // TODO - Attachment + new ImageModelResolver(), + new AttachmentModelResolver(), ]; } diff --git a/app/References/ModelResolvers/AttachmentModelResolver.php b/app/References/ModelResolvers/AttachmentModelResolver.php new file mode 100644 index 00000000000..e870d515bbe --- /dev/null +++ b/app/References/ModelResolvers/AttachmentModelResolver.php @@ -0,0 +1,22 @@ +find($id); + } +} diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php new file mode 100644 index 00000000000..331dd593b73 --- /dev/null +++ b/app/References/ModelResolvers/ImageModelResolver.php @@ -0,0 +1,33 @@ +where('path', '=', $fullPath)->first(); + } +} From 4fb4fe0931d220ffb9d7e173388351047b665f4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Oct 2024 13:59:15 +0100 Subject: [PATCH 182/800] ZIP Exports: Added working image handling/inclusion --- app/Exports/ZipExportBuilder.php | 2 +- app/Exports/ZipExportFiles.php | 51 ++++++++++++++- .../ZipExportModels/ZipExportImage.php | 16 ++++- app/Exports/ZipExportReferences.php | 62 ++++++++++++++++--- app/Uploads/ImageService.php | 13 ++++ app/Uploads/ImageStorageDisk.php | 9 +++ dev/docs/portable-zip-file-format.md | 13 ++-- 7 files changed, 148 insertions(+), 18 deletions(-) diff --git a/app/Exports/ZipExportBuilder.php b/app/Exports/ZipExportBuilder.php index 720b4997d4a..5c56e531b40 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -35,7 +35,7 @@ public function buildForPage(Page $page): string */ protected function build(): string { - $this->references->buildReferences(); + $this->references->buildReferences($this->files); $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ diff --git a/app/Exports/ZipExportFiles.php b/app/Exports/ZipExportFiles.php index d3ee70e93f2..27b6f937a38 100644 --- a/app/Exports/ZipExportFiles.php +++ b/app/Exports/ZipExportFiles.php @@ -4,6 +4,8 @@ use BookStack\Uploads\Attachment; use BookStack\Uploads\AttachmentService; +use BookStack\Uploads\Image; +use BookStack\Uploads\ImageService; use Illuminate\Support\Str; class ZipExportFiles @@ -14,8 +16,15 @@ class ZipExportFiles */ protected array $attachmentRefsById = []; + /** + * References for images by image ID. + * @var array + */ + protected array $imageRefsById = []; + public function __construct( protected AttachmentService $attachmentService, + protected ImageService $imageService, ) { } @@ -30,15 +39,46 @@ public function referenceForAttachment(Attachment $attachment): string return $this->attachmentRefsById[$attachment->id]; } + $existingFiles = $this->getAllFileNames(); do { $fileName = Str::random(20) . '.' . $attachment->extension; - } while (in_array($fileName, $this->attachmentRefsById)); + } while (in_array($fileName, $existingFiles)); $this->attachmentRefsById[$attachment->id] = $fileName; return $fileName; } + /** + * Gain a reference to the given image instance. + * This is expected to be an image that the user has visibility of, + * no permission/access checks are performed here. + */ + public function referenceForImage(Image $image): string + { + if (isset($this->imageRefsById[$image->id])) { + return $this->imageRefsById[$image->id]; + } + + $existingFiles = $this->getAllFileNames(); + $extension = pathinfo($image->path, PATHINFO_EXTENSION); + do { + $fileName = Str::random(20) . '.' . $extension; + } while (in_array($fileName, $existingFiles)); + + $this->imageRefsById[$image->id] = $fileName; + + return $fileName; + } + + protected function getAllFileNames(): array + { + return array_merge( + array_values($this->attachmentRefsById), + array_values($this->imageRefsById), + ); + } + /** * Extract each of the ZIP export tracked files. * Calls the given callback for each tracked file, passing a temporary @@ -54,5 +94,14 @@ public function extractEach(callable $callback): void stream_copy_to_stream($stream, $tmpFileStream); $callback($tmpFile, $ref); } + + foreach ($this->imageRefsById as $imageId => $ref) { + $image = Image::query()->find($imageId); + $stream = $this->imageService->getImageStream($image); + $tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-'); + $tmpFileStream = fopen($tmpFile, 'w'); + stream_copy_to_stream($stream, $tmpFileStream); + $callback($tmpFile, $ref); + } } } diff --git a/app/Exports/ZipExportModels/ZipExportImage.php b/app/Exports/ZipExportModels/ZipExportImage.php index 540d3d4e55d..39f1d101298 100644 --- a/app/Exports/ZipExportModels/ZipExportImage.php +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -2,10 +2,24 @@ namespace BookStack\Exports\ZipExportModels; -use BookStack\Activity\Models\Tag; +use BookStack\Exports\ZipExportFiles; +use BookStack\Uploads\Image; class ZipExportImage extends ZipExportModel { + public ?int $id = null; public string $name; public string $file; + public string $type; + + public static function fromModel(Image $model, ZipExportFiles $files): self + { + $instance = new self(); + $instance->id = $model->id; + $instance->name = $model->name; + $instance->type = $model->type; + $instance->file = $files->referenceForImage($model); + + return $instance; + } } diff --git a/app/Exports/ZipExportReferences.php b/app/Exports/ZipExportReferences.php index 76a7fedbec4..19672db0a0e 100644 --- a/app/Exports/ZipExportReferences.php +++ b/app/Exports/ZipExportReferences.php @@ -3,8 +3,13 @@ namespace BookStack\Exports; use BookStack\App\Model; +use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExportModels\ZipExportAttachment; +use BookStack\Exports\ZipExportModels\ZipExportImage; +use BookStack\Exports\ZipExportModels\ZipExportModel; use BookStack\Exports\ZipExportModels\ZipExportPage; +use BookStack\Uploads\Attachment; +use BookStack\Uploads\Image; class ZipExportReferences { @@ -16,6 +21,9 @@ class ZipExportReferences /** @var ZipExportAttachment[] */ protected array $attachments = []; + /** @var ZipExportImage[] */ + protected array $images = []; + public function __construct( protected ZipReferenceParser $parser, ) { @@ -34,19 +42,12 @@ public function addPage(ZipExportPage $page): void } } - public function buildReferences(): void + public function buildReferences(ZipExportFiles $files): void { - // TODO - References to images, attachments, other entities - // TODO - Parse page MD & HTML foreach ($this->pages as $page) { - $page->html = $this->parser->parse($page->html ?? '', function (Model $model): ?string { - // TODO - Handle found link to $model - // - Validate we can see/access $model, or/and that it's - // part of the export in progress. - - // TODO - Add images after the above to files - return '[CAT]'; + $page->html = $this->parser->parse($page->html ?? '', function (Model $model) use ($files, $page) { + return $this->handleModelReference($model, $page, $files); }); // TODO - markdown } @@ -55,4 +56,45 @@ public function buildReferences(): void // TODO - Parse chapter desc html // TODO - Parse book desc html } + + protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string + { + // TODO - References to other entities + + // Handle attachment references + // No permission check needed here since they would only already exist in this + // reference context if already allowed via their entity access. + if ($model instanceof Attachment) { + if (isset($this->attachments[$model->id])) { + return "[[bsexport:attachment:{$model->id}]]"; + } + return null; + } + + // Handle image references + if ($model instanceof Image) { + // Only handle gallery and drawio images + if ($model->type !== 'gallery' && $model->type !== 'drawio') { + return null; + } + + // We don't expect images to be part of book/chapter content + if (!($exportModel instanceof ZipExportPage)) { + return null; + } + + $page = $model->getPage(); + if ($page && userCan('view', $page)) { + if (!isset($this->images[$model->id])) { + $exportImage = ZipExportImage::fromModel($model, $files); + $this->images[$model->id] = $exportImage; + $exportModel->images[] = $exportImage; + } + return "[[bsexport:image:{$model->id}]]"; + } + return null; + } + + return null; + } } diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 8d8da61ec18..e501cc7b12d 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -133,6 +133,19 @@ public function getImageData(Image $image): string return $disk->get($image->path); } + /** + * Get the raw data content from an image. + * + * @throws Exception + * @returns ?resource + */ + public function getImageStream(Image $image): mixed + { + $disk = $this->storage->getDisk(); + + return $disk->stream($image->path); + } + /** * Destroy an image along with its revisions, thumbnails and remaining folders. * diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 798b72abdbf..8df702e0d94 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -55,6 +55,15 @@ public function get(string $path): ?string return $this->filesystem->get($this->adjustPathForDisk($path)); } + /** + * Get a stream to the file at the given path. + * @returns ?resource + */ + public function stream(string $path): mixed + { + return $this->filesystem->readStream($this->adjustPathForDisk($path)); + } + /** * Save the given image data at the given path. Can choose to set * the image as public which will update its visibility after saving. diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 7a99563d14b..1ba5872018c 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -46,13 +46,12 @@ This can be done using the following format: [[bsexport::]] ``` -Images and attachments are referenced via their file name within the `files/` directory. -Otherwise, other content types are referenced by `id`. +References are to the `id` for data objects. Here's an example of each type of such reference that could be used: ``` -[[bsexport:image:an-image-path.png]] -[[bsexport:attachment:an-image-path.png]] +[[bsexport:image:22]] +[[bsexport:attachment:55]] [[bsexport:page:40]] [[bsexport:chapter:2]] [[bsexport:book:8]] @@ -121,10 +120,14 @@ The page editor type, and edit content will be determined by what content is pro #### Image +- `id` - Number, optional, original ID for the page from exported system. - `name` - String, required, name of image. - `file` - String reference, required, reference to image file. +- `type` - String, required, must be 'gallery' or 'drawio' -File must be an image type accepted by BookStack (png, jpg, gif, webp) +File must be an image type accepted by BookStack (png, jpg, gif, webp). +Images of type 'drawio' are expected to be png with draw.io drawing data +embedded within it. #### Attachment From f732ef05d5b60a48ce3b42e05155e9e91d92d927 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 23 Oct 2024 10:48:26 +0100 Subject: [PATCH 183/800] ZIP Exports: Reorganised files, added page md parsing --- .../Controllers/PageExportController.php | 2 +- .../Models}/ZipExportAttachment.php | 4 ++-- .../Models}/ZipExportImage.php | 4 ++-- .../Models}/ZipExportModel.php | 2 +- .../Models}/ZipExportPage.php | 4 ++-- .../Models}/ZipExportTag.php | 2 +- .../{ => ZipExports}/ZipExportBuilder.php | 4 ++-- .../{ => ZipExports}/ZipExportFiles.php | 2 +- .../{ => ZipExports}/ZipExportReferences.php | 23 +++++++++++-------- .../{ => ZipExports}/ZipReferenceParser.php | 2 +- 10 files changed, 26 insertions(+), 23 deletions(-) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportAttachment.php (90%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportImage.php (84%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportModel.php (92%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportPage.php (91%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportTag.php (92%) rename app/Exports/{ => ZipExports}/ZipExportBuilder.php (94%) rename app/Exports/{ => ZipExports}/ZipExportFiles.php (98%) rename app/Exports/{ => ZipExports}/ZipExportReferences.php (83%) rename app/Exports/{ => ZipExports}/ZipReferenceParser.php (98%) diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index 01611fd2121..34e67ffcf70 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -6,7 +6,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; -use BookStack\Exports\ZipExportBuilder; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; diff --git a/app/Exports/ZipExportModels/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php similarity index 90% rename from app/Exports/ZipExportModels/ZipExportAttachment.php rename to app/Exports/ZipExports/Models/ZipExportAttachment.php index d79a16cc109..8c89ae11f14 100644 --- a/app/Exports/ZipExportModels/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -1,8 +1,8 @@ pages as $page) { - $page->html = $this->parser->parse($page->html ?? '', function (Model $model) use ($files, $page) { + $handler = function (Model $model) use ($files, $page) { return $this->handleModelReference($model, $page, $files); - }); - // TODO - markdown + }; + + $page->html = $this->parser->parse($page->html ?? '', $handler); + if ($page->markdown) { + $page->markdown = $this->parser->parse($page->markdown, $handler); + } } // dd('end'); diff --git a/app/Exports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php similarity index 98% rename from app/Exports/ZipReferenceParser.php rename to app/Exports/ZipExports/ZipReferenceParser.php index 820920da28d..4d16dbc6110 100644 --- a/app/Exports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -1,6 +1,6 @@ Date: Wed, 23 Oct 2024 11:30:32 +0100 Subject: [PATCH 184/800] ZIP Exports: Added core logic for books/chapters --- app/Entities/Models/Chapter.php | 1 + .../ZipExports/Models/ZipExportBook.php | 53 ++++++++++++++++ .../ZipExports/Models/ZipExportChapter.php | 45 ++++++++++++++ .../ZipExports/Models/ZipExportPage.php | 12 ++++ app/Exports/ZipExports/ZipExportBuilder.php | 30 +++++++++ .../ZipExports/ZipExportReferences.php | 61 ++++++++++++++++--- dev/docs/portable-zip-file-format.md | 2 +- 7 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 app/Exports/ZipExports/Models/ZipExportBook.php create mode 100644 app/Exports/ZipExports/Models/ZipExportChapter.php diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index c926aaa647a..088d199da67 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -60,6 +60,7 @@ public function defaultTemplate(): BelongsTo /** * Get the visible pages in this chapter. + * @returns Collection */ public function getVisiblePages(): Collection { diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php new file mode 100644 index 00000000000..5a0c5806ba8 --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -0,0 +1,53 @@ +id = $model->id; + $instance->name = $model->name; + $instance->description_html = $model->descriptionHtml(); + + if ($model->cover) { + $instance->cover = $files->referenceForImage($model->cover); + } + + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + + $chapters = []; + $pages = []; + + $children = $model->getDirectVisibleChildren()->all(); + foreach ($children as $child) { + if ($child instanceof Chapter) { + $chapters[] = $child; + } else if ($child instanceof Page) { + $pages[] = $child; + } + } + + $instance->pages = ZipExportPage::fromModelArray($pages, $files); + $instance->chapters = ZipExportChapter::fromModelArray($chapters, $files); + + return $instance; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php new file mode 100644 index 00000000000..cd5765f48bc --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -0,0 +1,45 @@ +id = $model->id; + $instance->name = $model->name; + $instance->description_html = $model->descriptionHtml(); + $instance->priority = $model->priority; + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + + $pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all(); + $instance->pages = ZipExportPage::fromModelArray($pages, $files); + + return $instance; + } + + /** + * @param Chapter[] $chapterArray + * @return self[] + */ + public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Chapter $chapter) use ($files) { + return self::fromModel($chapter, $files); + }, $chapterArray)); + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index bae46ca8225..8075595f228 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -26,6 +26,7 @@ public static function fromModel(Page $model, ZipExportFiles $files): self $instance->id = $model->id; $instance->name = $model->name; $instance->html = (new PageContent($model))->render(); + $instance->priority = $model->priority; if (!empty($model->markdown)) { $instance->markdown = $model->markdown; @@ -36,4 +37,15 @@ public static function fromModel(Page $model, ZipExportFiles $files): self return $instance; } + + /** + * @param Page[] $pageArray + * @return self[] + */ + public static function fromModelArray(array $pageArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Page $page) use ($files) { + return self::fromModel($page, $files); + }, $pageArray)); + } } diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php index 15edebea574..42fb03541c0 100644 --- a/app/Exports/ZipExports/ZipExportBuilder.php +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -2,8 +2,12 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ZipExportException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; @@ -30,6 +34,32 @@ public function buildForPage(Page $page): string return $this->build(); } + /** + * @throws ZipExportException + */ + public function buildForChapter(Chapter $chapter): string + { + $exportChapter = ZipExportChapter::fromModel($chapter, $this->files); + $this->data['chapter'] = $exportChapter; + + $this->references->addChapter($exportChapter); + + return $this->build(); + } + + /** + * @throws ZipExportException + */ + public function buildForBook(Book $book): string + { + $exportBook = ZipExportBook::fromModel($book, $this->files); + $this->data['book'] = $exportBook; + + $this->references->addBook($exportBook); + + return $this->build(); + } + /** * @throws ZipExportException */ diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index c3565aaa383..1fce0fc972e 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -4,6 +4,8 @@ use BookStack\App\Model; use BookStack\Exports\ZipExports\Models\ZipExportAttachment; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; @@ -14,8 +16,10 @@ class ZipExportReferences { /** @var ZipExportPage[] */ protected array $pages = []; - protected array $books = []; + /** @var ZipExportChapter[] */ protected array $chapters = []; + /** @var ZipExportBook[] */ + protected array $books = []; /** @var ZipExportAttachment[] */ protected array $attachments = []; @@ -41,23 +45,64 @@ public function addPage(ZipExportPage $page): void } } + public function addChapter(ZipExportChapter $chapter): void + { + if ($chapter->id) { + $this->chapters[$chapter->id] = $chapter; + } + + foreach ($chapter->pages as $page) { + $this->addPage($page); + } + } + + public function addBook(ZipExportBook $book): void + { + if ($book->id) { + $this->chapters[$book->id] = $book; + } + + foreach ($book->pages as $page) { + $this->addPage($page); + } + + foreach ($book->chapters as $chapter) { + $this->addChapter($chapter); + } + } + public function buildReferences(ZipExportFiles $files): void { - // Parse page content first - foreach ($this->pages as $page) { - $handler = function (Model $model) use ($files, $page) { - return $this->handleModelReference($model, $page, $files); + $createHandler = function (ZipExportModel $zipModel) use ($files) { + return function (Model $model) use ($files, $zipModel) { + return $this->handleModelReference($model, $zipModel, $files); }; + }; + // Parse page content first + foreach ($this->pages as $page) { + $handler = $createHandler($page); $page->html = $this->parser->parse($page->html ?? '', $handler); if ($page->markdown) { $page->markdown = $this->parser->parse($page->markdown, $handler); } } -// dd('end'); - // TODO - Parse chapter desc html - // TODO - Parse book desc html + // Parse chapter description HTML + foreach ($this->chapters as $chapter) { + if ($chapter->description_html) { + $handler = $createHandler($chapter); + $chapter->description_html = $this->parser->parse($chapter->description_html, $handler); + } + } + + // Parse book description HTML + foreach ($this->books as $book) { + if ($book->description_html) { + $handler = $createHandler($book); + $book->description_html = $this->parser->parse($book->description_html, $handler); + } + } } protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 1ba5872018c..6cee7356d23 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -87,7 +87,7 @@ The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This i - `id` - Number, optional, original ID for the book from exported system. - `name` - String, required, name/title of the book. - `description_html` - String, optional, HTML description content. -- `cover` - String reference, options, reference to book cover image. +- `cover` - String reference, optional, reference to book cover image. - `chapters` - [Chapter](#chapter) array, optional, chapters within this book. - `pages` - [Page](#page) array, optional, direct child pages for this book. - `tags` - [Tag](#tag) array, optional, tags assigned to this book. From 484342f26adab723b8c4625d22a8901f5bfe79af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 23 Oct 2024 15:59:58 +0100 Subject: [PATCH 185/800] ZIP Exports: Added entity cross refs, Started export tests --- .../Controllers/BookExportController.php | 14 +++ .../Controllers/ChapterExportController.php | 13 +++ .../ZipExports/ZipExportReferences.php | 14 ++- routes/web.php | 1 + tests/Exports/ZipExportTest.php | 85 ++++++++++++++++++- tests/Exports/ZipResultData.php | 13 +++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/Exports/ZipResultData.php diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php index 36906b6ad7b..f726175a086 100644 --- a/app/Exports/Controllers/BookExportController.php +++ b/app/Exports/Controllers/BookExportController.php @@ -3,7 +3,9 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\BookQueries; +use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; @@ -63,4 +65,16 @@ public function markdown(string $bookSlug) return $this->download()->directly($textContent, $bookSlug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, ZipExportBuilder $builder) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $zip = $builder->buildForBook($book); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index d85b90dcb41..0d7a5c0d195 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -5,6 +5,7 @@ use BookStack\Entities\Queries\ChapterQueries; use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; @@ -70,4 +71,16 @@ public function markdown(string $bookSlug, string $chapterSlug) return $this->download()->directly($chapterText, $chapterSlug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $zip = $builder->buildForChapter($chapter); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index 1fce0fc972e..8b3a4b612fe 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -3,6 +3,9 @@ namespace BookStack\Exports\ZipExports; use BookStack\App\Model; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\Models\ZipExportAttachment; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; @@ -107,8 +110,6 @@ public function buildReferences(ZipExportFiles $files): void protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string { - // TODO - References to other entities - // Handle attachment references // No permission check needed here since they would only already exist in this // reference context if already allowed via their entity access. @@ -143,6 +144,15 @@ protected function handleModelReference(Model $model, ZipExportModel $exportMode return null; } + // Handle entity references + if ($model instanceof Book && isset($this->books[$model->id])) { + return "[[bsexport:book:{$model->id}]]"; + } else if ($model instanceof Chapter && isset($this->chapters[$model->id])) { + return "[[bsexport:chapter:{$model->id}]]"; + } else if ($model instanceof Page && isset($this->pages[$model->id])) { + return "[[bsexport:page:{$model->id}]]"; + } + return null; } } diff --git a/routes/web.php b/routes/web.php index 6ae70983d52..e6f3683c643 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,6 +132,7 @@ Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']); diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index d8ce00be33f..536e23806f6 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -2,14 +2,95 @@ namespace Tests\Exports; -use BookStack\Entities\Models\Book; +use Illuminate\Support\Carbon; +use Illuminate\Testing\TestResponse; use Tests\TestCase; +use ZipArchive; class ZipExportTest extends TestCase { - public function test_page_export() + public function test_export_results_in_zip_format() + { + $page = $this->entities->page(); + $response = $this->asEditor()->get($page->getUrl("/export/zip")); + + $zipData = $response->streamedContent(); + $zipFile = tempnam(sys_get_temp_dir(), 'bstesta-'); + file_put_contents($zipFile, $zipData); + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::RDONLY); + + $this->assertNotFalse($zip->locateName('data.json')); + $this->assertNotFalse($zip->locateName('files/')); + + $data = json_decode($zip->getFromName('data.json'), true); + $this->assertIsArray($data); + $this->assertGreaterThan(0, count($data)); + + $zip->close(); + unlink($zipFile); + } + + public function test_export_metadata() { $page = $this->entities->page(); + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $this->assertEquals($page->id, $zip->data['page']['id'] ?? null); + $this->assertArrayNotHasKey('book', $zip->data); + $this->assertArrayNotHasKey('chapter', $zip->data); + + $now = time(); + $date = Carbon::parse($zip->data['exported_at'])->unix(); + $this->assertLessThan($now + 2, $date); + $this->assertGreaterThan($now - 2, $date); + + $version = trim(file_get_contents(base_path('version'))); + $this->assertEquals($version, $zip->data['instance']['version']); + + $instanceId = decrypt($zip->data['instance']['id_ciphertext']); + $this->assertEquals('bookstack', $instanceId); + } + + public function test_page_export() + { + // TODO + } + + public function test_book_export() + { + // TODO + } + + public function test_chapter_export() + { // TODO } + + protected function extractZipResponse(TestResponse $response): ZipResultData + { + $zipData = $response->streamedContent(); + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + file_put_contents($zipFile, $zipData); + $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-'); + if (file_exists($extractDir)) { + unlink($extractDir); + } + mkdir($extractDir); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::RDONLY); + $zip->extractTo($extractDir); + + $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json"); + $data = json_decode($dataJson, true); + + return new ZipResultData( + $zipFile, + $extractDir, + $data, + ); + } } diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php new file mode 100644 index 00000000000..b5cc2b4ca61 --- /dev/null +++ b/tests/Exports/ZipResultData.php @@ -0,0 +1,13 @@ + Date: Sat, 26 Oct 2024 13:24:49 +0200 Subject: [PATCH 186/800] fix tests namespace definition --- tests/Activity/AuditLogApiTest.php | 2 +- tests/Activity/AuditLogTest.php | 2 +- tests/Activity/WebhookCallTest.php | 2 +- tests/Activity/WebhookFormatTesting.php | 2 +- tests/Activity/WebhookManagementTest.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Activity/AuditLogApiTest.php b/tests/Activity/AuditLogApiTest.php index 75cc364f895..2212ae5991a 100644 --- a/tests/Activity/AuditLogApiTest.php +++ b/tests/Activity/AuditLogApiTest.php @@ -1,6 +1,6 @@ Date: Sun, 27 Oct 2024 14:33:43 +0000 Subject: [PATCH 187/800] ZIP Exports: Tested each type and model of export --- .../ZipExports/Models/ZipExportAttachment.php | 1 + .../ZipExports/ZipExportReferences.php | 2 +- app/Exports/ZipExports/ZipReferenceParser.php | 2 +- tests/Exports/ZipExportTest.php | 265 +++++++++++++++++- tests/Exports/ZipResultData.php | 9 + 5 files changed, 274 insertions(+), 5 deletions(-) diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 8c89ae11f14..283ffa751c9 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -18,6 +18,7 @@ public static function fromModel(Attachment $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; + $instance->order = $model->order; if ($model->external) { $instance->link = $model->path; diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index 8b3a4b612fe..c630c832b37 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -62,7 +62,7 @@ public function addChapter(ZipExportChapter $chapter): void public function addBook(ZipExportBook $book): void { if ($book->id) { - $this->chapters[$book->id] = $book; + $this->books[$book->id] = $book; } foreach ($book->pages as $page) { diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index 4d16dbc6110..da43d1b366b 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -38,7 +38,7 @@ public function __construct(EntityQueries $queries) public function parse(string $content, callable $handler): string { $escapedBase = preg_quote(url('/'), '/'); - $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#]/"; + $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#()]/"; $matches = []; preg_match_all($linkRegex, $content, $matches); diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 536e23806f6..ac07b33aef5 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -2,6 +2,11 @@ namespace Tests\Exports; +use BookStack\Activity\Models\Tag; +use BookStack\Entities\Repos\BookRepo; +use BookStack\Entities\Tools\PageContent; +use BookStack\Uploads\Attachment; +use BookStack\Uploads\Image; use Illuminate\Support\Carbon; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -55,17 +60,271 @@ public function test_export_metadata() public function test_page_export() { - // TODO + $page = $this->entities->page(); + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals([ + 'id' => $page->id, + 'name' => $page->name, + 'html' => (new PageContent($page))->render(), + 'priority' => $page->priority, + 'attachments' => [], + 'images' => [], + 'tags' => [], + ], $pageData); + } + + public function test_page_export_with_markdown() + { + $page = $this->entities->page(); + $markdown = "# My page\n\nwritten in markdown for export\n"; + $page->markdown = $markdown; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals($markdown, $pageData['markdown']); + $this->assertNotEmpty($pageData['html']); + } + + public function test_page_export_with_tags() + { + $page = $this->entities->page(); + $page->tags()->saveMany([ + new Tag(['name' => 'Exporty', 'value' => 'Content', 'order' => 1]), + new Tag(['name' => 'Another', 'value' => '', 'order' => 2]), + ]); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals([ + [ + 'name' => 'Exporty', + 'value' => 'Content', + 'order' => 1, + ], + [ + 'name' => 'Another', + 'value' => '', + 'order' => 2, + ] + ], $pageData['tags']); + } + + public function test_page_export_with_images() + { + $this->asEditor(); + $page = $this->entities->page(); + $result = $this->files->uploadGalleryImageToPage($this, $page); + $displayThumb = $result['response']->thumbs->gallery ?? ''; + $page->html = '

                                My image

                                '; + $page->save(); + $image = Image::findOrFail($result['response']->id); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertCount(1, $pageData['images']); + $imageData = $pageData['images'][0]; + $this->assertEquals($image->id, $imageData['id']); + $this->assertEquals($image->name, $imageData['name']); + $this->assertEquals('gallery', $imageData['type']); + $this->assertNotEmpty($imageData['file']); + + $filePath = $zip->extractPath("files/{$imageData['file']}"); + $this->assertFileExists($filePath); + $this->assertEquals(file_get_contents(public_path($image->path)), file_get_contents($filePath)); + + $this->assertEquals('

                                My image

                                ', $pageData['html']); + } + + public function test_page_export_file_attachments() + { + $contents = 'My great attachment content!'; + + $page = $this->entities->page(); + $this->asAdmin(); + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain'); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertCount(1, $pageData['attachments']); + + $attachmentData = $pageData['attachments'][0]; + $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']); + $this->assertEquals($attachment->id, $attachmentData['id']); + $this->assertEquals(1, $attachmentData['order']); + $this->assertArrayNotHasKey('link', $attachmentData); + $this->assertNotEmpty($attachmentData['file']); + + $fileRef = $attachmentData['file']; + $filePath = $zip->extractPath("/files/$fileRef"); + $this->assertFileExists($filePath); + $this->assertEquals($contents, file_get_contents($filePath)); + } + + public function test_page_export_link_attachments() + { + $page = $this->entities->page(); + $this->asEditor(); + $attachment = Attachment::factory()->create([ + 'name' => 'My link attachment for export', + 'path' => 'https://example.com/cats', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + ]); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertCount(1, $pageData['attachments']); + + $attachmentData = $pageData['attachments'][0]; + $this->assertEquals('My link attachment for export', $attachmentData['name']); + $this->assertEquals($attachment->id, $attachmentData['id']); + $this->assertEquals(1, $attachmentData['order']); + $this->assertEquals('https://example.com/cats', $attachmentData['link']); + $this->assertArrayNotHasKey('file', $attachmentData); } public function test_book_export() { - // TODO + $book = $this->entities->book(); + $book->tags()->saveMany(Tag::factory()->count(2)->make()); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertArrayHasKey('book', $zip->data); + + $bookData = $zip->data['book']; + $this->assertEquals($book->id, $bookData['id']); + $this->assertEquals($book->name, $bookData['name']); + $this->assertEquals($book->descriptionHtml(), $bookData['description_html']); + $this->assertCount(2, $bookData['tags']); + $this->assertCount($book->directPages()->count(), $bookData['pages']); + $this->assertCount($book->chapters()->count(), $bookData['chapters']); + $this->assertArrayNotHasKey('cover', $bookData); + } + + public function test_book_export_with_cover_image() + { + $book = $this->entities->book(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->files->uploadedImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + $coverImage = $book->cover()->first(); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $this->assertArrayHasKey('cover', $zip->data['book']); + $coverRef = $zip->data['book']['cover']; + $coverPath = $zip->extractPath("/files/$coverRef"); + $this->assertFileExists($coverPath); + $this->assertEquals(file_get_contents(public_path($coverImage->path)), file_get_contents($coverPath)); } public function test_chapter_export() { - // TODO + $chapter = $this->entities->chapter(); + $chapter->tags()->saveMany(Tag::factory()->count(2)->make()); + + $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertArrayHasKey('chapter', $zip->data); + + $chapterData = $zip->data['chapter']; + $this->assertEquals($chapter->id, $chapterData['id']); + $this->assertEquals($chapter->name, $chapterData['name']); + $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']); + $this->assertCount(2, $chapterData['tags']); + $this->assertEquals($chapter->priority, $chapterData['priority']); + $this->assertCount($chapter->pages()->count(), $chapterData['pages']); + } + + + public function test_cross_reference_links_are_converted() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + $book->description_html = '

                                Link to chapter

                                '; + $book->save(); + $chapter->description_html = '

                                Link to page

                                '; + $chapter->save(); + $page->html = '

                                Link to book

                                '; + $page->save(); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $bookData = $zip->data['book']; + $chapterData = $bookData['chapters'][0]; + $pageData = $chapterData['pages'][0]; + + $this->assertStringContainsString('href="[[bsexport:chapter:' . $chapter->id . ']]"', $bookData['description_html']); + $this->assertStringContainsString('href="[[bsexport:page:' . $page->id . ']]#section2"', $chapterData['description_html']); + $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']); + } + + public function test_cross_reference_links_external_to_export_are_not_converted() + { + $page = $this->entities->page(); + $page->html = '

                                Link to book

                                '; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']); + } + + public function test_attachments_links_are_converted() + { + $page = $this->entities->page(); + $attachment = Attachment::factory()->create([ + 'name' => 'My link attachment for export reference', + 'path' => 'https://example.com/cats/ref', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + ]); + + $page->html = '

                                id}") . '?open=true">Link to attachment

                                '; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']); + } + + public function test_links_in_markdown_are_parsed() + { + $chapter = $this->entities->chapterHasPages(); + $page = $chapter->pages()->first(); + + $page->markdown = "[Link to chapter]({$chapter->getUrl()})"; + $page->save(); + + $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['chapter']['pages'][0]; + + $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']); } protected function extractZipResponse(TestResponse $response): ZipResultData diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php index b5cc2b4ca61..7725004c7be 100644 --- a/tests/Exports/ZipResultData.php +++ b/tests/Exports/ZipResultData.php @@ -10,4 +10,13 @@ public function __construct( public array $data, ) { } + + /** + * Build a path to a location the extracted content, using the given relative $path. + */ + public function extractPath(string $path): string + { + $relPath = implode(DIRECTORY_SEPARATOR, explode('/', $path)); + return $this->extractedDirPath . DIRECTORY_SEPARATOR . ltrim($relPath, DIRECTORY_SEPARATOR); + } } From f60671146379e76550ce81f7a8738f848ebc63de Mon Sep 17 00:00:00 2001 From: Rashad Date: Sun, 27 Oct 2024 22:50:20 +0530 Subject: [PATCH 188/800] respective book and chapter structure added. --- app/Api/ApiEntityListFormatter.php | 23 ++--- app/Search/SearchApiController.php | 63 ++---------- dev/api/requests/search-all.http | 2 +- dev/api/responses/search-all.json | 152 ++++++++++++++++------------- tests/Api/SearchApiTest.php | 109 --------------------- 5 files changed, 103 insertions(+), 246 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 2fd9b7c5507..7c2d09d4f3a 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -13,11 +13,6 @@ class ApiEntityListFormatter */ protected array $list = []; - /** - * Whether to include related titles in the response. - */ - protected bool $includeRelatedTitles = false; - /** * The fields to show in the formatted data. * Can be a plain string array item for a direct model field (If existing on model). @@ -79,20 +74,18 @@ public function withTags(): self /** * Enable the inclusion of related book and chapter titles in the response. */ - public function withRelatedTitles(): self + public function withRelatedData(): self { - $this->includeRelatedTitles = true; - - $this->withField('book_title', function (Entity $entity) { + $this->withField('book', function (Entity $entity) { if (method_exists($entity, 'book')) { - return $entity->book?->name; + return $entity->book()->select(['id', 'name', 'slug'])->first(); } return null; }); - $this->withField('chapter_title', function (Entity $entity) { + $this->withField('chapter', function (Entity $entity) { if ($entity instanceof Page && $entity->chapter_id) { - return optional($entity->getAttribute('chapter'))->name; + return $entity->chapter()->select(['id', 'name', 'slug'])->first(); } return null; }); @@ -106,9 +99,7 @@ public function withRelatedTitles(): self */ public function format(): array { - if ($this->includeRelatedTitles) { - $this->loadRelatedTitles(); - } + $this->loadRelatedData(); $results = []; @@ -122,7 +113,7 @@ public function format(): array /** * Eager load the related book and chapter data when needed. */ - protected function loadRelatedTitles(): void + protected function loadRelatedData(): void { $pages = collect($this->list)->filter(fn($item) => $item instanceof Page); diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 5072bd3b463..28a3b53e6c3 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -17,20 +17,9 @@ class SearchApiController extends ApiController 'query' => ['required'], 'page' => ['integer', 'min:1'], 'count' => ['integer', 'min:1', 'max:100'], - 'include' => ['string', 'regex:/^[a-zA-Z,]*$/'], ], ]; - /** - * Valid include parameters and their corresponding formatter methods. - * These parameters allow for additional related data, like titles or tags, - * to be included in the search results when requested via the API. - */ - protected const VALID_INCLUDES = [ - 'titles' => 'withRelatedTitles', - 'tags' => 'withTags', - ]; - public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) { $this->searchRunner = $searchRunner; @@ -44,13 +33,6 @@ public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $ * for a full list of search term options. Results contain a 'type' property to distinguish * between: bookshelf, book, chapter & page. * - * This method now supports the 'include' parameter, which allows API clients to specify related - * fields (such as titles or tags) that should be included in the search results. - * - * The 'include' parameter is a comma-separated string. For example, adding `include=titles,tags` - * will include both titles and tags in the API response. If the parameter is not provided, only - * basic entity data will be returned. - * * The paging parameters and response format emulates a standard listing endpoint * but standard sorting and filtering cannot be done on this endpoint. If a count value * is provided this will only be taken as a suggestion. The results in the response @@ -63,49 +45,22 @@ public function all(Request $request) $options = SearchOptions::fromString($request->get('query') ?? ''); $page = intval($request->get('page', '0')) ?: 1; $count = min(intval($request->get('count', '0')) ?: 20, 100); - $includes = $this->parseIncludes($request->get('include', '')); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $this->resultsFormatter->format($results['results']->all(), $options); - $formatter = new ApiEntityListFormatter($results['results']->all()); - $formatter->withType(); // Always include type as it's essential for search results - - foreach ($includes as $include) { - if (isset(self::VALID_INCLUDES[$include])) { - $method = self::VALID_INCLUDES[$include]; - $formatter->$method(); - } - } - - $formatter->withField('preview_html', function (Entity $entity) { - return [ - 'name' => (string) $entity->getAttribute('preview_name'), - 'content' => (string) $entity->getAttribute('preview_content'), - ]; - }); + $data = (new ApiEntityListFormatter($results['results']->all())) + ->withType()->withTags()->withRelatedData() + ->withField('preview_html', function (Entity $entity) { + return [ + 'name' => (string) $entity->getAttribute('preview_name'), + 'content' => (string) $entity->getAttribute('preview_content'), + ]; + })->format(); return response()->json([ - 'data' => $formatter->format(), + 'data' => $data, 'total' => $results['total'], ]); } - - /** - * Parse and validate the include parameter. - * - * @param string $includeString Comma-separated list of includes - * @return array - */ - protected function parseIncludes(string $includeString): array - { - if (empty($includeString)) { - return []; - } - - return array_filter( - explode(',', strtolower($includeString)), - fn($include) => isset (self::VALID_INCLUDES[$include]) - ); - } } diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http index 7fa1a304e21..f9c17fa1696 100644 --- a/dev/api/requests/search-all.http +++ b/dev/api/requests/search-all.http @@ -1 +1 @@ -GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags +GET /api/search?query=cats+{created_by:me}&page=1&count=2 diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index bb45b795903..f60a12f750d 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -1,72 +1,92 @@ { - "data": [ - { - "id": 84, - "book_id": 1, - "slug": "a-chapter-for-cats", - "name": "A chapter for cats", - "created_at": "2021-11-14T15:57:35.000000Z", - "updated_at": "2021-11-14T15:57:35.000000Z", - "type": "chapter", - "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", - "book_title": "Cats", - "preview_html": { - "name": "A chapter for cats", - "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" - }, - "tags": [] - }, - { - "name": "The hows and whys of cats", - "id": 396, - "slug": "the-hows-and-whys-of-cats", - "book_id": 1, - "chapter_id": 75, - "draft": false, - "template": false, - "created_at": "2021-05-15T16:28:10.000000Z", - "updated_at": "2021-11-14T15:56:49.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", - "book_title": "Cats", - "chapter_title": "A chapter for cats", - "preview_html": { - "name": "The hows and whys of cats", - "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." - }, - "tags": [ + "data": [ { - "name": "Animal", - "value": "Cat", - "order": 0 + "id": 84, + "book_id": 1, + "slug": "a-chapter-for-cats", + "name": "A chapter for cats", + "created_at": "2021-11-14T15:57:35.000000Z", + "updated_at": "2021-11-14T15:57:35.000000Z", + "type": "chapter", + "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "preview_html": { + "name": "A chapter for cats", + "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" + }, + "tags": [] }, { - "name": "Category", - "value": "Top Content", - "order": 0 + "name": "The hows and whys of cats", + "id": 396, + "slug": "the-hows-and-whys-of-cats", + "book_id": 1, + "chapter_id": 75, + "draft": false, + "template": false, + "created_at": "2021-05-15T16:28:10.000000Z", + "updated_at": "2021-11-14T15:56:49.000000Z", + "type": "page", + "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 84, + "name": "A chapter for cats", + "slug": "a-chapter-for-cats" + }, + "preview_html": { + "name": "The hows and whys of cats", + "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." + }, + "tags": [ + { + "name": "Animal", + "value": "Cat", + "order": 0 + }, + { + "name": "Category", + "value": "Top Content", + "order": 0 + } + ] + }, + { + "name": "How advanced are cats?", + "id": 362, + "slug": "how-advanced-are-cats", + "book_id": 13, + "chapter_id": 73, + "draft": false, + "template": false, + "created_at": "2020-11-29T21:55:07.000000Z", + "updated_at": "2021-11-14T16:02:39.000000Z", + "type": "page", + "url": "https://example.com/books/my-book/page/how-advanced-are-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 84, + "name": "A chapter for cats", + "slug": "a-chapter-for-cats" + }, + "preview_html": { + "name": "How advanced are cats?", + "content": "cats are some of the most advanced animals in the world." + }, + "tags": [] } - ] - }, - { - "name": "How advanced are cats?", - "id": 362, - "slug": "how-advanced-are-cats", - "book_id": 13, - "chapter_id": 73, - "draft": false, - "template": false, - "created_at": "2020-11-29T21:55:07.000000Z", - "updated_at": "2021-11-14T16:02:39.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/how-advanced-are-cats", - "book_title": "Cats", - "chapter_title": "A chapter for cats", - "preview_html": { - "name": "How advanced are cats?", - "content": "cats are some of the most advanced animals in the world." - }, - "tags": [] - } - ], - "total": 3 + ], + "total": 3 } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index b80ed4530ba..3f2eb395c11 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -2,7 +2,6 @@ namespace Tests\Api; -use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; @@ -75,112 +74,4 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } - - public function test_all_endpoint_includes_book_and_chapter_titles_when_requested() - { - $this->actingAsApiEditor(); - - $book = $this->entities->book(); - $chapter = $this->entities->chapter(); - $page = $this->entities->newPage(); - - $book->name = 'My Test Book'; - $book->save(); - - $chapter->name = 'My Test Chapter'; - $chapter->book_id = $book->id; - $chapter->save(); - - $page->name = 'My Test Page With UniqueSearchTerm'; - $page->book_id = $book->id; - $page->chapter_id = $chapter->id; - $page->save(); - - $page->indexForSearch(); - - // Test without include parameter - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); - $resp->assertOk(); - $resp->assertDontSee('book_title'); - $resp->assertDontSee('chapter_title'); - - // Test with include parameter - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=titles'); - $resp->assertOk(); - $resp->assertJsonFragment([ - 'name' => 'My Test Page With UniqueSearchTerm', - 'book_title' => 'My Test Book', - 'chapter_title' => 'My Test Chapter', - 'type' => 'page' - ]); - } - - public function test_all_endpoint_validates_include_parameter() - { - $this->actingAsApiEditor(); - - // Test invalid include value - $resp = $this->getJson($this->baseEndpoint . '?query=test&include=invalid'); - $resp->assertOk(); - $resp->assertDontSee('book_title'); - - // Test SQL injection attempt - $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles;DROP TABLE users'); - $resp->assertStatus(422); - - // Test multiple includes - $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles,tags'); - $resp->assertOk(); - } - - public function test_all_endpoint_includes_tags_when_requested() - { - $this->actingAsApiEditor(); - - // Create a page and give it a unique name for search - $page = $this->entities->page(); - $page->name = 'Page With UniqueSearchTerm'; - $page->save(); - - // Save tags to the page using the existing saveTagsToEntity method - $tags = [ - ['name' => 'SampleTag', 'value' => 'SampleValue'] - ]; - app(\BookStack\Activity\TagRepo::class)->saveTagsToEntity($page, $tags); - - // Ensure the page is indexed for search - $page->indexForSearch(); - - // Test without the "tags" include - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); - $resp->assertOk(); - $resp->assertDontSee('tags'); - - // Test with the "tags" include - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=tags'); - $resp->assertOk(); - - // Assert that tags are included in the response - $resp->assertJsonFragment([ - 'name' => 'SampleTag', - 'value' => 'SampleValue', - ]); - - // Optionally: check the structure to match the tag order as well - $resp->assertJsonStructure([ - 'data' => [ - '*' => [ - 'tags' => [ - '*' => [ - 'name', - 'value', - 'order', - ], - ], - ], - ], - ]); - } - - } From 72d9ffd8b4a0680a858446c5f753db4f989f3989 Mon Sep 17 00:00:00 2001 From: Matthieu Leboeuf Date: Mon, 28 Oct 2024 22:14:30 +0100 Subject: [PATCH 189/800] Added support for concatenating multiple LDAP attributes in displayName --- app/Access/LdapService.php | 30 ++++++++++++++++++++++++++---- app/Config/services.php | 2 +- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index 365cb1db015..ef6d33f4db1 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -71,6 +71,28 @@ private function getUserWithAttributes(string $userName, array $attributes): ?ar return $users[0]; } + /** + * Calculate the display name. + */ + protected function getUserDisplayName(array $displayNameAttr, array $userDetails, string $defaultValue): string + { + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null); + if ($dnComponent !== null) { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName = $defaultValue; + } else { + $displayName = implode(' ', $displayName); + } + + return $displayName; + } + /** * Get the details of a user from LDAP using the given username. * User found via configurable user filter. @@ -84,9 +106,9 @@ public function getUserDetails(string $userName): ?array $displayNameAttr = $this->config['display_name_attribute']; $thumbnailAttr = $this->config['thumbnail_attribute']; - $user = $this->getUserWithAttributes($userName, array_filter([ - 'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr, - ])); + $user = $this->getUserWithAttributes($userName, array_filter(array_merge($displayNameAttr, [ + 'cn', 'dn', $idAttr, $emailAttr, $thumbnailAttr, + ]))); if (is_null($user)) { return null; @@ -95,7 +117,7 @@ public function getUserDetails(string $userName): ?array $userCn = $this->getUserResponseProperty($user, 'cn', null); $formatted = [ 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), - 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn), + 'name' => $this->getUserDisplayName($displayNameAttr, $user, $userCn), 'dn' => $user['dn'], 'email' => $this->getUserResponseProperty($user, $emailAttr, null), 'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null, diff --git a/app/Config/services.php b/app/Config/services.php index d7345823150..4e27896870a 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -127,7 +127,7 @@ 'version' => env('LDAP_VERSION', false), 'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), - 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), + 'display_name_attribute' => explode('|', env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn')), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), From 87242ce6cb462bc933e63d80e514ae5096ee2b67 Mon Sep 17 00:00:00 2001 From: Matthieu Leboeuf Date: Mon, 28 Oct 2024 22:27:15 +0100 Subject: [PATCH 190/800] Adapt tests with displayName array --- tests/Auth/LdapTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index ef95bc2e8f4..27169a2becf 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -29,7 +29,7 @@ protected function setUp(): void 'auth.defaults.guard' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'services.ldap.email_attribute' => 'mail', - 'services.ldap.display_name_attribute' => 'cn', + 'services.ldap.display_name_attribute' => ['cn'], 'services.ldap.id_attribute' => 'uid', 'services.ldap.user_to_groups' => false, 'services.ldap.version' => '3', @@ -581,7 +581,7 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() public function test_login_uses_specified_display_name_attribute() { app('config')->set([ - 'services.ldap.display_name_attribute' => 'displayName', + 'services.ldap.display_name_attribute' => ['displayName'], ]); $this->commonLdapMocks(1, 1, 2, 4, 2); @@ -606,7 +606,7 @@ public function test_login_uses_specified_display_name_attribute() public function test_login_uses_default_display_name_attribute_if_specified_not_present() { app('config')->set([ - 'services.ldap.display_name_attribute' => 'displayName', + 'services.ldap.display_name_attribute' => ['displayName'], ]); $this->commonLdapMocks(1, 1, 2, 4, 2); From 4051d5b8037119b382c576042bc668b8f00eee14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Oct 2024 12:11:51 +0000 Subject: [PATCH 191/800] ZIP Exports: Added new import permission Also updated new route/view to new non-book-specific flow. Also fixed down migration of old export permissions migration. --- app/Exports/Controllers/ImportController.php | 24 ++++++++ ...8_28_161743_add_export_role_permission.php | 7 ++- ...0_29_114420_add_import_role_permission.php | 61 +++++++++++++++++++ lang/en/entities.php | 1 + lang/en/settings.php | 1 + resources/views/books/index.blade.php | 7 +++ resources/views/exports/import.blade.php | 34 +++++++++++ .../views/settings/roles/parts/form.blade.php | 1 + routes/web.php | 4 ++ 9 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 app/Exports/Controllers/ImportController.php create mode 100644 database/migrations/2024_10_29_114420_add_import_role_permission.php create mode 100644 resources/views/exports/import.blade.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php new file mode 100644 index 00000000000..acc803a0f0d --- /dev/null +++ b/app/Exports/Controllers/ImportController.php @@ -0,0 +1,24 @@ +middleware('can:content-import'); + } + + public function start(Request $request) + { + return view('exports.import'); + } + + public function upload(Request $request) + { + // TODO + } +} diff --git a/database/migrations/2021_08_28_161743_add_export_role_permission.php b/database/migrations/2021_08_28_161743_add_export_role_permission.php index 21f45aa0691..99416f9fcf6 100644 --- a/database/migrations/2021_08_28_161743_add_export_role_permission.php +++ b/database/migrations/2021_08_28_161743_add_export_role_permission.php @@ -11,8 +11,7 @@ */ public function up(): void { - // Create new templates-manage permission and assign to admin role - $roles = DB::table('roles')->get('id'); + // Create new content-export permission $permissionId = DB::table('role_permissions')->insertGetId([ 'name' => 'content-export', 'display_name' => 'Export Content', @@ -20,6 +19,7 @@ public function up(): void 'updated_at' => Carbon::now()->toDateTimeString(), ]); + $roles = DB::table('roles')->get('id'); $permissionRoles = $roles->map(function ($role) use ($permissionId) { return [ 'role_id' => $role->id, @@ -27,6 +27,7 @@ public function up(): void ]; })->values()->toArray(); + // Assign to all existing roles in the system DB::table('permission_role')->insert($permissionRoles); } @@ -40,6 +41,6 @@ public function down(): void ->where('name', '=', 'content-export')->first(); DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete(); - DB::table('role_permissions')->where('id', '=', 'content-export')->delete(); + DB::table('role_permissions')->where('id', '=', $contentExportPermission->id)->delete(); } }; diff --git a/database/migrations/2024_10_29_114420_add_import_role_permission.php b/database/migrations/2024_10_29_114420_add_import_role_permission.php new file mode 100644 index 00000000000..17bbe4cff26 --- /dev/null +++ b/database/migrations/2024_10_29_114420_add_import_role_permission.php @@ -0,0 +1,61 @@ +insertGetId([ + 'name' => 'content-import', + 'display_name' => 'Import Content', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + // Get existing admin-level role ids + $settingManagePermission = DB::table('role_permissions') + ->where('name', '=', 'settings-manage')->first(); + + if (!$settingManagePermission) { + return; + } + + $adminRoleIds = DB::table('permission_role') + ->where('permission_id', '=', $settingManagePermission->id) + ->pluck('role_id')->all(); + + // Assign the new permission to all existing admins + $newPermissionRoles = array_values(array_map(function ($roleId) use ($permissionId) { + return [ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + ]; + }, $adminRoleIds)); + + DB::table('permission_role')->insert($newPermissionRoles); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove content-import permission + $importPermission = DB::table('role_permissions') + ->where('name', '=', 'content-import')->first(); + + if (!$importPermission) { + return; + } + + DB::table('permission_role')->where('permission_id', '=', $importPermission->id)->delete(); + DB::table('role_permissions')->where('id', '=', $importPermission->id)->delete(); + } +}; diff --git a/lang/en/entities.php b/lang/en/entities.php index 7e5a708ef62..1a61b629a72 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -43,6 +43,7 @@ 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/settings.php b/lang/en/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 0b407a8609a..418c0fea8d1 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -49,6 +49,13 @@ @icon('tag') {{ trans('entities.tags_view_tags') }} + + @if(userCan('content-import')) + + @icon('upload') + {{ trans('entities.import') }} + + @endif diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php new file mode 100644 index 00000000000..df8f705cb45 --- /dev/null +++ b/resources/views/exports/import.blade.php @@ -0,0 +1,34 @@ +@extends('layouts.simple') + +@section('body') + +
                                + +
                                +
                                +
                                +

                                {{ trans('entities.import') }}

                                +

                                + TODO - Desc +{{-- {{ trans('entities.permissions_desc') }}--}} +

                                +
                                +
                                + + {{ csrf_field() }} +
                                +
                                + @include('form.checkbox', ['name' => 'images', 'label' => 'Include Images']) + @include('form.checkbox', ['name' => 'attachments', 'label' => 'Include Attachments']) +
                                +
                                + +
                                + {{ trans('common.cancel') }} + +
                                + +
                                +
                                + +@stop diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 9fa76f2bfd7..a77b80e4c69 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -37,6 +37,7 @@
                                @include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
                                @include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
                                @include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
                                +
                                @include('settings.roles.parts.checkbox', ['permission' => 'content-import', 'label' => trans('settings.role_import_content')])
                                @include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])
                                @include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])
                                diff --git a/routes/web.php b/routes/web.php index e6f3683c643..91aab13fecf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -206,6 +206,10 @@ // Watching Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']); + // Importing + Route::get('/import', [ExportControllers\ImportController::class, 'start']); + Route::post('/import', [ExportControllers\ImportController::class, 'upload']); + // Other Pages Route::get('/', [HomeController::class, 'index']); Route::get('/home', [HomeController::class, 'index']); From a56a28fbb7eaff40a639c2d06f56de255cd654ea Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Oct 2024 14:21:32 +0000 Subject: [PATCH 192/800] ZIP Exports: Built out initial import view Added syles for non-custom, non-image file inputs. Started planning out back-end handling. --- app/Exports/Controllers/ImportController.php | 8 ++++- lang/en/entities.php | 1 + resources/sass/_forms.scss | 37 ++++++++++++++++++++ resources/views/exports/import.blade.php | 31 ++++++++-------- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index acc803a0f0d..9eefb097438 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -14,11 +14,17 @@ public function __construct() public function start(Request $request) { + // TODO - Show existing imports for user (or for all users if admin-level user) + return view('exports.import'); } public function upload(Request $request) { - // TODO + // TODO - Read existing ZIP upload and send through validator + // TODO - If invalid, return user with errors + // TODO - Upload to storage + // TODO - Store info/results from validator + // TODO - Send user to next import stage } } diff --git a/lang/en/entities.php b/lang/en/entities.php index 1a61b629a72..45ca4cf6b31 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -44,6 +44,7 @@ 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', 'import' => 'Import', + 'import_validate' => 'Validate Import', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 67df4171499..1c679aaa0dd 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -545,6 +545,43 @@ input[type=color] { outline: 1px solid var(--color-primary); } +.custom-simple-file-input { + max-width: 100%; + border: 1px solid; + @include lightDark(border-color, #DDD, #666); + border-radius: 4px; + padding: $-s $-m; +} +.custom-simple-file-input::file-selector-button { + background-color: transparent; + text-decoration: none; + font-size: 0.8rem; + line-height: 1.4em; + padding: $-xs $-s; + border: 1px solid; + font-weight: 400; + outline: 0; + border-radius: 4px; + cursor: pointer; + margin-right: $-m; + @include lightDark(color, #666, #AAA); + @include lightDark(border-color, #CCC, #666); + &: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); + } +} + input.shortcut-input { width: auto; max-width: 120px; diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index df8f705cb45..b7030f11478 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -5,27 +5,30 @@
                                -
                                -
                                -

                                {{ trans('entities.import') }}

                                -

                                - TODO - Desc -{{-- {{ trans('entities.permissions_desc') }}--}} -

                                -
                                -
                                +

                                {{ trans('entities.import') }}

                                {{ csrf_field() }} -
                                -
                                - @include('form.checkbox', ['name' => 'images', 'label' => 'Include Images']) - @include('form.checkbox', ['name' => 'attachments', 'label' => 'Include Attachments']) +
                                +

                                + Import content using a portable zip export from the same, or a different, instance. + Select a ZIP file to import then press "Validate Import" to proceed. + After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view. +

                                +
                                +
                                + + +
                                {{ trans('common.cancel') }} - +
                                From 4b60c03caa5ff990bf935b4b6ede7d8d32f0e8c5 Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 29 Oct 2024 23:06:50 +0800 Subject: [PATCH 193/800] re-write Dockerfile --- dev/docker/Dockerfile | 52 +++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 50d528fafb8..5040298e052 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -1,34 +1,34 @@ FROM php:8.3-apache -ENV APACHE_DOCUMENT_ROOT /app/public -WORKDIR /app - -RUN < Date: Tue, 29 Oct 2024 23:07:15 +0800 Subject: [PATCH 194/800] fix deprecated syntax --- dev/docker/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 5040298e052..9f283c75544 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -30,5 +30,6 @@ RUN a2enmod rewrite && \ RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" && \ sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini" -ENV APACHE_DOCUMENT_ROOT /app/public +ENV APACHE_DOCUMENT_ROOT="/app/public" + WORKDIR /app From b50b7b667d2266950baa56457f2ed8b7eeda273d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 30 Oct 2024 13:13:41 +0000 Subject: [PATCH 195/800] ZIP Exports: Started import validation --- .../ZipExportValidationException.php | 12 ++++ app/Exports/Controllers/ImportController.php | 6 ++ .../ZipExports/Models/ZipExportAttachment.php | 14 +++++ .../ZipExports/Models/ZipExportModel.php | 9 +++ .../ZipExports/Models/ZipExportTag.php | 12 ++++ app/Exports/ZipExports/ZipExportValidator.php | 63 +++++++++++++++++++ .../ZipExports/ZipFileReferenceRule.php | 26 ++++++++ .../ZipExports/ZipValidationHelper.php | 32 ++++++++++ lang/en/validation.php | 2 + resources/views/exports/import.blade.php | 2 +- 10 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/ZipExportValidationException.php create mode 100644 app/Exports/ZipExports/ZipExportValidator.php create mode 100644 app/Exports/ZipExports/ZipFileReferenceRule.php create mode 100644 app/Exports/ZipExports/ZipValidationHelper.php diff --git a/app/Exceptions/ZipExportValidationException.php b/app/Exceptions/ZipExportValidationException.php new file mode 100644 index 00000000000..2ed567d6343 --- /dev/null +++ b/app/Exceptions/ZipExportValidationException.php @@ -0,0 +1,12 @@ +validate($request, [ + 'file' => ['required', 'file'] + ]); + + $file = $request->file('file'); + $file->getRealPath(); // TODO - Read existing ZIP upload and send through validator // TODO - If invalid, return user with errors // TODO - Upload to storage diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 283ffa751c9..ab1f5ab7559 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Attachment; class ZipExportAttachment extends ZipExportModel @@ -35,4 +36,17 @@ public static function fromModelArray(array $attachmentArray, ZipExportFiles $fi return self::fromModel($attachment, $files); }, $attachmentArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'order' => ['nullable', 'integer'], + 'link' => ['required_without:file', 'nullable', 'string'], + 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], + ]; + + return $context->validateArray($data, $rules); + } } diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php index 8d0c0a4370b..4d66f010f86 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -2,6 +2,7 @@ namespace BookStack\Exports\ZipExports\Models; +use BookStack\Exports\ZipExports\ZipValidationHelper; use JsonSerializable; abstract class ZipExportModel implements JsonSerializable @@ -17,4 +18,12 @@ public function jsonSerialize(): array $publicProps = get_object_vars(...)->__invoke($this); return array_filter($publicProps, fn ($value) => $value !== null); } + + /** + * Validate the given array of data intended for this model. + * Return an array of validation errors messages. + * Child items can be considered in the validation result by returning a keyed + * item in the array for its own validation messages. + */ + abstract public static function validate(ZipValidationHelper $context, array $data): array; } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index d4e3c4290b3..ad17d5a33c6 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Activity\Models\Tag; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportTag extends ZipExportModel { @@ -24,4 +25,15 @@ public static function fromModelArray(array $tagArray): array { return array_values(array_map(self::fromModel(...), $tagArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'name' => ['required', 'string', 'min:1'], + 'value' => ['nullable', 'string'], + 'order' => ['nullable', 'integer'], + ]; + + return $context->validateArray($data, $rules); + } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php new file mode 100644 index 00000000000..5ad9272de43 --- /dev/null +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -0,0 +1,63 @@ +zipPath) || !is_readable($this->zipPath)) { + $this->throwErrors("Could not read ZIP file"); + } + + // Validate file is valid zip + $zip = new \ZipArchive(); + $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); + if ($opened !== true) { + $this->throwErrors("Could not read ZIP file"); + } + + // Validate json data exists, including metadata + $jsonData = $zip->getFromName('data.json') ?: ''; + $importData = json_decode($jsonData, true); + if (!$importData) { + $this->throwErrors("Could not decode ZIP data.json content"); + } + + if (isset($importData['book'])) { + // TODO - Validate book + } else if (isset($importData['chapter'])) { + // TODO - Validate chapter + } else if (isset($importData['page'])) { + // TODO - Validate page + } else { + $this->throwErrors("ZIP file has no book, chapter or page data"); + } + } + + /** + * @throws ZipExportValidationException + */ + protected function throwErrors(...$errorsToAdd): never + { + array_push($this->errors, ...$errorsToAdd); + throw new ZipExportValidationException($this->errors); + } +} diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php new file mode 100644 index 00000000000..4f942e0e789 --- /dev/null +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -0,0 +1,26 @@ +context->zipFileExists($value)) { + $fail('validation.zip_file')->translate(); + } + } +} diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php new file mode 100644 index 00000000000..dd41e6f8b25 --- /dev/null +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -0,0 +1,32 @@ +validationFactory = app(Factory::class); + } + + public function validateArray(array $data, array $rules): array + { + return $this->validationFactory->make($data, $rules)->errors()->messages(); + } + + public function zipFileExists(string $name): bool + { + return $this->zip->statName("files/{$name}") !== false; + } + + public function fileReferenceRule(): ZipFileReferenceRule + { + return new ZipFileReferenceRule($this); + } +} diff --git a/lang/en/validation.php b/lang/en/validation.php index 2a676c7c4cc..6971edc023a 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,6 +105,8 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index b7030f11478..9fe596d8888 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -10,7 +10,7 @@ {{ csrf_field() }}

                                - Import content using a portable zip export from the same, or a different, instance. + Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view.

                                From c4ec50d437e52ccd831b6fb2e43baa5cf255fd1a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 30 Oct 2024 15:26:23 +0000 Subject: [PATCH 196/800] ZIP Exports: Got zip format validation functionally complete --- .../ZipExportValidationException.php | 12 ----- app/Exports/Controllers/ImportController.php | 9 +++- .../ZipExports/Models/ZipExportAttachment.php | 2 +- .../ZipExports/Models/ZipExportBook.php | 21 ++++++++ .../ZipExports/Models/ZipExportChapter.php | 19 +++++++ .../ZipExports/Models/ZipExportImage.php | 14 +++++ .../ZipExports/Models/ZipExportPage.php | 22 ++++++++ .../ZipExports/Models/ZipExportTag.php | 2 +- app/Exports/ZipExports/ZipExportValidator.php | 53 +++++++++++-------- .../ZipExports/ZipValidationHelper.php | 31 ++++++++++- lang/en/validation.php | 3 +- resources/views/exports/import.blade.php | 3 +- 12 files changed, 149 insertions(+), 42 deletions(-) delete mode 100644 app/Exceptions/ZipExportValidationException.php diff --git a/app/Exceptions/ZipExportValidationException.php b/app/Exceptions/ZipExportValidationException.php deleted file mode 100644 index 2ed567d6343..00000000000 --- a/app/Exceptions/ZipExportValidationException.php +++ /dev/null @@ -1,12 +0,0 @@ -file('file'); - $file->getRealPath(); + $zipPath = $file->getRealPath(); + + $errors = (new ZipExportValidator($zipPath))->validate(); + if ($errors) { + dd($errors); + } + dd('passed'); // TODO - Read existing ZIP upload and send through validator // TODO - If invalid, return user with errors // TODO - Upload to storage diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index ab1f5ab7559..e586b91b0ee 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -47,6 +47,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; - return $context->validateArray($data, $rules); + return $context->validateData($data, $rules); } } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 5a0c5806ba8..7e1f2d8106e 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportBook extends ZipExportModel { @@ -50,4 +51,24 @@ public static function fromModel(Book $model, ZipExportFiles $files): self return $instance; } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'description_html' => ['nullable', 'string'], + 'cover' => ['nullable', 'string', $context->fileReferenceRule()], + 'tags' => ['array'], + 'pages' => ['array'], + 'chapters' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class); + $errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class); + + return $errors; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index cd5765f48bc..03df31b7078 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportChapter extends ZipExportModel { @@ -42,4 +43,22 @@ public static function fromModelArray(array $chapterArray, ZipExportFiles $files return self::fromModel($chapter, $files); }, $chapterArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'description_html' => ['nullable', 'string'], + 'priority' => ['nullable', 'int'], + 'tags' => ['array'], + 'pages' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class); + + return $errors; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 05d828734a0..3388c66df36 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -3,7 +3,9 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Image; +use Illuminate\Validation\Rule; class ZipExportImage extends ZipExportModel { @@ -22,4 +24,16 @@ public static function fromModel(Image $model, ZipExportFiles $files): self return $instance; } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'file' => ['required', 'string', $context->fileReferenceRule()], + 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], + ]; + + return $context->validateData($data, $rules); + } } diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 8075595f228..2c8b9a88abd 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\PageContent; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportPage extends ZipExportModel { @@ -48,4 +49,25 @@ public static function fromModelArray(array $pageArray, ZipExportFiles $files): return self::fromModel($page, $files); }, $pageArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'html' => ['nullable', 'string'], + 'markdown' => ['nullable', 'string'], + 'priority' => ['nullable', 'int'], + 'attachments' => ['array'], + 'images' => ['array'], + 'tags' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class); + $errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + + return $errors; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index ad17d5a33c6..99abb811c06 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -34,6 +34,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra 'order' => ['nullable', 'integer'], ]; - return $context->validateArray($data, $rules); + return $context->validateData($data, $rules); } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index 5ad9272de43..e56394acaeb 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -2,62 +2,69 @@ namespace BookStack\Exports\ZipExports; -use BookStack\Exceptions\ZipExportValidationException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; class ZipExportValidator { - protected array $errors = []; - public function __construct( protected string $zipPath, ) { } - /** - * @throws ZipExportValidationException - */ - public function validate() + public function validate(): array { - // TODO - Return type - // TODO - extract messages to translations? - // Validate file exists if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - $this->throwErrors("Could not read ZIP file"); + return ['format' => "Could not read ZIP file"]; } // Validate file is valid zip $zip = new \ZipArchive(); $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); if ($opened !== true) { - $this->throwErrors("Could not read ZIP file"); + return ['format' => "Could not read ZIP file"]; } // Validate json data exists, including metadata $jsonData = $zip->getFromName('data.json') ?: ''; $importData = json_decode($jsonData, true); if (!$importData) { - $this->throwErrors("Could not decode ZIP data.json content"); + return ['format' => "Could not find and decode ZIP data.json content"]; } + $helper = new ZipValidationHelper($zip); + if (isset($importData['book'])) { - // TODO - Validate book + $modelErrors = ZipExportBook::validate($helper, $importData['book']); + $keyPrefix = 'book'; } else if (isset($importData['chapter'])) { - // TODO - Validate chapter + $modelErrors = ZipExportChapter::validate($helper, $importData['chapter']); + $keyPrefix = 'chapter'; } else if (isset($importData['page'])) { - // TODO - Validate page + $modelErrors = ZipExportPage::validate($helper, $importData['page']); + $keyPrefix = 'page'; } else { - $this->throwErrors("ZIP file has no book, chapter or page data"); + return ['format' => "ZIP file has no book, chapter or page data"]; } + + return $this->flattenModelErrors($modelErrors, $keyPrefix); } - /** - * @throws ZipExportValidationException - */ - protected function throwErrors(...$errorsToAdd): never + protected function flattenModelErrors(array $errors, string $keyPrefix): array { - array_push($this->errors, ...$errorsToAdd); - throw new ZipExportValidationException($this->errors); + $flattened = []; + + foreach ($errors as $key => $error) { + if (is_array($error)) { + $flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key)); + } else { + $flattened[$keyPrefix . '.' . $key] = $error; + } + } + + return $flattened; } } diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index dd41e6f8b25..8c285deaf5d 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -2,6 +2,7 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Exports\ZipExports\Models\ZipExportModel; use Illuminate\Validation\Factory; use ZipArchive; @@ -15,9 +16,15 @@ public function __construct( $this->validationFactory = app(Factory::class); } - public function validateArray(array $data, array $rules): array + public function validateData(array $data, array $rules): array { - return $this->validationFactory->make($data, $rules)->errors()->messages(); + $messages = $this->validationFactory->make($data, $rules)->errors()->messages(); + + foreach ($messages as $key => $message) { + $messages[$key] = implode("\n", $message); + } + + return $messages; } public function zipFileExists(string $name): bool @@ -29,4 +36,24 @@ public function fileReferenceRule(): ZipFileReferenceRule { return new ZipFileReferenceRule($this); } + + /** + * Validate an array of relation data arrays that are expected + * to be for the given ZipExportModel. + * @param class-string $model + */ + public function validateRelations(array $relations, string $model): array + { + $results = []; + + foreach ($relations as $key => $relationData) { + if (is_array($relationData)) { + $results[$key] = $model::validate($this, $relationData); + } else { + $results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])]; + } + } + + return $results; + } } diff --git a/lang/en/validation.php b/lang/en/validation.php index 6971edc023a..9cf5d78b6f6 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,7 +105,8 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_model_expected' => 'Data object expected but ":type" found', // Custom validation lines 'custom' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index 9fe596d8888..15f33e6b7c1 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -6,7 +6,7 @@

                                {{ trans('entities.import') }}

                                -
                                + {{ csrf_field() }}

                                @@ -22,6 +22,7 @@ name="file" id="file" class="custom-simple-file-input"> + @include('form.errors', ['name' => 'file'])

                                From 259aa829d42b1cd93011d5b8b531c15804741cb5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 14:51:04 +0000 Subject: [PATCH 197/800] ZIP Imports: Added validation message display, added testing Testing covers main UI access, and main non-successfull import actions. Started planning stored import model. Extracted some text to language files. --- app/Exports/Controllers/ImportController.php | 20 ++- app/Exports/ZipExports/ZipExportValidator.php | 9 +- lang/en/entities.php | 3 + lang/en/errors.php | 5 + lang/en/validation.php | 2 +- resources/views/exports/import.blade.php | 17 ++- tests/Exports/ZipImportTest.php | 124 ++++++++++++++++++ 7 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 tests/Exports/ZipImportTest.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 5885f7991cd..323ecef268f 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -17,7 +17,9 @@ public function start(Request $request) { // TODO - Show existing imports for user (or for all users if admin-level user) - return view('exports.import'); + return view('exports.import', [ + 'zipErrors' => session()->pull('validation_errors') ?? [], + ]); } public function upload(Request $request) @@ -31,13 +33,21 @@ public function upload(Request $request) $errors = (new ZipExportValidator($zipPath))->validate(); if ($errors) { - dd($errors); + session()->flash('validation_errors', $errors); + return redirect('/import'); } + dd('passed'); - // TODO - Read existing ZIP upload and send through validator - // TODO - If invalid, return user with errors // TODO - Upload to storage - // TODO - Store info/results from validator + // TODO - Store info/results for display: + // - zip_path + // - name (From name of thing being imported) + // - size + // - book_count + // - chapter_count + // - page_count + // - created_by + // - created_at/updated_at // TODO - Send user to next import stage } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index e56394acaeb..dd56f3e70a8 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -18,21 +18,21 @@ public function validate(): array { // Validate file exists if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - return ['format' => "Could not read ZIP file"]; + return ['format' => trans('errors.import_zip_cant_read')]; } // Validate file is valid zip $zip = new \ZipArchive(); $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); if ($opened !== true) { - return ['format' => "Could not read ZIP file"]; + return ['format' => trans('errors.import_zip_cant_read')]; } // Validate json data exists, including metadata $jsonData = $zip->getFromName('data.json') ?: ''; $importData = json_decode($jsonData, true); if (!$importData) { - return ['format' => "Could not find and decode ZIP data.json content"]; + return ['format' => trans('errors.import_zip_cant_decode_data')]; } $helper = new ZipValidationHelper($zip); @@ -47,9 +47,10 @@ public function validate(): array $modelErrors = ZipExportPage::validate($helper, $importData['page']); $keyPrefix = 'page'; } else { - return ['format' => "ZIP file has no book, chapter or page data"]; + return ['format' => trans('errors.import_zip_no_data')]; } + return $this->flattenModelErrors($modelErrors, $keyPrefix); } diff --git a/lang/en/entities.php b/lang/en/entities.php index 45ca4cf6b31..10614733533 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -45,6 +45,9 @@ 'default_template_select' => 'Select a template page', 'import' => 'Import', 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/errors.php b/lang/en/errors.php index 9c40aa9ed33..3f2f303311e 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -105,6 +105,11 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/en/validation.php b/lang/en/validation.php index 9cf5d78b6f6..bc01ac47b94 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,7 +106,7 @@ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_model_expected' => 'Data object expected but ":type" found', + 'zip_model_expected' => 'Data object expected but ":type" found.', // Custom validation lines 'custom' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index 15f33e6b7c1..c4d7c881845 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -9,14 +9,10 @@ {{ csrf_field() }}
                                -

                                - Import books, chapters & pages using a portable zip export from the same, or a different, instance. - Select a ZIP file to import then press "Validate Import" to proceed. - After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view. -

                                +

                                {{ trans('entities.import_desc') }}

                                - +
                                + @if(count($zipErrors) > 0) +

                                {{ trans('entities.import_zip_validation_errors') }}

                                +
                                  + @foreach($zipErrors as $key => $error) +
                                • [{{ $key }}]: {{ $error }}
                                • + @endforeach +
                                + @endif +
                                {{ trans('common.cancel') }} diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php new file mode 100644 index 00000000000..c9d255b1e97 --- /dev/null +++ b/tests/Exports/ZipImportTest.php @@ -0,0 +1,124 @@ +asAdmin()->get('/import'); + $resp->assertSee('Import'); + $this->withHtml($resp)->assertElementExists('form input[type="file"][name="file"]'); + } + + public function test_permissions_needed_for_import_page() + { + $user = $this->users->viewer(); + $this->actingAs($user); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertLinkNotExists(url('/import')); + $resp = $this->get('/import'); + $resp->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertLinkExists(url('/import')); + $resp = $this->get('/import'); + $resp->assertOk(); + $resp->assertSeeText('Select ZIP file to upload'); + } + + public function test_zip_read_errors_are_shown_on_validation() + { + $invalidUpload = $this->files->uploadedImage('image.zip'); + + $this->asAdmin(); + $resp = $this->runImportFromFile($invalidUpload); + $resp->assertRedirect('/import'); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('Could not read ZIP file'); + } + + public function test_error_shown_if_missing_data() + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('beans', 'cat'); + $zip->close(); + + $this->asAdmin(); + $upload = new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + $resp = $this->runImportFromFile($upload); + $resp->assertRedirect('/import'); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('Could not find and decode ZIP data.json content.'); + } + + public function test_error_shown_if_no_importable_key() + { + $this->asAdmin(); + $resp = $this->runImportFromFile($this->zipUploadFromData([ + 'instance' => [] + ])); + + $resp->assertRedirect('/import'); + $resp = $this->followRedirects($resp); + $resp->assertSeeText('ZIP file data has no expected book, chapter or page content.'); + } + + public function test_zip_data_validation_messages_shown() + { + $this->asAdmin(); + $resp = $this->runImportFromFile($this->zipUploadFromData([ + 'book' => [ + 'id' => 4, + 'pages' => [ + 'cat', + [ + 'name' => 'My inner page', + 'tags' => [ + [ + 'value' => 5 + ] + ], + ] + ], + ] + ])); + + $resp->assertRedirect('/import'); + $resp = $this->followRedirects($resp); + + $resp->assertSeeText('[book.name]: The name field is required.'); + $resp->assertSeeText('[book.pages.0.0]: Data object expected but "string" found.'); + $resp->assertSeeText('[book.pages.1.tags.0.name]: The name field is required.'); + $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.'); + } + + protected function runImportFromFile(UploadedFile $file): TestResponse + { + return $this->call('POST', '/import', [], [], ['file' => $file]); + } + + protected function zipUploadFromData(array $data): UploadedFile + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('data.json', json_encode($data)); + $zip->close(); + + return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + } +} From 74fce9640ef39a743bdb5a997724465c7c2b764c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 17:17:34 +0000 Subject: [PATCH 198/800] ZIP Import: Added model+migration, and reader class --- app/Exports/Controllers/ImportController.php | 24 +++-- app/Exports/Import.php | 41 +++++++ app/Exports/ZipExports/ZipExportReader.php | 102 ++++++++++++++++++ app/Exports/ZipExports/ZipExportValidator.php | 26 ++--- .../ZipExports/ZipFileReferenceRule.php | 2 +- .../ZipExports/ZipValidationHelper.php | 8 +- database/factories/Exports/ImportFactory.php | 32 ++++++ ...2024_11_02_160700_create_imports_table.php | 34 ++++++ 8 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 app/Exports/Import.php create mode 100644 app/Exports/ZipExports/ZipExportReader.php create mode 100644 database/factories/Exports/ImportFactory.php create mode 100644 database/migrations/2024_11_02_160700_create_imports_table.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 323ecef268f..bbf0ff57d8c 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -2,6 +2,8 @@ namespace BookStack\Exports\Controllers; +use BookStack\Exports\Import; +use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Http\Controller; use Illuminate\Http\Request; @@ -37,17 +39,23 @@ public function upload(Request $request) return redirect('/import'); } + $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $import = new Import(); + $import->name = $zipEntityInfo['name']; + $import->book_count = $zipEntityInfo['book_count']; + $import->chapter_count = $zipEntityInfo['chapter_count']; + $import->page_count = $zipEntityInfo['page_count']; + $import->created_by = user()->id; + $import->size = filesize($zipPath); + // TODO - Set path + // TODO - Save + + // TODO - Split out attachment service to separate out core filesystem/disk stuff + // To reuse for import handling + dd('passed'); // TODO - Upload to storage // TODO - Store info/results for display: - // - zip_path - // - name (From name of thing being imported) - // - size - // - book_count - // - chapter_count - // - page_count - // - created_by - // - created_at/updated_at // TODO - Send user to next import stage } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php new file mode 100644 index 00000000000..c3ac3d52924 --- /dev/null +++ b/app/Exports/Import.php @@ -0,0 +1,41 @@ +book_count === 1) { + return self::TYPE_BOOK; + } elseif ($this->chapter_count === 1) { + return self::TYPE_CHAPTER; + } + + return self::TYPE_PAGE; + } +} diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php new file mode 100644 index 00000000000..7187a18897d --- /dev/null +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -0,0 +1,102 @@ +zip = new ZipArchive(); + } + + /** + * @throws ZipExportException + */ + protected function open(): void + { + if ($this->open) { + return; + } + + // Validate file exists + if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { + throw new ZipExportException(trans('errors.import_zip_cant_read')); + } + + // Validate file is valid zip + $opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY); + if ($opened !== true) { + throw new ZipExportException(trans('errors.import_zip_cant_read')); + } + + $this->open = true; + } + + public function close(): void + { + if ($this->open) { + $this->zip->close(); + $this->open = false; + } + } + + /** + * @throws ZipExportException + */ + public function readData(): array + { + $this->open(); + + // Validate json data exists, including metadata + $jsonData = $this->zip->getFromName('data.json') ?: ''; + $importData = json_decode($jsonData, true); + if (!$importData) { + throw new ZipExportException(trans('errors.import_zip_cant_decode_data')); + } + + return $importData; + } + + public function fileExists(string $fileName): bool + { + return $this->zip->statName("files/{$fileName}") !== false; + } + + /** + * @throws ZipExportException + * @returns array{name: string, book_count: int, chapter_count: int, page_count: int} + */ + public function getEntityInfo(): array + { + $data = $this->readData(); + $info = ['name' => '', 'book_count' => 0, 'chapter_count' => 0, 'page_count' => 0]; + + if (isset($data['book'])) { + $info['name'] = $data['book']['name'] ?? ''; + $info['book_count']++; + $chapters = $data['book']['chapters'] ?? []; + $pages = $data['book']['pages'] ?? []; + $info['chapter_count'] += count($chapters); + $info['page_count'] += count($pages); + foreach ($chapters as $chapter) { + $info['page_count'] += count($chapter['pages'] ?? []); + } + } elseif (isset($data['chapter'])) { + $info['name'] = $data['chapter']['name'] ?? ''; + $info['chapter_count']++; + $info['page_count'] += count($data['chapter']['pages'] ?? []); + } elseif (isset($data['page'])) { + $info['name'] = $data['page']['name'] ?? ''; + $info['page_count']++; + } + + return $info; + } +} diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index dd56f3e70a8..e476998c216 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -2,10 +2,10 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Exceptions\ZipExportException; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportPage; -use ZipArchive; class ZipExportValidator { @@ -16,26 +16,14 @@ public function __construct( public function validate(): array { - // Validate file exists - if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - return ['format' => trans('errors.import_zip_cant_read')]; + $reader = new ZipExportReader($this->zipPath); + try { + $importData = $reader->readData(); + } catch (ZipExportException $exception) { + return ['format' => $exception->getMessage()]; } - // Validate file is valid zip - $zip = new \ZipArchive(); - $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); - if ($opened !== true) { - return ['format' => trans('errors.import_zip_cant_read')]; - } - - // Validate json data exists, including metadata - $jsonData = $zip->getFromName('data.json') ?: ''; - $importData = json_decode($jsonData, true); - if (!$importData) { - return ['format' => trans('errors.import_zip_cant_decode_data')]; - } - - $helper = new ZipValidationHelper($zip); + $helper = new ZipValidationHelper($reader); if (isset($importData['book'])) { $modelErrors = ZipExportBook::validate($helper, $importData['book']); diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index 4f942e0e789..bcd3c39acf0 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -19,7 +19,7 @@ public function __construct( */ public function validate(string $attribute, mixed $value, Closure $fail): void { - if (!$this->context->zipFileExists($value)) { + if (!$this->context->zipReader->fileExists($value)) { $fail('validation.zip_file')->translate(); } } diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index 8c285deaf5d..55c86b03b5b 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -4,14 +4,13 @@ use BookStack\Exports\ZipExports\Models\ZipExportModel; use Illuminate\Validation\Factory; -use ZipArchive; class ZipValidationHelper { protected Factory $validationFactory; public function __construct( - protected ZipArchive $zip, + public ZipExportReader $zipReader, ) { $this->validationFactory = app(Factory::class); } @@ -27,11 +26,6 @@ public function validateData(array $data, array $rules): array return $messages; } - public function zipFileExists(string $name): bool - { - return $this->zip->statName("files/{$name}") !== false; - } - public function fileReferenceRule(): ZipFileReferenceRule { return new ZipFileReferenceRule($this); diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php new file mode 100644 index 00000000000..55378d5832e --- /dev/null +++ b/database/factories/Exports/ImportFactory.php @@ -0,0 +1,32 @@ + 'uploads/imports/' . Str::random(10) . '.zip', + 'name' => $this->faker->words(3, true), + 'book_count' => 1, + 'chapter_count' => 5, + 'page_count' => 15, + 'created_at' => User::factory(), + ]; + } +} diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php new file mode 100644 index 00000000000..ed188226981 --- /dev/null +++ b/database/migrations/2024_11_02_160700_create_imports_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->string('name'); + $table->string('path'); + $table->integer('size'); + $table->integer('book_count'); + $table->integer('chapter_count'); + $table->integer('page_count'); + $table->integer('created_by'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('imports'); + } +}; From 8ea3855e02aa5ff7782dc65e1eee8b8b24f28ce6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 20:48:21 +0000 Subject: [PATCH 199/800] ZIP Import: Added upload handling Split attachment service storage work out so it can be shared. --- app/Exceptions/ZipValidationException.php | 12 ++ app/Exports/Controllers/ImportController.php | 41 ++----- app/Exports/ImportRepo.php | 48 ++++++++ app/Uploads/AttachmentService.php | 86 ++------------ app/Uploads/FileStorage.php | 111 +++++++++++++++++++ 5 files changed, 195 insertions(+), 103 deletions(-) create mode 100644 app/Exceptions/ZipValidationException.php create mode 100644 app/Exports/ImportRepo.php create mode 100644 app/Uploads/FileStorage.php diff --git a/app/Exceptions/ZipValidationException.php b/app/Exceptions/ZipValidationException.php new file mode 100644 index 00000000000..aaaee792ef0 --- /dev/null +++ b/app/Exceptions/ZipValidationException.php @@ -0,0 +1,12 @@ +middleware('can:content-import'); } @@ -27,35 +28,17 @@ public function start(Request $request) public function upload(Request $request) { $this->validate($request, [ - 'file' => ['required', 'file'] + 'file' => ['required', ...AttachmentService::getFileValidationRules()] ]); $file = $request->file('file'); - $zipPath = $file->getRealPath(); - - $errors = (new ZipExportValidator($zipPath))->validate(); - if ($errors) { - session()->flash('validation_errors', $errors); + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + session()->flash('validation_errors', $exception->errors); return redirect('/import'); } - $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); - $import = new Import(); - $import->name = $zipEntityInfo['name']; - $import->book_count = $zipEntityInfo['book_count']; - $import->chapter_count = $zipEntityInfo['chapter_count']; - $import->page_count = $zipEntityInfo['page_count']; - $import->created_by = user()->id; - $import->size = filesize($zipPath); - // TODO - Set path - // TODO - Save - - // TODO - Split out attachment service to separate out core filesystem/disk stuff - // To reuse for import handling - - dd('passed'); - // TODO - Upload to storage - // TODO - Store info/results for display: - // TODO - Send user to next import stage + return redirect("imports/{$import->id}"); } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php new file mode 100644 index 00000000000..c8157967bc3 --- /dev/null +++ b/app/Exports/ImportRepo.php @@ -0,0 +1,48 @@ +getRealPath(); + + $errors = (new ZipExportValidator($zipPath))->validate(); + if ($errors) { + throw new ZipValidationException($errors); + } + + $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $import = new Import(); + $import->name = $zipEntityInfo['name']; + $import->book_count = $zipEntityInfo['book_count']; + $import->chapter_count = $zipEntityInfo['chapter_count']; + $import->page_count = $zipEntityInfo['page_count']; + $import->created_by = user()->id; + $import->size = filesize($zipPath); + + $path = $this->storage->uploadFile( + $file, + 'uploads/files/imports/', + '', + 'zip' + ); + + $import->path = $path; + $import->save(); + + return $import; + } +} diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 227649d8f00..fa53c4ae499 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -4,59 +4,15 @@ use BookStack\Exceptions\FileUploadException; use Exception; -use Illuminate\Contracts\Filesystem\Filesystem as Storage; -use Illuminate\Filesystem\FilesystemManager; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class AttachmentService { public function __construct( - protected FilesystemManager $fileSystem + protected FileStorage $storage, ) { } - /** - * Get the storage that will be used for storing files. - */ - protected function getStorageDisk(): Storage - { - return $this->fileSystem->disk($this->getStorageDiskName()); - } - - /** - * Get the name of the storage disk to use. - */ - protected function getStorageDiskName(): string - { - $storageType = config('filesystems.attachments'); - - // Change to our secure-attachment disk if any of the local options - // are used to prevent escaping that location. - if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { - $storageType = 'local_secure_attachments'; - } - - return $storageType; - } - - /** - * Change the originally provided path to fit any disk-specific requirements. - * This also ensures the path is kept to the expected root folders. - */ - protected function adjustPathForStorageDisk(string $path): string - { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); - - if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; - } - - return 'uploads/files/' . $path; - } - /** * Stream an attachment from storage. * @@ -64,7 +20,7 @@ protected function adjustPathForStorageDisk(string $path): string */ public function streamAttachmentFromStorage(Attachment $attachment) { - return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getReadStream($attachment->path); } /** @@ -72,7 +28,7 @@ public function streamAttachmentFromStorage(Attachment $attachment) */ public function getAttachmentFileSize(Attachment $attachment): int { - return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getSize($attachment->path); } /** @@ -195,15 +151,9 @@ public function deleteFile(Attachment $attachment) * Delete a file from the filesystem it sits on. * Cleans any empty leftover folders. */ - protected function deleteFileInStorage(Attachment $attachment) + protected function deleteFileInStorage(Attachment $attachment): void { - $storage = $this->getStorageDisk(); - $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path)); - - $storage->delete($this->adjustPathForStorageDisk($attachment->path)); - if (count($storage->allFiles($dirPath)) === 0) { - $storage->deleteDirectory($dirPath); - } + $this->storage->delete($attachment->path); } /** @@ -213,32 +163,20 @@ protected function deleteFileInStorage(Attachment $attachment) */ protected function putFileInStorage(UploadedFile $uploadedFile): string { - $storage = $this->getStorageDisk(); $basePath = 'uploads/files/' . date('Y-m-M') . '/'; - $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension(); - while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { - $uploadFileName = Str::random(3) . $uploadFileName; - } - - $attachmentStream = fopen($uploadedFile->getRealPath(), 'r'); - $attachmentPath = $basePath . $uploadFileName; - - try { - $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream); - } catch (Exception $e) { - Log::error('Error when attempting file upload:' . $e->getMessage()); - - throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath])); - } - - return $attachmentPath; + return $this->storage->uploadFile( + $uploadedFile, + $basePath, + $uploadedFile->getClientOriginalExtension(), + '' + ); } /** * Get the file validation rules for attachments. */ - public function getFileValidationRules(): array + public static function getFileValidationRules(): array { return ['file', 'max:' . (config('app.upload_limit') * 1000)]; } diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php new file mode 100644 index 00000000000..278484e519d --- /dev/null +++ b/app/Uploads/FileStorage.php @@ -0,0 +1,111 @@ +getStorageDisk()->readStream($this->adjustPathForStorageDisk($path)); + } + + public function getSize(string $path): int + { + return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path)); + } + + public function delete(string $path, bool $removeEmptyDir = false): void + { + $storage = $this->getStorageDisk(); + $adjustedPath = $this->adjustPathForStorageDisk($path); + $dir = dirname($adjustedPath); + + $storage->delete($adjustedPath); + if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) { + $storage->deleteDirectory($dir); + } + } + + /** + * @throws FileUploadException + */ + public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string + { + $storage = $this->getStorageDisk(); + $basePath = trim($subDirectory, '/') . '/'; + + $uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : ''); + while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { + $uploadFileName = Str::random(3) . $uploadFileName; + } + + $fileStream = fopen($file->getRealPath(), 'r'); + $filePath = $basePath . $uploadFileName; + + try { + $storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream); + } catch (Exception $e) { + Log::error('Error when attempting file upload:' . $e->getMessage()); + + throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath])); + } + + return $filePath; + } + + /** + * Get the storage that will be used for storing files. + */ + protected function getStorageDisk(): Storage + { + return $this->fileSystem->disk($this->getStorageDiskName()); + } + + /** + * Get the name of the storage disk to use. + */ + protected function getStorageDiskName(): string + { + $storageType = config('filesystems.attachments'); + + // Change to our secure-attachment disk if any of the local options + // are used to prevent escaping that location. + if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { + $storageType = 'local_secure_attachments'; + } + + return $storageType; + } + + /** + * Change the originally provided path to fit any disk-specific requirements. + * This also ensures the path is kept to the expected root folders. + */ + protected function adjustPathForStorageDisk(string $path): string + { + $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + + if ($this->getStorageDiskName() === 'local_secure_attachments') { + return $path; + } + + return 'uploads/files/' . $path; + } +} From c6109c708735434fdb30333ff4c24b4a80b0b749 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Nov 2024 14:13:05 +0000 Subject: [PATCH 200/800] ZIP Imports: Added listing, show view, delete, activity --- app/Activity/ActivityType.php | 4 ++ app/Exports/Controllers/ImportController.php | 50 ++++++++++++++++++- app/Exports/Import.php | 23 ++++++++- app/Exports/ImportRepo.php | 32 ++++++++++++ app/Http/Controller.php | 4 +- lang/en/activities.php | 8 +++ lang/en/entities.php | 6 +++ resources/views/exports/import-show.blade.php | 38 ++++++++++++++ resources/views/exports/import.blade.php | 13 +++++ .../views/exports/parts/import.blade.php | 19 +++++++ routes/web.php | 2 + 11 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 resources/views/exports/import-show.blade.php create mode 100644 resources/views/exports/parts/import.blade.php diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 09b2ae73c56..5ec9b9cf0dc 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -67,6 +67,10 @@ class ActivityType const WEBHOOK_UPDATE = 'webhook_update'; const WEBHOOK_DELETE = 'webhook_delete'; + const IMPORT_CREATE = 'import_create'; + const IMPORT_RUN = 'import_run'; + const IMPORT_DELETE = 'import_delete'; + /** * Get all the possible values. */ diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 640b4c10891..582fff975d2 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -1,7 +1,10 @@ middleware('can:content-import'); } + /** + * Show the view to start a new import, and also list out the existing + * in progress imports that are visible to the user. + */ public function start(Request $request) { - // TODO - Show existing imports for user (or for all users if admin-level user) + // TODO - Test visibility access for listed items + $imports = $this->imports->getVisibleImports(); + + $this->setPageTitle(trans('entities.import')); return view('exports.import', [ + 'imports' => $imports, 'zipErrors' => session()->pull('validation_errors') ?? [], ]); } + /** + * Upload, validate and store an import file. + */ public function upload(Request $request) { $this->validate($request, [ @@ -39,6 +53,38 @@ public function upload(Request $request) return redirect('/import'); } - return redirect("imports/{$import->id}"); + $this->logActivity(ActivityType::IMPORT_CREATE, $import); + + return redirect($import->getUrl()); + } + + /** + * Show a pending import, with a form to allow progressing + * with the import process. + */ + public function show(int $id) + { + // TODO - Test visibility access + $import = $this->imports->findVisible($id); + + $this->setPageTitle(trans('entities.import_continue')); + + return view('exports.import-show', [ + 'import' => $import, + ]); + } + + /** + * Delete an active pending import from the filesystem and database. + */ + public function delete(int $id) + { + // TODO - Test visibility access + $import = $this->imports->findVisible($id); + $this->imports->deleteImport($import); + + $this->logActivity(ActivityType::IMPORT_DELETE, $import); + + return redirect('/import'); } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php index c3ac3d52924..520d8ea6cc8 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -2,6 +2,7 @@ namespace BookStack\Exports; +use BookStack\Activity\Models\Loggable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,7 +18,7 @@ * @property Carbon $created_at * @property Carbon $updated_at */ -class Import extends Model +class Import extends Model implements Loggable { use HasFactory; @@ -38,4 +39,24 @@ public function getType(): string return self::TYPE_PAGE; } + + public function getSizeString(): string + { + $mb = round($this->size / 1000000, 2); + return "{$mb} MB"; + } + + /** + * Get the URL to view/continue this import. + */ + public function getUrl(string $path = ''): string + { + $path = ltrim($path, '/'); + return url("/import/{$this->id}" . ($path ? '/' . $path : '')); + } + + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index c8157967bc3..d7e169ad166 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -6,6 +6,7 @@ use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Uploads\FileStorage; +use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpFoundation\File\UploadedFile; class ImportRepo @@ -15,6 +16,31 @@ public function __construct( ) { } + /** + * @return Collection + */ + public function getVisibleImports(): Collection + { + $query = Import::query(); + + if (!userCan('settings-manage')) { + $query->where('created_by', user()->id); + } + + return $query->get(); + } + + public function findVisible(int $id): Import + { + $query = Import::query(); + + if (!userCan('settings-manage')) { + $query->where('created_by', user()->id); + } + + return $query->findOrFail($id); + } + public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); @@ -45,4 +71,10 @@ public function storeFromUpload(UploadedFile $file): Import return $import; } + + public function deleteImport(Import $import): void + { + $this->storage->delete($import->path); + $import->delete(); + } } diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 8facf5dab3c..090cf523ad2 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -152,10 +152,8 @@ protected function showErrorNotification(string $message): void /** * Log an activity in the system. - * - * @param string|Loggable $detail */ - protected function logActivity(string $type, $detail = ''): void + protected function logActivity(string $type, string|Loggable $detail = ''): void { Activity::add($type, $detail); } diff --git a/lang/en/activities.php b/lang/en/activities.php index 092398ef0e1..7c3454d41ca 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/en/entities.php b/lang/en/entities.php index 10614733533..e2d8e47c592 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -48,6 +48,12 @@ 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', 'import_zip_select' => 'Select ZIP file to upload', 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_run' => 'Run Import', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php new file mode 100644 index 00000000000..843a052468c --- /dev/null +++ b/resources/views/exports/import-show.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.simple') + +@section('body') + +
                                + +
                                +

                                {{ trans('entities.import_continue') }}

                                + + {{ csrf_field() }} + + +
                                + {{ trans('common.cancel') }} +
                                + + +
                                + +
                                +
                                +
                                + +
                                + {{ method_field('DELETE') }} + {{ csrf_field() }} +
                                + +@stop diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index c4d7c881845..be9de4c0e91 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -38,6 +38,19 @@ class="custom-simple-file-input">
                                + +
                                +

                                {{ trans('entities.import_pending') }}

                                + @if(count($imports) === 0) +

                                {{ trans('entities.import_pending_none') }}

                                + @else +
                                + @foreach($imports as $import) + @include('exports.parts.import', ['import' => $import]) + @endforeach +
                                + @endif +
                                @stop diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php new file mode 100644 index 00000000000..5ff6600f24b --- /dev/null +++ b/resources/views/exports/parts/import.blade.php @@ -0,0 +1,19 @@ +@php + $type = $import->getType(); +@endphp +
                                + +
                                + @if($type === 'book') +
                                @icon('chapter') {{ $import->chapter_count }}
                                + @endif + @if($type === 'book' || $type === 'chapter') +
                                @icon('page') {{ $import->page_count }}
                                + @endif +
                                {{ $import->getSizeString() }}
                                +
                                @icon('time'){{ $import->created_at->diffForHumans() }}
                                +
                                +
                                \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 91aab13fecf..c490bb3b34e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -209,6 +209,8 @@ // Importing Route::get('/import', [ExportControllers\ImportController::class, 'start']); Route::post('/import', [ExportControllers\ImportController::class, 'upload']); + Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']); + Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']); // Other Pages Route::get('/', [HomeController::class, 'index']); From 8f6f81948e81b4d63251bee57da57aa5809eaad2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Nov 2024 17:28:18 +0000 Subject: [PATCH 201/800] ZIP Imports: Fleshed out continue page, Added testing --- app/Exports/Controllers/ImportController.php | 17 ++- app/Exports/Import.php | 9 ++ lang/en/entities.php | 4 + resources/views/exports/import-show.blade.php | 41 ++++- routes/web.php | 1 + tests/Exports/ZipImportTest.php | 140 ++++++++++++++++++ 6 files changed, 206 insertions(+), 6 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 582fff975d2..787fd1b27e0 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -23,9 +23,8 @@ public function __construct( * Show the view to start a new import, and also list out the existing * in progress imports that are visible to the user. */ - public function start(Request $request) + public function start() { - // TODO - Test visibility access for listed items $imports = $this->imports->getVisibleImports(); $this->setPageTitle(trans('entities.import')); @@ -64,7 +63,6 @@ public function upload(Request $request) */ public function show(int $id) { - // TODO - Test visibility access $import = $this->imports->findVisible($id); $this->setPageTitle(trans('entities.import_continue')); @@ -74,12 +72,23 @@ public function show(int $id) ]); } + public function run(int $id) + { + // TODO - Test access/visibility + + $import = $this->imports->findVisible($id); + + // TODO - Run import + // Validate again before + // TODO - Redirect to result + // TOOD - Or redirect back with errors + } + /** * Delete an active pending import from the filesystem and database. */ public function delete(int $id) { - // TODO - Test visibility access $import = $this->imports->findVisible($id); $this->imports->deleteImport($import); diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 520d8ea6cc8..8400382fd0d 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -3,11 +3,14 @@ namespace BookStack\Exports; use BookStack\Activity\Models\Loggable; +use BookStack\Users\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** + * @property int $id * @property string $path * @property string $name * @property int $size - ZIP size in bytes @@ -17,6 +20,7 @@ * @property int $created_by * @property Carbon $created_at * @property Carbon $updated_at + * @property User $createdBy */ class Import extends Model implements Loggable { @@ -59,4 +63,9 @@ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } } diff --git a/lang/en/entities.php b/lang/en/entities.php index e2d8e47c592..4f5a530049b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -51,7 +51,11 @@ 'import_pending' => 'Pending Imports', 'import_pending_none' => 'No imports have been started.', 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', 'import_run' => 'Run Import', + 'import_size' => 'Import ZIP Size:', + 'import_uploaded_at' => 'Uploaded:', + 'import_uploaded_by' => 'Uploaded by:', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index 843a052468c..ac1b8a45d06 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -6,7 +6,44 @@

                                {{ trans('entities.import_continue') }}

                                -
                                +

                                {{ trans('entities.import_continue_desc') }}

                                + +
                                + @php + $type = $import->getType(); + @endphp +
                                +
                                +

                                @icon($type) {{ $import->name }}

                                + @if($type === 'book') +

                                @icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}

                                + @endif + @if($type === 'book' || $type === 'chapter') +

                                @icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}

                                + @endif +
                                +
                                +
                                + {{ trans('entities.import_size') }} + {{ $import->getSizeString() }} +
                                +
                                + {{ trans('entities.import_uploaded_at') }} + {{ $import->created_at->diffForHumans() }} +
                                + @if($import->createdBy) +
                                + {{ trans('entities.import_uploaded_by') }} + {{ $import->createdBy->name }} +
                                + @endif +
                                +
                                +
                                + + {{ csrf_field() }}
                                @@ -23,7 +60,7 @@ class="button outline">{{ trans('common.delete') }} - +
                                diff --git a/routes/web.php b/routes/web.php index c490bb3b34e..85f83352859 100644 --- a/routes/web.php +++ b/routes/web.php @@ -210,6 +210,7 @@ Route::get('/import', [ExportControllers\ImportController::class, 'start']); Route::post('/import', [ExportControllers\ImportController::class, 'upload']); Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']); + Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']); Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']); // Other Pages diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index c9d255b1e97..b9a8598fabe 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -2,6 +2,8 @@ namespace Tests\Exports; +use BookStack\Activity\ActivityType; +use BookStack\Exports\Import; use Illuminate\Http\UploadedFile; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -35,6 +37,25 @@ public function test_permissions_needed_for_import_page() $resp->assertSeeText('Select ZIP file to upload'); } + public function test_import_page_pending_import_visibility_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->actingAs($user)->get('/import'); + $resp->assertSeeText('MySuperUserImport'); + $resp->assertDontSeeText('MySuperAdminImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAs($user)->get('/import'); + $resp->assertSeeText('MySuperUserImport'); + $resp->assertSeeText('MySuperAdminImport'); + } + public function test_zip_read_errors_are_shown_on_validation() { $invalidUpload = $this->files->uploadedImage('image.zip'); @@ -105,6 +126,125 @@ public function test_zip_data_validation_messages_shown() $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.'); } + public function test_import_upload_success() + { + $admin = $this->users->admin(); + $this->actingAs($admin); + $resp = $this->runImportFromFile($this->zipUploadFromData([ + 'book' => [ + 'name' => 'My great book name', + 'chapters' => [ + [ + 'name' => 'my chapter', + 'pages' => [ + [ + 'name' => 'my chapter page', + ] + ] + ] + ], + 'pages' => [ + [ + 'name' => 'My page', + ] + ], + ], + ])); + + $this->assertDatabaseHas('imports', [ + 'name' => 'My great book name', + 'book_count' => 1, + 'chapter_count' => 1, + 'page_count' => 2, + 'created_by' => $admin->id, + ]); + + /** @var Import $import */ + $import = Import::query()->latest()->first(); + $resp->assertRedirect("/import/{$import->id}"); + $this->assertFileExists(storage_path($import->path)); + $this->assertActivityExists(ActivityType::IMPORT_CREATE); + } + + public function test_import_show_page() + { + $import = Import::factory()->create(['name' => 'MySuperAdminImport']); + + $resp = $this->asAdmin()->get("/import/{$import->id}"); + $resp->assertOk(); + $resp->assertSee('MySuperAdminImport'); + } + + public function test_import_show_page_access_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->get("/import/{$userImport->id}")->assertRedirect('/'); + $this->get("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->get("/import/{$userImport->id}")->assertOk(); + $this->get("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->get("/import/{$userImport->id}")->assertOk(); + $this->get("/import/{$adminImport->id}")->assertOk(); + } + + public function test_import_delete() + { + $this->asAdmin(); + $this->runImportFromFile($this->zipUploadFromData([ + 'book' => [ + 'name' => 'My great book name' + ], + ])); + + /** @var Import $import */ + $import = Import::query()->latest()->first(); + $this->assertDatabaseHas('imports', [ + 'id' => $import->id, + 'name' => 'My great book name' + ]); + $this->assertFileExists(storage_path($import->path)); + + $resp = $this->delete("/import/{$import->id}"); + + $resp->assertRedirect('/import'); + $this->assertActivityExists(ActivityType::IMPORT_DELETE); + $this->assertDatabaseMissing('imports', [ + 'id' => $import->id, + ]); + $this->assertFileDoesNotExist(storage_path($import->path)); + } + + public function test_import_delete_access_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->delete("/import/{$userImport->id}")->assertRedirect('/'); + $this->delete("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->delete("/import/{$userImport->id}")->assertRedirect('/import'); + $this->delete("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->delete("/import/{$adminImport->id}")->assertRedirect('/import'); + } + protected function runImportFromFile(UploadedFile $file): TestResponse { return $this->call('POST', '/import', [], [], ['file' => $file]); From 14578c22570d7f9ac197125ece1cf86d9d07be9b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 4 Nov 2024 16:21:22 +0000 Subject: [PATCH 202/800] ZIP Imports: Added parent selector for page/chapter imports --- app/Exports/Controllers/ImportController.php | 14 ++++++++--- lang/en/entities.php | 2 ++ resources/sass/styles.scss | 18 +++++++++++++ resources/views/entities/selector.blade.php | 8 ++++++ resources/views/exports/import-show.blade.php | 25 +++++++++++++++---- resources/views/form/errors.blade.php | 3 +++ 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 787fd1b27e0..a2389c725f3 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -72,14 +72,22 @@ public function show(int $id) ]); } - public function run(int $id) + public function run(int $id, Request $request) { // TODO - Test access/visibility - $import = $this->imports->findVisible($id); + $parent = null; + + if ($import->getType() === 'page' || $import->getType() === 'chapter') { + $data = $this->validate($request, [ + 'parent' => ['required', 'string'] + ]); + $parent = $data['parent']; + } // TODO - Run import - // Validate again before + // TODO - Validate again before + // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) // TODO - Redirect to result // TOOD - Or redirect back with errors } diff --git a/lang/en/entities.php b/lang/en/entities.php index 4f5a530049b..065eb043a11 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -56,6 +56,8 @@ 'import_size' => 'Import ZIP Size:', 'import_uploaded_at' => 'Uploaded:', 'import_uploaded_by' => 'Uploaded by:', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 636367e3aeb..942265d04d8 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -138,6 +138,11 @@ $loadingSize: 10px; font-size: 16px; padding: $-s $-m; } + input[type="text"]:focus { + outline: 1px solid var(--color-primary); + border-radius: 3px 3px 0 0; + outline-offset: -1px; + } .entity-list { overflow-y: scroll; height: 400px; @@ -171,6 +176,19 @@ $loadingSize: 10px; font-size: 14px; } } + &.small { + .entity-list-item { + padding: $-xs $-m; + } + .entity-list, .loading { + height: 300px; + } + input[type="text"] { + font-size: 13px; + padding: $-xs $-m; + height: auto; + } + } } .fullscreen { diff --git a/resources/views/entities/selector.blade.php b/resources/views/entities/selector.blade.php index c1280cfb2f7..0cdf4376cc7 100644 --- a/resources/views/entities/selector.blade.php +++ b/resources/views/entities/selector.blade.php @@ -1,3 +1,11 @@ +{{-- +$name - string +$autofocus - boolean, optional +$entityTypes - string, optional +$entityPermission - string, optional +$selectorEndpoint - string, optional +$selectorSize - string, optional (compact) +--}}
                                getType(); + @endphp +
                                @@ -9,11 +13,9 @@

                                {{ trans('entities.import_continue_desc') }}

                                - @php - $type = $import->getType(); - @endphp +
                                -
                                +

                                @icon($type) {{ $import->name }}

                                @if($type === 'book')

                                @icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}

                                @@ -22,7 +24,7 @@

                                @icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}

                                @endif
                                -
                                +
                                {{ trans('entities.import_size') }} {{ $import->getSizeString() }} @@ -45,6 +47,19 @@ action="{{ $import->getUrl() }}" method="POST"> {{ csrf_field() }} + + @if($type === 'page' || $type === 'chapter') +
                                + +

                                {{ trans('entities.import_location_desc') }}

                                + @include('entities.selector', [ + 'name' => 'parent', + 'entityTypes' => $type === 'page' ? 'chapter,book' : 'book', + 'entityPermission' => "{$type}-create", + 'selectorSize' => 'compact small', + ]) + @include('form.errors', ['name' => 'parent']) + @endif
                                diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php index 03cd4be88f0..72d41ee56c7 100644 --- a/resources/views/form/errors.blade.php +++ b/resources/views/form/errors.blade.php @@ -1,3 +1,6 @@ +{{-- +$name - string +--}} @if($errors->has($name))
                                {{ $errors->first($name) }}
                                @endif \ No newline at end of file From 92cfde495e0d3141af608ea3734b612402f257dd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 Nov 2024 13:17:31 +0000 Subject: [PATCH 203/800] ZIP Imports: Added full contents view to import display Reduced import data will now be stored on the import itself, instead of storing a set of totals. --- app/Exports/Controllers/ImportController.php | 5 ++- app/Exports/Import.php | 37 ++++++++---------- app/Exports/ImportRepo.php | 35 ++++++++++++++--- .../ZipExports/Models/ZipExportAttachment.php | 18 +++++++++ .../ZipExports/Models/ZipExportBook.php | 30 ++++++++++++++ .../ZipExports/Models/ZipExportChapter.php | 26 +++++++++++++ .../ZipExports/Models/ZipExportImage.php | 17 ++++++++ .../ZipExports/Models/ZipExportModel.php | 28 +++++++++++++ .../ZipExports/Models/ZipExportPage.php | 31 +++++++++++++++ .../ZipExports/Models/ZipExportTag.php | 16 ++++++++ app/Exports/ZipExports/ZipExportReader.php | 32 ++++++--------- app/Exports/ZipExports/ZipExportValidator.php | 1 - database/factories/Exports/ImportFactory.php | 5 +-- ...2024_11_02_160700_create_imports_table.php | 7 ++-- lang/en/entities.php | 8 ++-- resources/sass/styles.scss | 5 +++ resources/views/exports/import-show.blade.php | 39 ++++++------------- .../views/exports/parts/import-item.blade.php | 26 +++++++++++++ .../views/exports/parts/import.blade.php | 11 +----- tests/Exports/ZipImportTest.php | 31 +++++++++++---- 20 files changed, 303 insertions(+), 105 deletions(-) create mode 100644 resources/views/exports/parts/import-item.blade.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index a2389c725f3..3a56ed03456 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -65,10 +65,13 @@ public function show(int $id) { $import = $this->imports->findVisible($id); +// dd($import->decodeMetadata()); + $this->setPageTitle(trans('entities.import_continue')); return view('exports.import-show', [ 'import' => $import, + 'data' => $import->decodeMetadata(), ]); } @@ -89,7 +92,7 @@ public function run(int $id, Request $request) // TODO - Validate again before // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) // TODO - Redirect to result - // TOOD - Or redirect back with errors + // TODO - Or redirect back with errors } /** diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 8400382fd0d..9c1771c468f 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -3,6 +3,9 @@ namespace BookStack\Exports; use BookStack\Activity\Models\Loggable; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Users\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,9 +17,8 @@ * @property string $path * @property string $name * @property int $size - ZIP size in bytes - * @property int $book_count - * @property int $chapter_count - * @property int $page_count + * @property string $type + * @property string $metadata * @property int $created_by * @property Carbon $created_at * @property Carbon $updated_at @@ -26,24 +28,6 @@ class Import extends Model implements Loggable { use HasFactory; - public const TYPE_BOOK = 'book'; - public const TYPE_CHAPTER = 'chapter'; - public const TYPE_PAGE = 'page'; - - /** - * Get the type (model) that this import is intended to be. - */ - public function getType(): string - { - if ($this->book_count === 1) { - return self::TYPE_BOOK; - } elseif ($this->chapter_count === 1) { - return self::TYPE_CHAPTER; - } - - return self::TYPE_PAGE; - } - public function getSizeString(): string { $mb = round($this->size / 1000000, 2); @@ -68,4 +52,15 @@ public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); } + + public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null + { + $metadataArray = json_decode($this->metadata, true); + return match ($this->type) { + 'book' => ZipExportBook::fromArray($metadataArray), + 'chapter' => ZipExportChapter::fromArray($metadataArray), + 'page' => ZipExportPage::fromArray($metadataArray), + default => null, + }; + } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index d7e169ad166..3265e1c80dc 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,7 +2,12 @@ namespace BookStack\Exports; +use BookStack\Exceptions\FileUploadException; +use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipValidationException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Uploads\FileStorage; @@ -41,6 +46,11 @@ public function findVisible(int $id): Import return $query->findOrFail($id); } + /** + * @throws FileUploadException + * @throws ZipValidationException + * @throws ZipExportException + */ public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); @@ -50,15 +60,23 @@ public function storeFromUpload(UploadedFile $file): Import throw new ZipValidationException($errors); } - $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $reader = new ZipExportReader($zipPath); + $exportModel = $reader->decodeDataToExportModel(); + $import = new Import(); - $import->name = $zipEntityInfo['name']; - $import->book_count = $zipEntityInfo['book_count']; - $import->chapter_count = $zipEntityInfo['chapter_count']; - $import->page_count = $zipEntityInfo['page_count']; + $import->type = match (get_class($exportModel)) { + ZipExportPage::class => 'page', + ZipExportChapter::class => 'chapter', + ZipExportBook::class => 'book', + }; + + $import->name = $exportModel->name; $import->created_by = user()->id; $import->size = filesize($zipPath); + $exportModel->metadataOnly(); + $import->metadata = json_encode($exportModel); + $path = $this->storage->uploadFile( $file, 'uploads/files/imports/', @@ -72,6 +90,13 @@ public function storeFromUpload(UploadedFile $file): Import return $import; } + public function runImport(Import $import, ?string $parent = null) + { + // TODO - Download import zip (if needed) + // TODO - Validate zip file again + // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) + } + public function deleteImport(Import $import): void { $this->storage->delete($import->path); diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index e586b91b0ee..1dbdc7333e8 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -14,6 +14,11 @@ class ZipExportAttachment extends ZipExportModel public ?string $link = null; public ?string $file = null; + public function metadataOnly(): void + { + $this->order = $this->link = $this->file = null; + } + public static function fromModel(Attachment $model, ZipExportFiles $files): self { $instance = new self(); @@ -49,4 +54,17 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $context->validateData($data, $rules); } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->order = isset($data['order']) ? intval($data['order']) : null; + $model->link = $data['link'] ?? null; + $model->file = $data['file'] ?? null; + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 7e1f2d8106e..0dc4e93d43c 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -21,6 +21,21 @@ class ZipExportBook extends ZipExportModel /** @var ZipExportTag[] */ public array $tags = []; + public function metadataOnly(): void + { + $this->description_html = $this->cover = null; + + foreach ($this->chapters as $chapter) { + $chapter->metadataOnly(); + } + foreach ($this->pages as $page) { + $page->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + public static function fromModel(Book $model, ZipExportFiles $files): self { $instance = new self(); @@ -71,4 +86,19 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $errors; } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->description_html = $data['description_html'] ?? null; + $model->cover = $data['cover'] ?? null; + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []); + $model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []); + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 03df31b7078..50440d61a5a 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -18,6 +18,18 @@ class ZipExportChapter extends ZipExportModel /** @var ZipExportTag[] */ public array $tags = []; + public function metadataOnly(): void + { + $this->description_html = $this->priority = null; + + foreach ($this->pages as $page) { + $page->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + public static function fromModel(Chapter $model, ZipExportFiles $files): self { $instance = new self(); @@ -61,4 +73,18 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $errors; } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->description_html = $data['description_html'] ?? null; + $model->priority = isset($data['priority']) ? intval($data['priority']) : null; + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []); + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 3388c66df36..691eb918fc6 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -25,6 +25,11 @@ public static function fromModel(Image $model, ZipExportFiles $files): self return $instance; } + public function metadataOnly(): void + { + // + } + public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ @@ -36,4 +41,16 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $context->validateData($data, $rules); } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->file = $data['file']; + $model->type = $data['type']; + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php index 4d66f010f86..d3a8c35674b 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -26,4 +26,32 @@ public function jsonSerialize(): array * item in the array for its own validation messages. */ abstract public static function validate(ZipValidationHelper $context, array $data): array; + + /** + * Decode the array of data into this export model. + */ + abstract public static function fromArray(array $data): self; + + /** + * Decode an array of array data into an array of export models. + * @param array[] $data + * @return self[] + */ + public static function fromManyArray(array $data): array + { + $results = []; + foreach ($data as $item) { + $results[] = static::fromArray($item); + } + return $results; + } + + /** + * Remove additional content in this model to reduce it down + * to just essential id/name values for identification. + * + * The result of this may be something that does not pass validation, but is + * simple for the purpose of creating a contents. + */ + abstract public function metadataOnly(): void; } diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 2c8b9a88abd..3a876e7aaff 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -21,6 +21,21 @@ class ZipExportPage extends ZipExportModel /** @var ZipExportTag[] */ public array $tags = []; + public function metadataOnly(): void + { + $this->html = $this->markdown = $this->priority = null; + + foreach ($this->attachments as $attachment) { + $attachment->metadataOnly(); + } + foreach ($this->images as $image) { + $image->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + public static function fromModel(Page $model, ZipExportFiles $files): self { $instance = new self(); @@ -70,4 +85,20 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $errors; } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->html = $data['html'] ?? null; + $model->markdown = $data['markdown'] ?? null; + $model->priority = isset($data['priority']) ? intval($data['priority']) : null; + $model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []); + $model->images = ZipExportImage::fromManyArray($data['images'] ?? []); + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index 99abb811c06..b6c9e338aef 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -11,6 +11,11 @@ class ZipExportTag extends ZipExportModel public ?string $value = null; public ?int $order = null; + public function metadataOnly(): void + { + $this->value = $this->order = null; + } + public static function fromModel(Tag $model): self { $instance = new self(); @@ -36,4 +41,15 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $context->validateData($data, $rules); } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->name = $data['name']; + $model->value = $data['value'] ?? null; + $model->order = isset($data['order']) ? intval($data['order']) : null; + + return $model; + } } diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index 7187a18897d..c3e47da048d 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -3,6 +3,10 @@ namespace BookStack\Exports\ZipExports; use BookStack\Exceptions\ZipExportException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportModel; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; class ZipExportReader @@ -71,32 +75,18 @@ public function fileExists(string $fileName): bool /** * @throws ZipExportException - * @returns array{name: string, book_count: int, chapter_count: int, page_count: int} */ - public function getEntityInfo(): array + public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage { $data = $this->readData(); - $info = ['name' => '', 'book_count' => 0, 'chapter_count' => 0, 'page_count' => 0]; - if (isset($data['book'])) { - $info['name'] = $data['book']['name'] ?? ''; - $info['book_count']++; - $chapters = $data['book']['chapters'] ?? []; - $pages = $data['book']['pages'] ?? []; - $info['chapter_count'] += count($chapters); - $info['page_count'] += count($pages); - foreach ($chapters as $chapter) { - $info['page_count'] += count($chapter['pages'] ?? []); - } - } elseif (isset($data['chapter'])) { - $info['name'] = $data['chapter']['name'] ?? ''; - $info['chapter_count']++; - $info['page_count'] += count($data['chapter']['pages'] ?? []); - } elseif (isset($data['page'])) { - $info['name'] = $data['page']['name'] ?? ''; - $info['page_count']++; + return ZipExportBook::fromArray($data['book']); + } else if (isset($data['chapter'])) { + return ZipExportChapter::fromArray($data['chapter']); + } else if (isset($data['page'])) { + return ZipExportPage::fromArray($data['page']); } - return $info; + throw new ZipExportException("Could not identify content in ZIP file data."); } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index e476998c216..e27ae53c774 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -38,7 +38,6 @@ public function validate(): array return ['format' => trans('errors.import_zip_no_data')]; } - return $this->flattenModelErrors($modelErrors, $keyPrefix); } diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 55378d5832e..74a2bcd65f3 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -23,9 +23,8 @@ public function definition(): array return [ 'path' => 'uploads/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), - 'book_count' => 1, - 'chapter_count' => 5, - 'page_count' => 15, + 'type' => 'book', + 'metadata' => '{"name": "My book"}', 'created_at' => User::factory(), ]; } diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php index ed188226981..0784591b8e3 100644 --- a/database/migrations/2024_11_02_160700_create_imports_table.php +++ b/database/migrations/2024_11_02_160700_create_imports_table.php @@ -16,10 +16,9 @@ public function up(): void $table->string('name'); $table->string('path'); $table->integer('size'); - $table->integer('book_count'); - $table->integer('chapter_count'); - $table->integer('page_count'); - $table->integer('created_by'); + $table->string('type'); + $table->longText('metadata'); + $table->integer('created_by')->index(); $table->timestamps(); }); } diff --git a/lang/en/entities.php b/lang/en/entities.php index 065eb043a11..ae1c1e8d4cc 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -45,7 +45,7 @@ 'default_template_select' => 'Select a template page', 'import' => 'Import', 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', 'import_zip_select' => 'Select ZIP file to upload', 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', 'import_pending' => 'Pending Imports', @@ -53,9 +53,9 @@ 'import_continue' => 'Continue Import', 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', 'import_run' => 'Run Import', - 'import_size' => 'Import ZIP Size:', - 'import_uploaded_at' => 'Uploaded:', - 'import_uploaded_by' => 'Uploaded by:', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', 'import_location' => 'Import Location', 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 942265d04d8..2cf3cbf8221 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -248,4 +248,9 @@ $loadingSize: 10px; transform: rotate(180deg); } } +} + +.import-item { + border-inline-start: 2px solid currentColor; + padding-inline-start: $-xs; } \ No newline at end of file diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index 63977947dba..40867377fb0 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -1,11 +1,6 @@ @extends('layouts.simple') @section('body') - - @php - $type = $import->getType(); - @endphp -
                                @@ -13,29 +8,17 @@

                                {{ trans('entities.import_continue_desc') }}

                                - -
                                + +
                                -

                                @icon($type) {{ $import->name }}

                                - @if($type === 'book') -

                                @icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}

                                - @endif - @if($type === 'book' || $type === 'chapter') -

                                @icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}

                                - @endif + @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
                                -
                                -
                                - {{ trans('entities.import_size') }} - {{ $import->getSizeString() }} -
                                -
                                - {{ trans('entities.import_uploaded_at') }} - {{ $import->created_at->diffForHumans() }} -
                                +
                                +
                                {{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}
                                +
                                {{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}
                                @if($import->createdBy) -
                                - {{ trans('entities.import_uploaded_by') }} +
                                + {{ trans('entities.import_uploaded_by') }} {{ $import->createdBy->name }}
                                @endif @@ -48,14 +31,14 @@ method="POST"> {{ csrf_field() }} - @if($type === 'page' || $type === 'chapter') + @if($import->type === 'page' || $import->type === 'chapter')

                                {{ trans('entities.import_location_desc') }}

                                @include('entities.selector', [ 'name' => 'parent', - 'entityTypes' => $type === 'page' ? 'chapter,book' : 'book', - 'entityPermission' => "{$type}-create", + 'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book', + 'entityPermission' => "{$import->type}-create", 'selectorSize' => 'compact small', ]) @include('form.errors', ['name' => 'parent']) diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php new file mode 100644 index 00000000000..811a3b31bda --- /dev/null +++ b/resources/views/exports/parts/import-item.blade.php @@ -0,0 +1,26 @@ +{{-- +$type - string +$model - object +--}} +
                                +

                                @icon($type){{ $model->name }}

                                +
                                +
                                + @if($model->attachments ?? []) + @icon('attach'){{ count($model->attachments) }} + @endif + @if($model->images ?? []) + @icon('image'){{ count($model->images) }} + @endif + @if($model->tags ?? []) + @icon('tag'){{ count($model->tags) }} + @endif +
                                + @foreach($model->chapters ?? [] as $chapter) + @include('exports.parts.import-item', ['type' => 'chapter', 'model' => $chapter]) + @endforeach + @foreach($model->pages ?? [] as $page) + @include('exports.parts.import-item', ['type' => 'page', 'model' => $page]) + @endforeach +
                                +
                                \ No newline at end of file diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php index 5ff6600f24b..fd53095a422 100644 --- a/resources/views/exports/parts/import.blade.php +++ b/resources/views/exports/parts/import.blade.php @@ -1,18 +1,9 @@ -@php - $type = $import->getType(); -@endphp
                                @icon($type) {{ $import->name }} + class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}
                                - @if($type === 'book') -
                                @icon('chapter') {{ $import->chapter_count }}
                                - @endif - @if($type === 'book' || $type === 'chapter') -
                                @icon('page') {{ $import->page_count }}
                                - @endif
                                {{ $import->getSizeString() }}
                                @icon('time'){{ $import->created_at->diffForHumans() }}
                                diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index b9a8598fabe..2b40100aabe 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -4,6 +4,9 @@ use BookStack\Activity\ActivityType; use BookStack\Exports\Import; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use Illuminate\Http\UploadedFile; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -130,7 +133,7 @@ public function test_import_upload_success() { $admin = $this->users->admin(); $this->actingAs($admin); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $data = [ 'book' => [ 'name' => 'My great book name', 'chapters' => [ @@ -149,13 +152,13 @@ public function test_import_upload_success() ] ], ], - ])); + ]; + + $resp = $this->runImportFromFile($this->zipUploadFromData($data)); $this->assertDatabaseHas('imports', [ 'name' => 'My great book name', - 'book_count' => 1, - 'chapter_count' => 1, - 'page_count' => 2, + 'type' => 'book', 'created_by' => $admin->id, ]); @@ -168,11 +171,25 @@ public function test_import_upload_success() public function test_import_show_page() { - $import = Import::factory()->create(['name' => 'MySuperAdminImport']); + $exportBook = new ZipExportBook(); + $exportBook->name = 'My exported book'; + $exportChapter = new ZipExportChapter(); + $exportChapter->name = 'My exported chapter'; + $exportPage = new ZipExportPage(); + $exportPage->name = 'My exported page'; + $exportBook->chapters = [$exportChapter]; + $exportChapter->pages = [$exportPage]; + + $import = Import::factory()->create([ + 'name' => 'MySuperAdminImport', + 'metadata' => json_encode($exportBook) + ]); $resp = $this->asAdmin()->get("/import/{$import->id}"); $resp->assertOk(); - $resp->assertSee('MySuperAdminImport'); + $resp->assertSeeText('My exported book'); + $resp->assertSeeText('My exported chapter'); + $resp->assertSeeText('My exported page'); } public function test_import_show_page_access_limited() From 7b84558ca1deb0a605a2f632e60baaad325615e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 Nov 2024 15:41:58 +0000 Subject: [PATCH 204/800] ZIP Imports: Added parent and permission check pre-import --- app/Exceptions/ZipImportException.php | 12 ++ app/Exports/Controllers/ImportController.php | 2 - app/Exports/ImportRepo.php | 20 ++- app/Exports/ZipExports/ZipExportValidator.php | 7 +- app/Exports/ZipExports/ZipImportRunner.php | 143 ++++++++++++++++++ app/Uploads/FileStorage.php | 23 ++- 6 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 app/Exceptions/ZipImportException.php create mode 100644 app/Exports/ZipExports/ZipImportRunner.php diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php new file mode 100644 index 00000000000..2403c514477 --- /dev/null +++ b/app/Exceptions/ZipImportException.php @@ -0,0 +1,12 @@ +imports->findVisible($id); -// dd($import->decodeMetadata()); - $this->setPageTitle(trans('entities.import_continue')); return view('exports.import-show', [ diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index 3265e1c80dc..b94563545a4 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,6 +2,7 @@ namespace BookStack\Exports; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipValidationException; @@ -10,6 +11,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; +use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Uploads\FileStorage; use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -18,6 +20,8 @@ class ImportRepo { public function __construct( protected FileStorage $storage, + protected ZipImportRunner $importer, + protected EntityQueries $entityQueries, ) { } @@ -54,13 +58,13 @@ public function findVisible(int $id): Import public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); + $reader = new ZipExportReader($zipPath); - $errors = (new ZipExportValidator($zipPath))->validate(); + $errors = (new ZipExportValidator($reader))->validate(); if ($errors) { throw new ZipValidationException($errors); } - $reader = new ZipExportReader($zipPath); $exportModel = $reader->decodeDataToExportModel(); $import = new Import(); @@ -90,11 +94,17 @@ public function storeFromUpload(UploadedFile $file): Import return $import; } + /** + * @throws ZipValidationException + */ public function runImport(Import $import, ?string $parent = null) { - // TODO - Download import zip (if needed) - // TODO - Validate zip file again - // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) + $parentModel = null; + if ($import->type === 'page' || $import->type === 'chapter') { + $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; + } + + return $this->importer->run($import, $parentModel); } public function deleteImport(Import $import): void diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index e27ae53c774..889804f2013 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -10,20 +10,19 @@ class ZipExportValidator { public function __construct( - protected string $zipPath, + protected ZipExportReader $reader, ) { } public function validate(): array { - $reader = new ZipExportReader($this->zipPath); try { - $importData = $reader->readData(); + $importData = $this->reader->readData(); } catch (ZipExportException $exception) { return ['format' => $exception->getMessage()]; } - $helper = new ZipValidationHelper($reader); + $helper = new ZipValidationHelper($this->reader); if (isset($importData['book'])) { $modelErrors = ZipExportBook::validate($helper, $importData['book']); diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php new file mode 100644 index 00000000000..2f784ebea6e --- /dev/null +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -0,0 +1,143 @@ +getZipPath($import); + $reader = new ZipExportReader($zipPath); + + $errors = (new ZipExportValidator($reader))->validate(); + if ($errors) { + throw new ZipImportException(["ZIP failed to validate"]); + } + + try { + $exportModel = $reader->decodeDataToExportModel(); + } catch (ZipExportException $e) { + throw new ZipImportException([$e->getMessage()]); + } + + // Validate parent type + if ($exportModel instanceof ZipExportBook && ($parent !== null)) { + throw new ZipImportException(["Must not have a parent set for a Book import"]); + } else if ($exportModel instanceof ZipExportChapter && (!$parent instanceof Book)) { + throw new ZipImportException(["Parent book required for chapter import"]); + } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) { + throw new ZipImportException(["Parent book or chapter required for page import"]); + } + + $this->ensurePermissionsPermitImport($exportModel); + + // TODO - Run import + } + + /** + * @throws ZipImportException + */ + protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void + { + $errors = []; + + // TODO - Extract messages to language files + // TODO - Ensure these are shown to users on failure + + $chapters = []; + $pages = []; + $images = []; + $attachments = []; + + if ($exportModel instanceof ZipExportBook) { + if (!userCan('book-create-all')) { + $errors[] = 'You are lacking the required permission to create books.'; + } + array_push($pages, ...$exportModel->pages); + array_push($chapters, ...$exportModel->chapters); + } else if ($exportModel instanceof ZipExportChapter) { + $chapters[] = $exportModel; + } else if ($exportModel instanceof ZipExportPage) { + $pages[] = $exportModel; + } + + foreach ($chapters as $chapter) { + array_push($pages, ...$chapter->pages); + } + + if (count($chapters) > 0) { + $permission = 'chapter-create' . ($parent ? '' : '-all'); + if (!userCan($permission, $parent)) { + $errors[] = 'You are lacking the required permission to create chapters.'; + } + } + + foreach ($pages as $page) { + array_push($attachments, ...$page->attachments); + array_push($images, ...$page->images); + } + + if (count($pages) > 0) { + if ($parent) { + if (!userCan('page-create', $parent)) { + $errors[] = 'You are lacking the required permission to create pages.'; + } + } else { + $hasPermission = userCan('page-create-all') || userCan('page-create-own'); + if (!$hasPermission) { + $errors[] = 'You are lacking the required permission to create pages.'; + } + } + } + + if (count($images) > 0) { + if (!userCan('image-create-all')) { + $errors[] = 'You are lacking the required permissions to create images.'; + } + } + + if (count($attachments) > 0) { + if (userCan('attachment-create-all')) { + $errors[] = 'You are lacking the required permissions to create attachments.'; + } + } + + if (count($errors)) { + throw new ZipImportException($errors); + } + } + + protected function getZipPath(Import $import): string + { + if (!$this->storage->isRemote()) { + return $this->storage->getSystemPath($import->path); + } + + $tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-'); + $tempFile = fopen($tempFilePath, 'wb'); + $stream = $this->storage->getReadStream($import->path); + stream_copy_to_stream($stream, $tempFile); + fclose($tempFile); + + return $tempFilePath; + } +} diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index 278484e519d..e6ac368d000 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -5,6 +5,7 @@ use BookStack\Exceptions\FileUploadException; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -70,6 +71,26 @@ public function uploadFile(UploadedFile $file, string $subDirectory, string $suf return $filePath; } + /** + * Check whether the configured storage is remote from the host of this app. + */ + public function isRemote(): bool + { + return $this->getStorageDiskName() === 's3'; + } + + /** + * Get the actual path on system for the given relative file path. + */ + public function getSystemPath(string $filePath): string + { + if ($this->isRemote()) { + return ''; + } + + return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/')); + } + /** * Get the storage that will be used for storing files. */ @@ -83,7 +104,7 @@ protected function getStorageDisk(): Storage */ protected function getStorageDiskName(): string { - $storageType = config('filesystems.attachments'); + $storageType = trim(strtolower(config('filesystems.attachments'))); // Change to our secure-attachment disk if any of the local options // are used to prevent escaping that location. From 45189d9517257cdcf599273a5f1a08fa60206564 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 7 Nov 2024 17:10:35 +0000 Subject: [PATCH 205/800] System CLI: Updated to 126de5599c state --- bookstack-system-cli | Bin 355805 -> 386055 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/bookstack-system-cli b/bookstack-system-cli index 0a643e1142e46b4d6bbf2c474b08d64e3b0ae9dd..6fbfd98745b8bac922648e0354b9d55c9b9245a2 100755 GIT binary patch literal 386055 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 %*cN?ceKL{UwF7oe! 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�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%0Du@KH=}M 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 V7PkWf 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
                                FFt|U%9!|YoGcb0 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
                                &>3!V85U}cUg_*Uy&r4jUVaPqHFVs^pt;#&3Jf+p$MjKRt5M4w+c?Ua7tU19h) zNjLoC5fA}wgYK3hjy`Cd42UH^YX!@T6J`f5OXH}w23lCvI{20i7^19?R%K8)*D^I4 z%7fUM_liTDYIrN6<0yvgwQ7~I3!6H_BZ$xCioxdDQ69A#9aTn`vlBt;W6RmI3}VcC z-yP;y<9pl=VCOmAEV06|xZEs#5pHatwZBPe&hnjQORv2Amg)H%mNj~Kb1{?g?rl$C zzJR8tz~XVc6QR-IVR_}s%K!}>#kK0X%PBAq#sqg#ntLSkx}~lo)zo*IG7FrWmaf`F zIM%Cu^OH+3bzhLdrL|SUb`SZ`CzOvWd@;!;AwvdIl+M$ zeL56$oCM9kl6@x?5^b+9GkftbwaLVG3}%{7EoJT6JEj581r7?PQYU*!f)I^y2L8>G zH+yrXl_96YCG7kV2XVo-9XK`!WbINfi40^4V`Z8U`1~9)-tzSG^g%?)+7HXh_(dWJ zLa|TjYujeWvFoBaLN^4Q@6}Y3(8Z1pr5+x z5g8+5t$)lD%SUjdm4BXogQJV~8Dl^%;P|N`axK_1kK%j%%yOiHPqvVttlMpto<#f> zb89ylS)huYsO|+W8EN(;X|E7t_B1f8E|uu#SDr}|aD%s;gkk_b~&eUWvi0X5*4TY9MK=nthdsd z606POvux~9Rm1PdE%;NH`un1yw2ryCxEy%1mYB4zxr6))k?ZeH`fg=E9TRyNIrD?N z1y{sZLlnykk#)m`C2c*z)Z2d#8M zsgzD@?VE?&O5W(H)FYp5k#|4Oi01&$q3{^iG~cCYwSSvgiwHlMy`4pxI(VXl+w<0c zqP-y+Be1~UL@Jvi*^Qdd3o*aOYlw+ZSy**aGfO8Mpd@aij8A+0lMgHHAR6G6zbftY z`S!@p4+WeF{`wQGxzqX_f{MPC3p-8upP#yqZ_A(e2!gL4T?1%0A3GUw*?ytoafZpp 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%0AbWEDBpc1dt>XtfyhH{70j-VNSDSb3@KbB*9hLp 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�+5Q)G_ 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 Tq4|&trNfAozE~-WdZZhL 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 D&!*GK From 8120278b8ce22c8efa54146bcdb66861303a9c21 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 Nov 2024 10:41:25 +0000 Subject: [PATCH 206/800] PHP Deps: Bumped up minor versions --- composer.lock | 494 +++++++++++++++++++++++++------------------------- 1 file changed, 249 insertions(+), 245 deletions(-) diff --git a/composer.lock b/composer.lock index d86ec0b33d6..f2d585f8fb2 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "aws/aws-crt-php", - "version": "v1.2.6", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/awslabs/aws-crt-php.git", - "reference": "a63485b65b6b3367039306496d49737cf1995408" + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/a63485b65b6b3367039306496d49737cf1995408", - "reference": "a63485b65b6b3367039306496d49737cf1995408", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", "shasum": "" }, "require": { @@ -56,22 +56,22 @@ ], "support": { "issues": "https://github.com/awslabs/aws-crt-php/issues", - "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.6" + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" }, - "time": "2024-06-13T17:21:28+00:00" + "time": "2024-10-18T22:15:13+00:00" }, { "name": "aws/aws-sdk-php", - "version": "3.322.6", + "version": "3.325.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ae7b0edab466c3440fe007c07cb62ae32a4dbfca" + "reference": "86372984539f25485718059f997a4ea12c05ba0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ae7b0edab466c3440fe007c07cb62ae32a4dbfca", - "reference": "ae7b0edab466c3440fe007c07cb62ae32a4dbfca", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/86372984539f25485718059f997a4ea12c05ba0a", + "reference": "86372984539f25485718059f997a4ea12c05ba0a", "shasum": "" }, "require": { @@ -154,22 +154,22 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.322.6" + "source": "https://github.com/aws/aws-sdk-php/tree/3.325.4" }, - "time": "2024-09-26T18:12:45+00:00" + "time": "2024-11-07T19:25:01+00:00" }, { "name": "bacon/bacon-qr-code", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "510de6eca6248d77d31b339d62437cc995e2fb41" + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/510de6eca6248d77d31b339d62437cc995e2fb41", - "reference": "510de6eca6248d77d31b339d62437cc995e2fb41", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f9cc1f52b5a463062251d666761178dbdb6b544f", + "reference": "f9cc1f52b5a463062251d666761178dbdb6b544f", "shasum": "" }, "require": { @@ -208,9 +208,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.0" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.1" }, - "time": "2024-04-18T11:16:25+00:00" + "time": "2024-10-01T13:55:55+00:00" }, { "name": "brick/math", @@ -561,16 +561,16 @@ }, { "name": "doctrine/dbal", - "version": "3.9.1", + "version": "3.9.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7" + "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", - "reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", + "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", "shasum": "" }, "require": { @@ -586,7 +586,7 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.12.0", + "phpstan/phpstan": "1.12.6", "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "9.6.20", "psalm/plugin-phpunit": "0.18.4", @@ -654,7 +654,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.1" + "source": "https://github.com/doctrine/dbal/tree/3.9.3" }, "funding": [ { @@ -670,7 +670,7 @@ "type": "tidelift" } ], - "time": "2024-09-01T13:49:23+00:00" + "time": "2024-10-10T17:56:43+00:00" }, { "name": "doctrine/deprecations", @@ -1135,16 +1135,16 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.3.3", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a" + "reference": "8c784d071debd117328803d86b2097615b457500" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", - "reference": "adfb1f505deb6384dc8b39804c5065dd3c8c8c0a", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", "shasum": "" }, "require": { @@ -1157,10 +1157,14 @@ "require-dev": { "phpstan/extension-installer": "^1.0", "phpstan/phpstan": "^1.0", - "phpstan/phpstan-webmozart-assert": "^1.0", "phpunit/phpunit": "^7.0|^8.0|^9.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, "autoload": { "psr-4": { "Cron\\": "src/Cron/" @@ -1184,7 +1188,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.3" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" }, "funding": [ { @@ -1192,7 +1196,7 @@ "type": "github" } ], - "time": "2023-08-10T19:36:49+00:00" + "time": "2024-10-09T13:47:03+00:00" }, { "name": "egulias/email-validator", @@ -1585,16 +1589,16 @@ }, { "name": "guzzlehttp/promises", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8" + "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", - "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", + "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455", "shasum": "" }, "require": { @@ -1648,7 +1652,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.3" + "source": "https://github.com/guzzle/promises/tree/2.0.4" }, "funding": [ { @@ -1664,7 +1668,7 @@ "type": "tidelift" } ], - "time": "2024-07-18T10:29:17+00:00" + "time": "2024-10-17T10:06:22+00:00" }, { "name": "guzzlehttp/psr7", @@ -1938,21 +1942,21 @@ }, { "name": "intervention/image", - "version": "3.8.0", + "version": "3.9.1", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "1786ad5e1789050939d73cd195de4b8eaeeb34ed" + "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/1786ad5e1789050939d73cd195de4b8eaeeb34ed", - "reference": "1786ad5e1789050939d73cd195de4b8eaeeb34ed", + "url": "https://api.github.com/repos/Intervention/image/zipball/b496d1f6b9f812f96166623358dfcafb8c3b1683", + "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683", "shasum": "" }, "require": { "ext-mbstring": "*", - "intervention/gif": "^4.1", + "intervention/gif": "^4.2", "php": "^8.1" }, "require-dev": { @@ -1994,7 +1998,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.8.0" + "source": "https://github.com/Intervention/image/tree/3.9.1" }, "funding": [ { @@ -2010,7 +2014,7 @@ "type": "ko_fi" } ], - "time": "2024-08-16T14:57:26+00:00" + "time": "2024-10-27T10:15:54+00:00" }, { "name": "knplabs/knp-snappy", @@ -2733,16 +2737,16 @@ }, { "name": "league/flysystem", - "version": "3.28.0", + "version": "3.29.1", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c" + "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c", - "reference": "e611adab2b1ae2e3072fa72d62c62f52c2bf1f0c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/edc1bb7c86fab0776c3287dbd19b5fa278347319", + "reference": "edc1bb7c86fab0776c3287dbd19b5fa278347319", "shasum": "" }, "require": { @@ -2810,22 +2814,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.29.1" }, - "time": "2024-05-22T10:09:12+00:00" + "time": "2024-10-08T08:58:34+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.28.0", + "version": "3.29.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "22071ef1604bc776f5ff2468ac27a752514665c8" + "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/22071ef1604bc776f5ff2468ac27a752514665c8", - "reference": "22071ef1604bc776f5ff2468ac27a752514665c8", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9", + "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9", "shasum": "" }, "require": { @@ -2865,22 +2869,22 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0" }, - "time": "2024-05-06T20:05:52+00:00" + "time": "2024-08-17T13:10:48+00:00" }, { "name": "league/flysystem-local", - "version": "3.28.0", + "version": "3.29.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "13f22ea8be526ea58c2ddff9e158ef7c296e4f40" + "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/13f22ea8be526ea58c2ddff9e158ef7c296e4f40", - "reference": "13f22ea8be526ea58c2ddff9e158ef7c296e4f40", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", + "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", "shasum": "" }, "require": { @@ -2914,9 +2918,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.28.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" }, - "time": "2024-05-06T20:05:52+00:00" + "time": "2024-08-09T21:24:39+00:00" }, { "name": "league/html-to-markdown", @@ -3552,24 +3556,24 @@ }, { "name": "nette/schema", - "version": "v1.3.0", + "version": "v1.3.2", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "a6d3a6d1f545f01ef38e60f375d1cf1f4de98188" + "reference": "da801d52f0354f70a638673c4a0f04e16529431d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/a6d3a6d1f545f01ef38e60f375d1cf1f4de98188", - "reference": "a6d3a6d1f545f01ef38e60f375d1cf1f4de98188", + "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.3" + "php": "8.1 - 8.4" }, "require-dev": { - "nette/tester": "^2.4", + "nette/tester": "^2.5.2", "phpstan/phpstan-nette": "^1.0", "tracy/tracy": "^2.8" }, @@ -3608,9 +3612,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.0" + "source": "https://github.com/nette/schema/tree/v1.3.2" }, - "time": "2023-12-11T11:54:22+00:00" + "time": "2024-10-06T23:10:23+00:00" }, { "name": "nette/utils", @@ -3700,16 +3704,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.2.0", + "version": "v5.3.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb" + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", - "reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", "shasum": "" }, "require": { @@ -3752,39 +3756,38 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" }, - "time": "2024-09-15T16:40:33+00:00" + "time": "2024-10-08T18:51:32+00:00" }, { "name": "nunomaduro/termwind", - "version": "v1.15.1", + "version": "v1.16.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "8ab0b32c8caa4a2e09700ea32925441385e4a5dc" + "reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/8ab0b32c8caa4a2e09700ea32925441385e4a5dc", - "reference": "8ab0b32c8caa4a2e09700ea32925441385e4a5dc", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dcf1ec3dfa36137b7ce41d43866644a7ab8fc257", + "reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "^8.0", - "symfony/console": "^5.3.0|^6.0.0" + "php": "^8.1", + "symfony/console": "^6.4.12" }, "require-dev": { - "ergebnis/phpstan-rules": "^1.0.", - "illuminate/console": "^8.0|^9.0", - "illuminate/support": "^8.0|^9.0", - "laravel/pint": "^1.0.0", - "pestphp/pest": "^1.21.0", - "pestphp/pest-plugin-mock": "^1.0", - "phpstan/phpstan": "^1.4.6", - "phpstan/phpstan-strict-rules": "^1.1.0", - "symfony/var-dumper": "^5.2.7|^6.0.0", + "illuminate/console": "^10.48.22", + "illuminate/support": "^10.48.22", + "laravel/pint": "^1.18.1", + "pestphp/pest": "^2", + "pestphp/pest-plugin-mock": "2.0.0", + "phpstan/phpstan": "^1.12.6", + "phpstan/phpstan-strict-rules": "^1.6.1", + "symfony/var-dumper": "^6.4.11", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3824,7 +3827,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v1.15.1" + "source": "https://github.com/nunomaduro/termwind/tree/v1.16.0" }, "funding": [ { @@ -3840,7 +3843,7 @@ "type": "github" } ], - "time": "2023-02-08T01:06:31+00:00" + "time": "2024-10-15T15:27:12+00:00" }, { "name": "onelogin/php-saml", @@ -5130,24 +5133,24 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v8.6.0", + "version": "v8.7.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "d2fb94a9641be84d79c7548c6d39bbebba6e9a70" + "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d2fb94a9641be84d79c7548c6d39bbebba6e9a70", - "reference": "d2fb94a9641be84d79c7548c6d39bbebba6e9a70", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f414ff953002a9b18e3a116f5e462c56f21237cf", + "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf", "shasum": "" }, "require": { "ext-iconv": "*", - "php": ">=5.6.20" + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "require-dev": { - "phpunit/phpunit": "^5.7.27" + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.40" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -5189,9 +5192,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.6.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.7.0" }, - "time": "2024-07-01T07:33:21+00:00" + "time": "2024-10-27T17:38:32+00:00" }, { "name": "socialiteproviders/discord", @@ -5638,16 +5641,16 @@ }, { "name": "symfony/console", - "version": "v6.4.12", + "version": "v6.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765" + "reference": "897c2441ed4eec8a8a2c37b943427d24dba3f26b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/72d080eb9edf80e36c19be61f72c98ed8273b765", - "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765", + "url": "https://api.github.com/repos/symfony/console/zipball/897c2441ed4eec8a8a2c37b943427d24dba3f26b", + "reference": "897c2441ed4eec8a8a2c37b943427d24dba3f26b", "shasum": "" }, "require": { @@ -5712,7 +5715,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.12" + "source": "https://github.com/symfony/console/tree/v6.4.14" }, "funding": [ { @@ -5728,20 +5731,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:15:52+00:00" + "time": "2024-11-05T15:34:40+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.8", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "4b61b02fe15db48e3687ce1c45ea385d1780fe08" + "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/4b61b02fe15db48e3687ce1c45ea385d1780fe08", - "reference": "4b61b02fe15db48e3687ce1c45ea385d1780fe08", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/cb23e97813c5837a041b73a6d63a9ddff0778f5e", + "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e", "shasum": "" }, "require": { @@ -5777,7 +5780,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.8" + "source": "https://github.com/symfony/css-selector/tree/v6.4.13" }, "funding": [ { @@ -5793,7 +5796,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-25T14:18:03+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5864,16 +5867,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.10", + "version": "v6.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "231f1b2ee80f72daa1972f7340297d67439224f0" + "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/231f1b2ee80f72daa1972f7340297d67439224f0", - "reference": "231f1b2ee80f72daa1972f7340297d67439224f0", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/9e024324511eeb00983ee76b9aedc3e6ecd993d9", + "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9", "shasum": "" }, "require": { @@ -5919,7 +5922,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.10" + "source": "https://github.com/symfony/error-handler/tree/v6.4.14" }, "funding": [ { @@ -5935,20 +5938,20 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:30:32+00:00" + "time": "2024-11-05T15:34:40+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.8", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "8d7507f02b06e06815e56bb39aa0128e3806208b" + "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/8d7507f02b06e06815e56bb39aa0128e3806208b", - "reference": "8d7507f02b06e06815e56bb39aa0128e3806208b", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", + "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", "shasum": "" }, "require": { @@ -5999,7 +6002,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13" }, "funding": [ { @@ -6015,7 +6018,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-25T14:18:03+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6095,16 +6098,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.11", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453" + "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d7eb6daf8cd7e9ac4976e9576b32042ef7253453", - "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453", + "url": "https://api.github.com/repos/symfony/finder/zipball/daea9eca0b08d0ed1dc9ab702a46128fd1be4958", + "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958", "shasum": "" }, "require": { @@ -6139,7 +6142,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.11" + "source": "https://github.com/symfony/finder/tree/v6.4.13" }, "funding": [ { @@ -6155,20 +6158,20 @@ "type": "tidelift" } ], - "time": "2024-08-13T14:27:37+00:00" + "time": "2024-10-01T08:30:56+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.12", + "version": "v6.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "133ac043875f59c26c55e79cf074562127cce4d2" + "reference": "ba020a321a95519303a3f09ec2824d34d601c388" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/133ac043875f59c26c55e79cf074562127cce4d2", - "reference": "133ac043875f59c26c55e79cf074562127cce4d2", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ba020a321a95519303a3f09ec2824d34d601c388", + "reference": "ba020a321a95519303a3f09ec2824d34d601c388", "shasum": "" }, "require": { @@ -6216,7 +6219,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.12" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.14" }, "funding": [ { @@ -6232,20 +6235,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:18:25+00:00" + "time": "2024-11-05T16:39:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.12", + "version": "v6.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "96df83d51b5f78804f70c093b97310794fd6257b" + "reference": "8278a947d0369754a47b758a9e17b72cab970951" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/96df83d51b5f78804f70c093b97310794fd6257b", - "reference": "96df83d51b5f78804f70c093b97310794fd6257b", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8278a947d0369754a47b758a9e17b72cab970951", + "reference": "8278a947d0369754a47b758a9e17b72cab970951", "shasum": "" }, "require": { @@ -6330,7 +6333,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.12" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.14" }, "funding": [ { @@ -6346,20 +6349,20 @@ "type": "tidelift" } ], - "time": "2024-09-21T06:02:57+00:00" + "time": "2024-11-06T09:45:21+00:00" }, { "name": "symfony/mime", - "version": "v6.4.12", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "abe16ee7790b16aa525877419deb0f113953f0e1" + "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/abe16ee7790b16aa525877419deb0f113953f0e1", - "reference": "abe16ee7790b16aa525877419deb0f113953f0e1", + "url": "https://api.github.com/repos/symfony/mime/zipball/1de1cf14d99b12c7ebbb850491ec6ae3ed468855", + "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855", "shasum": "" }, "require": { @@ -6415,7 +6418,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.12" + "source": "https://github.com/symfony/mime/tree/v6.4.13" }, "funding": [ { @@ -6431,7 +6434,7 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:18:25+00:00" + "time": "2024-10-25T15:07:50+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7071,16 +7074,16 @@ }, { "name": "symfony/process", - "version": "v6.4.12", + "version": "v6.4.14", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3" + "reference": "25214adbb0996d18112548de20c281be9f27279f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/3f94e5f13ff58df371a7ead461b6e8068900fbb3", - "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3", + "url": "https://api.github.com/repos/symfony/process/zipball/25214adbb0996d18112548de20c281be9f27279f", + "reference": "25214adbb0996d18112548de20c281be9f27279f", "shasum": "" }, "require": { @@ -7112,7 +7115,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.12" + "source": "https://github.com/symfony/process/tree/v6.4.14" }, "funding": [ { @@ -7128,20 +7131,20 @@ "type": "tidelift" } ], - "time": "2024-09-17T12:47:12+00:00" + "time": "2024-11-06T09:25:01+00:00" }, { "name": "symfony/routing", - "version": "v6.4.12", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "a7c8036bd159486228dc9be3e846a00a0dda9f9f" + "reference": "640a74250d13f9c30d5ca045b6aaaabcc8215278" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/a7c8036bd159486228dc9be3e846a00a0dda9f9f", - "reference": "a7c8036bd159486228dc9be3e846a00a0dda9f9f", + "url": "https://api.github.com/repos/symfony/routing/zipball/640a74250d13f9c30d5ca045b6aaaabcc8215278", + "reference": "640a74250d13f9c30d5ca045b6aaaabcc8215278", "shasum": "" }, "require": { @@ -7195,7 +7198,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.12" + "source": "https://github.com/symfony/routing/tree/v6.4.13" }, "funding": [ { @@ -7211,7 +7214,7 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:32:26+00:00" + "time": "2024-10-01T08:30:56+00:00" }, { "name": "symfony/service-contracts", @@ -7298,16 +7301,16 @@ }, { "name": "symfony/string", - "version": "v6.4.12", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b" + "reference": "38371c60c71c72b3d64d8d76f6b1bb81a2cc3627" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f8a1ccebd0997e16112dfecfd74220b78e5b284b", - "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b", + "url": "https://api.github.com/repos/symfony/string/zipball/38371c60c71c72b3d64d8d76f6b1bb81a2cc3627", + "reference": "38371c60c71c72b3d64d8d76f6b1bb81a2cc3627", "shasum": "" }, "require": { @@ -7364,7 +7367,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.12" + "source": "https://github.com/symfony/string/tree/v6.4.13" }, "funding": [ { @@ -7380,20 +7383,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:15:52+00:00" + "time": "2024-09-25T14:18:03+00:00" }, { "name": "symfony/translation", - "version": "v6.4.12", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "cf8360b8352b086be620fae8342c4d96e391a489" + "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/cf8360b8352b086be620fae8342c4d96e391a489", - "reference": "cf8360b8352b086be620fae8342c4d96e391a489", + "url": "https://api.github.com/repos/symfony/translation/zipball/bee9bfabfa8b4045a66bf82520e492cddbaffa66", + "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66", "shasum": "" }, "require": { @@ -7459,7 +7462,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.12" + "source": "https://github.com/symfony/translation/tree/v6.4.13" }, "funding": [ { @@ -7475,7 +7478,7 @@ "type": "tidelift" } ], - "time": "2024-09-16T06:02:54+00:00" + "time": "2024-09-27T18:14:25+00:00" }, { "name": "symfony/translation-contracts", @@ -7557,16 +7560,16 @@ }, { "name": "symfony/uid", - "version": "v6.4.12", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d" + "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d", - "reference": "2f16054e0a9b194b8ca581d4a64eee3f7d4a9d4d", + "url": "https://api.github.com/repos/symfony/uid/zipball/18eb207f0436a993fffbdd811b5b8fa35fa5e007", + "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007", "shasum": "" }, "require": { @@ -7611,7 +7614,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.12" + "source": "https://github.com/symfony/uid/tree/v6.4.13" }, "funding": [ { @@ -7627,20 +7630,20 @@ "type": "tidelift" } ], - "time": "2024-09-20T08:32:26+00:00" + "time": "2024-09-25T14:18:03+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.11", + "version": "v6.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "ee14c8254a480913268b1e3b1cba8045ed122694" + "reference": "93c09246038178717a9c14b809ea8151ffcf7091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ee14c8254a480913268b1e3b1cba8045ed122694", - "reference": "ee14c8254a480913268b1e3b1cba8045ed122694", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/93c09246038178717a9c14b809ea8151ffcf7091", + "reference": "93c09246038178717a9c14b809ea8151ffcf7091", "shasum": "" }, "require": { @@ -7696,7 +7699,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.11" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.14" }, "funding": [ { @@ -7712,7 +7715,7 @@ "type": "tidelift" } ], - "time": "2024-08-30T16:03:21+00:00" + "time": "2024-11-05T15:34:40+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8247,36 +8250,37 @@ }, { "name": "larastan/larastan", - "version": "v2.9.8", + "version": "v2.9.10", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "340badd89b0eb5bddbc503a4829c08cf9a2819d7" + "reference": "9e7233d88f4f10796fadb8a2d85c5f2b55277c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/340badd89b0eb5bddbc503a4829c08cf9a2819d7", - "reference": "340badd89b0eb5bddbc503a4829c08cf9a2819d7", + "url": "https://api.github.com/repos/larastan/larastan/zipball/9e7233d88f4f10796fadb8a2d85c5f2b55277c76", + "reference": "9e7233d88f4f10796fadb8a2d85c5f2b55277c76", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.0", - "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.0", - "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.0", - "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.0", - "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.0", - "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.0", - "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.0", + "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.16", + "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.16", + "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.16", + "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.16", + "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.16", + "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.16", + "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16", "php": "^8.0.2", "phpmyadmin/sql-parser": "^5.9.0", - "phpstan/phpstan": "^1.11.2" + "phpstan/phpstan": "^1.12.5" }, "require-dev": { "doctrine/coding-standard": "^12.0", "nikic/php-parser": "^4.19.1", "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2", "orchestra/testbench": "^7.33.0 || ^8.13.0 || ^9.0.3", + "phpstan/phpstan-deprecation-rules": "^1.2", "phpunit/phpunit": "^9.6.13 || ^10.5.16" }, "suggest": { @@ -8325,7 +8329,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v2.9.8" + "source": "https://github.com/larastan/larastan/tree/v2.9.10" }, "funding": [ { @@ -8345,7 +8349,7 @@ "type": "patreon" } ], - "time": "2024-07-06T17:46:02+00:00" + "time": "2024-10-19T23:04:40+00:00" }, { "name": "mockery/mockery", @@ -8492,40 +8496,40 @@ }, { "name": "nunomaduro/collision", - "version": "v7.10.0", + "version": "v7.11.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "49ec67fa7b002712da8526678abd651c09f375b2" + "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/49ec67fa7b002712da8526678abd651c09f375b2", - "reference": "49ec67fa7b002712da8526678abd651c09f375b2", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/994ea93df5d4132f69d3f1bd74730509df6e8a05", + "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05", "shasum": "" }, "require": { - "filp/whoops": "^2.15.3", + "filp/whoops": "^2.16.0", "nunomaduro/termwind": "^1.15.1", "php": "^8.1.0", - "symfony/console": "^6.3.4" + "symfony/console": "^6.4.12" }, "conflict": { "laravel/framework": ">=11.0.0" }, "require-dev": { - "brianium/paratest": "^7.3.0", - "laravel/framework": "^10.28.0", - "laravel/pint": "^1.13.3", - "laravel/sail": "^1.25.0", - "laravel/sanctum": "^3.3.1", - "laravel/tinker": "^2.8.2", - "nunomaduro/larastan": "^2.6.4", - "orchestra/testbench-core": "^8.13.0", - "pestphp/pest": "^2.23.2", - "phpunit/phpunit": "^10.4.1", - "sebastian/environment": "^6.0.1", - "spatie/laravel-ignition": "^2.3.1" + "brianium/paratest": "^7.3.1", + "laravel/framework": "^10.48.22", + "laravel/pint": "^1.18.1", + "laravel/sail": "^1.36.0", + "laravel/sanctum": "^3.3.3", + "laravel/tinker": "^2.10.0", + "nunomaduro/larastan": "^2.9.8", + "orchestra/testbench-core": "^8.28.3", + "pestphp/pest": "^2.35.1", + "phpunit/phpunit": "^10.5.36", + "sebastian/environment": "^6.1.0", + "spatie/laravel-ignition": "^2.8.0" }, "type": "library", "extra": { @@ -8584,7 +8588,7 @@ "type": "patreon" } ], - "time": "2023-10-11T15:45:01+00:00" + "time": "2024-10-15T15:12:40+00:00" }, { "name": "phar-io/manifest", @@ -8793,16 +8797,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.5", + "version": "1.12.8", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" + "reference": "f6a60a4d66142b8156c9da923f1972657bc4748c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f6a60a4d66142b8156c9da923f1972657bc4748c", + "reference": "f6a60a4d66142b8156c9da923f1972657bc4748c", "shasum": "" }, "require": { @@ -8847,7 +8851,7 @@ "type": "github" } ], - "time": "2024-09-26T12:45:22+00:00" + "time": "2024-11-06T19:06:49+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9172,16 +9176,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.35", + "version": "10.5.38", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "7ac8b4e63f456046dcb4c9787da9382831a1874b" + "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7ac8b4e63f456046dcb4c9787da9382831a1874b", - "reference": "7ac8b4e63f456046dcb4c9787da9382831a1874b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a86773b9e887a67bc53efa9da9ad6e3f2498c132", + "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132", "shasum": "" }, "require": { @@ -9202,7 +9206,7 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.2", + "sebastian/comparator": "^5.0.3", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", "sebastian/exporter": "^5.1.2", @@ -9253,7 +9257,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.35" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.38" }, "funding": [ { @@ -9269,7 +9273,7 @@ "type": "tidelift" } ], - "time": "2024-09-19T10:52:21+00:00" + "time": "2024-10-28T13:06:21+00:00" }, { "name": "sebastian/cli-parser", @@ -9441,16 +9445,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.2", + "version": "5.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53" + "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", - "reference": "2d3e04c3b4c1e84a5e7382221ad8883c8fbc4f53", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", "shasum": "" }, "require": { @@ -9461,7 +9465,7 @@ "sebastian/exporter": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.4" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -9506,7 +9510,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" }, "funding": [ { @@ -9514,7 +9518,7 @@ "type": "github" } ], - "time": "2024-08-12T06:03:08+00:00" + "time": "2024-10-18T14:56:07+00:00" }, { "name": "sebastian/complexity", @@ -10325,16 +10329,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v6.4.12", + "version": "v6.4.13", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0" + "reference": "ae074dffb018c37a57071990d16e6152728dd972" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/9d307ecbcb917001692be333cdc58f474fdb37f0", - "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ae074dffb018c37a57071990d16e6152728dd972", + "reference": "ae074dffb018c37a57071990d16e6152728dd972", "shasum": "" }, "require": { @@ -10372,7 +10376,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.12" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.13" }, "funding": [ { @@ -10388,7 +10392,7 @@ "type": "tidelift" } ], - "time": "2024-09-15T06:35:36+00:00" + "time": "2024-10-25T15:07:50+00:00" }, { "name": "theseer/tokenizer", @@ -10458,7 +10462,7 @@ "ext-mbstring": "*", "ext-xml": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.1.0" }, From 7017a1cae59353fd3d94683ae8f30a98bad5c559 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 Nov 2024 11:22:30 +0000 Subject: [PATCH 207/800] Update URL Command: Added revisions table support For #5292 Added test to cover. --- app/Console/Commands/UpdateUrlCommand.php | 7 +++++++ tests/Commands/UpdateUrlCommandTest.php | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php index 0c95b0a3c8a..e155878d331 100644 --- a/app/Console/Commands/UpdateUrlCommand.php +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -49,6 +49,7 @@ public function handle(Connection $db): int 'chapters' => ['description_html'], 'books' => ['description_html'], 'bookshelves' => ['description_html'], + 'page_revisions' => ['html', 'text', 'markdown'], 'images' => ['url'], 'settings' => ['value'], 'comments' => ['html', 'text'], @@ -77,6 +78,12 @@ public function handle(Connection $db): int $this->info('URL update procedure complete.'); $this->info('============================================================================'); $this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.'); + + if (!str_starts_with($newUrl, url('/'))) { + $this->warn('You still need to update your APP_URL env value. This is currently set to:'); + $this->warn(url('/')); + } + $this->info('============================================================================'); return 0; diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index 62c39c2741f..1353cf80365 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -87,6 +87,26 @@ public function test_command_updates_array_settings() $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']); } + public function test_command_updates_page_revisions() + { + $page = $this->entities->page(); + + for ($i = 0; $i < 2; $i++) { + $this->entities->updatePage($page, [ + 'name' => $page->name, + 'markdown' => "[A link {$i}](https://example.com/donkey/cat)" + ]); + } + + $this->runUpdate('https://example.com', 'https://cats.example.com'); + setting()->flushCache(); + + $this->assertDatabaseHas('page_revisions', [ + 'markdown' => '[A link 1](https://cats.example.com/donkey/cat)', + 'html' => '' . "\n" + ]); + } + protected function runUpdate(string $oldUrl, string $newUrl) { $this->artisan("bookstack:update-url {$oldUrl} {$newUrl}") From a6ba8dd68f841048811b496a4b4c39e4e11536fd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 Nov 2024 11:35:18 +0000 Subject: [PATCH 208/800] Testing: Improved reliability - Added extra column/value check for page revision test for accuracy. - Changed search sort test to use more reliable values. - Change due to database seeding somtimes generating values that proceeded the test value, expected to be first, in sort results. --- tests/Commands/UpdateUrlCommandTest.php | 1 + tests/Entity/EntitySearchTest.php | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index 1353cf80365..d336e05a240 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -102,6 +102,7 @@ public function test_command_updates_page_revisions() setting()->flushCache(); $this->assertDatabaseHas('page_revisions', [ + 'page_id' => $page->id, 'markdown' => '[A link 1](https://cats.example.com/donkey/cat)', 'html' => '' . "\n" ]); diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 57b7c3f6b88..cabf23bd3d3 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -393,11 +393,11 @@ public function test_sibling_search_for_books_provides_results_in_alphabetical_o $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book"); $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras'); - $searchBook->name = 'AAAAAAArdvarks'; + $searchBook->name = '1AAAAAAArdvarks'; $searchBook->save(); $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextBook->id}&entity_type=book"); - $this->withHtml($search)->assertElementContains('a:first-child', 'AAAAAAArdvarks'); + $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks'); } public function test_sibling_search_for_shelves_provides_results_in_alphabetical_order() @@ -411,11 +411,11 @@ public function test_sibling_search_for_shelves_provides_results_in_alphabetical $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf"); $this->withHtml($search)->assertElementNotContains('a:first-child', 'Zebras'); - $searchShelf->name = 'AAAAAAArdvarks'; + $searchShelf->name = '1AAAAAAArdvarks'; $searchShelf->save(); $search = $this->actingAs($this->users->viewer())->get("/search/entity/siblings?entity_id={$contextShelf->id}&entity_type=bookshelf"); - $this->withHtml($search)->assertElementContains('a:first-child', 'AAAAAAArdvarks'); + $this->withHtml($search)->assertElementContains('a:first-child', '1AAAAAAArdvarks'); } public function test_search_works_on_updated_page_content() From e42cdbe8e040e707614969b1fbca02028b54698e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 Nov 2024 13:29:21 +0000 Subject: [PATCH 209/800] Updated translations with latest Crowdin changes (#5250) --- lang/ar/auth.php | 4 +- lang/cs/common.php | 2 +- lang/cs/entities.php | 4 +- lang/cs/errors.php | 2 +- lang/et/common.php | 2 +- lang/et/entities.php | 4 +- lang/et/errors.php | 2 +- lang/fr/common.php | 2 +- lang/fr/entities.php | 4 +- lang/fr/errors.php | 2 +- lang/he/activities.php | 70 +++--- lang/he/auth.php | 50 ++--- lang/he/common.php | 8 +- lang/he/components.php | 26 +-- lang/he/passwords.php | 8 +- lang/he/preferences.php | 58 ++--- lang/he/settings.php | 6 +- lang/it/activities.php | 50 ++--- lang/it/auth.php | 44 ++-- lang/it/common.php | 48 ++--- lang/it/components.php | 32 +-- lang/it/editor.php | 70 +++--- lang/it/entities.php | 342 ++++++++++++++--------------- lang/it/errors.php | 2 +- lang/ja/common.php | 2 +- lang/ja/entities.php | 4 +- lang/ja/errors.php | 2 +- lang/ko/activities.php | 36 ++-- lang/nl/common.php | 2 +- lang/nl/entities.php | 4 +- lang/nl/errors.php | 2 +- lang/nl/settings.php | 2 +- lang/pl/auth.php | 2 +- lang/pl/common.php | 2 +- lang/pl/entities.php | 4 +- lang/pl/errors.php | 4 +- lang/pt_BR/editor.php | 2 +- lang/pt_BR/entities.php | 124 +++++------ lang/tk/activities.php | 124 +++++++++++ lang/tk/auth.php | 117 ++++++++++ lang/tk/common.php | 113 ++++++++++ lang/tk/components.php | 46 ++++ lang/tk/editor.php | 177 +++++++++++++++ lang/tk/entities.php | 439 ++++++++++++++++++++++++++++++++++++++ lang/tk/errors.php | 121 +++++++++++ lang/tk/notifications.php | 27 +++ lang/tk/pagination.php | 12 ++ lang/tk/passwords.php | 15 ++ lang/tk/preferences.php | 51 +++++ lang/tk/settings.php | 338 +++++++++++++++++++++++++++++ lang/tk/validation.php | 117 ++++++++++ lang/vi/activities.php | 2 +- lang/vi/entities.php | 8 +- 53 files changed, 2219 insertions(+), 522 deletions(-) create mode 100644 lang/tk/activities.php create mode 100644 lang/tk/auth.php create mode 100644 lang/tk/common.php create mode 100644 lang/tk/components.php create mode 100644 lang/tk/editor.php create mode 100644 lang/tk/entities.php create mode 100644 lang/tk/errors.php create mode 100644 lang/tk/notifications.php create mode 100644 lang/tk/pagination.php create mode 100644 lang/tk/passwords.php create mode 100644 lang/tk/preferences.php create mode 100644 lang/tk/settings.php create mode 100644 lang/tk/validation.php diff --git a/lang/ar/auth.php b/lang/ar/auth.php index fb434699e2d..0d3b12f8fc6 100644 --- a/lang/ar/auth.php +++ b/lang/ar/auth.php @@ -89,8 +89,8 @@ 'mfa_setup_action' => 'إعداد (تنصيب)', 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', 'mfa_option_totp_title' => 'تطبيق الجوال', - 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_option_backup_codes_title' => 'Backup Codes', + 'mfa_option_totp_desc' => 'لاستخدام المصادقة المتعددة العوامل، ستحتاج إلى تطبيق محمول يدعم TOTP مثل Google Authenticator أو Authy أو Microsoft Authenticer.', + 'mfa_option_backup_codes_title' => 'رموز النسخ الاحتياطي', 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', diff --git a/lang/cs/common.php b/lang/cs/common.php index 1e20b17e7dc..66ad222792d 100644 --- a/lang/cs/common.php +++ b/lang/cs/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Podmínky služby', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Vyhledat :appName', ]; diff --git a/lang/cs/entities.php b/lang/cs/entities.php index c73dd6f1f11..6122fe6a445 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Vytvořený obsah)', 'pages_edit_switch_to_markdown_stable' => '(Stabilní obsah)', 'pages_edit_switch_to_wysiwyg' => 'Přepnout na WYSIWYG Editor', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Přepnout na nový WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(V alfa testování)', 'pages_edit_set_changelog' => 'Nastavit protokol změn', 'pages_edit_enter_changelog_desc' => 'Zadejte stručný popis změn, které jste provedli', 'pages_edit_enter_changelog' => 'Zadejte protokol změn', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index a1efdadb7ae..e23f1032b7d 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Nemůžete odstranit posledního administrátora', 'users_cannot_delete_guest' => 'Uživatele Host není možno odstranit', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Nebylo možné vytvořit uživatele, protože se nepodařilo odeslat email s pozvánkou', // Roles 'role_cannot_be_edited' => 'Tuto roli nelze editovat', diff --git a/lang/et/common.php b/lang/et/common.php index a53060a00e6..3eb6b50b96e 100644 --- a/lang/et/common.php +++ b/lang/et/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Kasutustingimused', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Otsi :appName', ]; diff --git a/lang/et/entities.php b/lang/et/entities.php index 0d90dd32f3c..dd875a6f2da 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Puhas sisu)', 'pages_edit_switch_to_markdown_stable' => '(Stabiilne sisu)', 'pages_edit_switch_to_wysiwyg' => 'Kasuta WYSIWYG redaktorit', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Kasuta uut tekstiredaktorit', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa-testimisel)', 'pages_edit_set_changelog' => 'Muudatuste logi', 'pages_edit_enter_changelog_desc' => 'Sisesta tehtud muudatuste lühikirjeldus', 'pages_edit_enter_changelog' => 'Salvesta muudatuste logi', diff --git a/lang/et/errors.php b/lang/et/errors.php index a4c3cf2bdcb..732c945295f 100644 --- a/lang/et/errors.php +++ b/lang/et/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Ainsat administraatorit ei saa kustutada', 'users_cannot_delete_guest' => 'Külaliskasutajat ei saa kustutada', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Kasutajat ei saadud luua, kuna kutse e-kirja saatmine ebaõnnestus', // Roles 'role_cannot_be_edited' => 'Seda rolli ei saa muuta', diff --git a/lang/fr/common.php b/lang/fr/common.php index 282a72e4569..0abff99542a 100644 --- a/lang/fr/common.php +++ b/lang/fr/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Conditions d\'utilisation', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Recherche :appName', ]; diff --git a/lang/fr/entities.php b/lang/fr/entities.php index 1f826bb7ee7..b92ddac0ac7 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Contenu nettoyé)', 'pages_edit_switch_to_markdown_stable' => '(Contenu stable)', 'pages_edit_switch_to_wysiwyg' => 'Basculer vers l\'éditeur WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Basculer vers le nouveau WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(En test Alpha)', 'pages_edit_set_changelog' => 'Remplir le journal des changements', 'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués', 'pages_edit_enter_changelog' => 'Ouvrir le journal des changements', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index f2c9b125f37..7658fd8a982 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Vous ne pouvez pas supprimer le dernier administrateur', 'users_cannot_delete_guest' => 'Vous ne pouvez pas supprimer l\'utilisateur invité', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Impossible de créer l\'utilisateur à cause d\'une erreur d\'envoi de l\'email d\'invitation', // Roles 'role_cannot_be_edited' => 'Ce rôle ne peut pas être modifié', diff --git a/lang/he/activities.php b/lang/he/activities.php index 8ce859127a5..75ab9b3ea28 100644 --- a/lang/he/activities.php +++ b/lang/he/activities.php @@ -62,61 +62,61 @@ 'watch_update_level_notification' => 'העדפות צפייה עודכנו בהצלחה', // Auth - 'auth_login' => 'logged in', - 'auth_register' => 'registered as new user', - 'auth_password_reset_request' => 'requested user password reset', - 'auth_password_reset_update' => 'reset user password', - 'mfa_setup_method' => 'configured MFA method', - 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', - 'mfa_remove_method' => 'removed MFA method', - 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + 'auth_login' => 'מחובר', + 'auth_register' => 'נרשם כמשתמש חדש', + 'auth_password_reset_request' => 'בקשת איפוס סיסמה למשתמש בוצעה בהצלחה', + 'auth_password_reset_update' => 'איפוס סיסמה למשתמש', + 'mfa_setup_method' => 'הגדרת אימות דו-שלבי פעיל', + 'mfa_setup_method_notification' => 'הגדרת אימות דו-שלבי בוצע בהצלחה', + 'mfa_remove_method' => 'הגדרת אימות דו-שלבי הוסר', + 'mfa_remove_method_notification' => 'אפשרות אימות דו-שלבי הוסר בהצלחה', // Settings - 'settings_update' => 'updated settings', - 'settings_update_notification' => 'Settings successfully updated', - 'maintenance_action_run' => 'ran maintenance action', + 'settings_update' => 'הגדרות עודכנו בהצלחה', + 'settings_update_notification' => 'ההגדרות עודכנו בהצלחה', + 'maintenance_action_run' => 'פעולות תחזוקה שהופעלו', // Webhooks - 'webhook_create' => 'created webhook', - 'webhook_create_notification' => 'Webhook נוצר בהצלחה', - 'webhook_update' => 'updated webhook', - 'webhook_update_notification' => 'Webhook successfully updated', - 'webhook_delete' => 'deleted webhook', - 'webhook_delete_notification' => 'Webhook successfully deleted', + 'webhook_create' => 'webook נוצר', + 'webhook_create_notification' => 'יצירת Webhook בוצעה בהצלחה', + 'webhook_update' => 'webhook עודכן', + 'webhook_update_notification' => 'webook עודכן בהצלחה', + 'webhook_delete' => 'Webhook נמחק', + 'webhook_delete_notification' => 'Webook נמחק בהצלחה', // Users - 'user_create' => 'created user', - 'user_create_notification' => 'User successfully created', - 'user_update' => 'updated user', + 'user_create' => 'משתמש חדש נוצר', + 'user_create_notification' => 'משתמש נוצר בהצלחה', + 'user_update' => 'משתמש עודכן', 'user_update_notification' => 'משתמש עודכן בהצלחה', - 'user_delete' => 'deleted user', + 'user_delete' => 'משתמש נמחק', 'user_delete_notification' => 'משתמש הוסר בהצלחה', // API Tokens - 'api_token_create' => 'created API token', - 'api_token_create_notification' => 'API token successfully created', - 'api_token_update' => 'updated API token', - 'api_token_update_notification' => 'API token successfully updated', - 'api_token_delete' => 'deleted API token', - 'api_token_delete_notification' => 'API token successfully deleted', + 'api_token_create' => 'API Token נוצר', + 'api_token_create_notification' => 'API Token נוצר בהצלחה', + 'api_token_update' => 'API Token עודכן', + 'api_token_update_notification' => 'API Token עודכן בהצלחה', + 'api_token_delete' => 'API Token נמחק', + 'api_token_delete_notification' => 'API Token נמחק בהצלחה', // Roles - 'role_create' => 'created role', + 'role_create' => 'תפקיד נוצר', 'role_create_notification' => 'תפקיד נוצר בהצלחה', - 'role_update' => 'updated role', + 'role_update' => 'תפקיד עודכן', 'role_update_notification' => 'תפקיד עודכן בהצלחה', - 'role_delete' => 'deleted role', + 'role_delete' => 'תפקיד נמחק', 'role_delete_notification' => 'תפקיד נמחק בהצלחה', // Recycle Bin - 'recycle_bin_empty' => 'emptied recycle bin', - 'recycle_bin_restore' => 'restored from recycle bin', - 'recycle_bin_destroy' => 'removed from recycle bin', + 'recycle_bin_empty' => 'סל המחזור רוקן', + 'recycle_bin_restore' => 'שוחזר מסל המחזור', + 'recycle_bin_destroy' => 'נמחק מסל המחזור', // Comments 'commented_on' => 'הגיב/ה על', - 'comment_create' => 'added comment', - 'comment_update' => 'updated comment', + 'comment_create' => 'הערה הוספה', + 'comment_update' => 'תגובה הוספה', 'comment_delete' => 'תגובה נמחקה', // Other diff --git a/lang/he/auth.php b/lang/he/auth.php index 04045e35758..017602b4f2a 100644 --- a/lang/he/auth.php +++ b/lang/he/auth.php @@ -6,12 +6,12 @@ */ return [ - 'failed' => 'פרטי ההתחברות אינם תואמים את הנתונים שלנו', - 'throttle' => 'נסיונות התחברות רבים מדי, יש להמתין :seconds שניות ולנסות שנית.', + 'failed' => 'פרטי ההתחברות אינם תואמים את הנתונים שלנו.', + 'throttle' => 'נסיונות התחברות מהירים מדי, יש להמתין :seconds שניות ולנסות שנית.', // Login & Register - 'sign_up' => 'הרשמה', - 'log_in' => 'התחבר', + 'sign_up' => 'הרשמה למערכת', + 'log_in' => 'התחבר למערכת', 'log_in_with' => 'התחבר באמצעות :socialDriver', 'sign_up_with' => 'הרשם באמצעות :socialDriver', 'logout' => 'התנתק', @@ -72,40 +72,40 @@ // User Invite 'user_invite_email_subject' => 'הוזמנת להצטרף ל:appName!', - 'user_invite_email_greeting' => 'An account has been created for you on :appName.', + 'user_invite_email_greeting' => 'חשבון נוצר עבורך ב :appName.', 'user_invite_email_text' => 'לחץ על הכפתור מטה בכדי להגדיר סיסמת משתמש ולקבל גישה:', 'user_invite_email_action' => 'הגדר סיסמה לחשבון', - 'user_invite_page_welcome' => 'Welcome to :appName!', - 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', + 'user_invite_page_welcome' => 'ברוכים הבאים ל :appName!', + 'user_invite_page_text' => 'על מנת לסיים את ההרשמה ולקבל גישה עלייך להגדיר סיסמה אשר תהיה בשימוש בהתחברות ל :appName בביקורים עתידיים.', 'user_invite_page_confirm_button' => 'אימות סיסמא', - 'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!', + 'user_invite_success_login' => 'הסיסמה הוגדרה בהצלחה, כעת תוכלו לקבל גישה ל :appName!', // Multi-factor Authentication 'mfa_setup' => 'הגדר אימות רב-שלבי', 'mfa_setup_desc' => 'הגדר אימות רב-שלבי כשכבת אבטחה נוספת עבור החשבון שלך.', 'mfa_setup_configured' => 'כבר הוגדר', 'mfa_setup_reconfigure' => 'הגדר מחדש', - 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', - 'mfa_setup_action' => 'Setup', + 'mfa_setup_remove_confirmation' => 'האם להסיר את אפשרות האימות הדו-שלבי הזאת?', + 'mfa_setup_action' => 'הגדרה', 'mfa_backup_codes_usage_limit_warning' => 'נשאר לך פחות מ 5 קודי גיבוי, בבקשה חולל ואחסן סט חדש לפני שיגמרו לך הקודים בכדי למנוע נעילה מחוץ לחשבון שלך.', 'mfa_option_totp_title' => 'אפליקציה לנייד', 'mfa_option_totp_desc' => 'בכדי להשתמש באימות רב-שלבי תצטרך אפליקציית מובייל תומכת TOTP כמו Google Authenticator, Authy או Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'קודי גיבוי', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', - 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', - 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', - 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', - 'mfa_gen_backup_codes_download' => 'Download Codes', - 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', - 'mfa_gen_totp_title' => 'Mobile App Setup', - 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', - 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', - 'mfa_gen_totp_verify_setup' => 'Verify Setup', - 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', - 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', - 'mfa_verify_access' => 'Verify Access', - 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', - 'mfa_verify_no_methods' => 'No Methods Configured', + 'mfa_option_backup_codes_desc' => 'יצירת מערך סיסמאות חד-פעמיות כגיבוי אשר תתקבשו להזין על מנת לאמת את הזהות שלכם, אנא וודאו כי הקודים האלו שמורים במקום בטוח.', + 'mfa_gen_confirm_and_enable' => 'אישור והפעלה', + 'mfa_gen_backup_codes_title' => 'הגדרת קודי גיבוי', + 'mfa_gen_backup_codes_desc' => 'אנא שמור את רשימת הקודים הרשומים מטה במקום בטוח. בגישה למערכת תהיה אפשרות להשתמש באחד הקודים הללו כאמצעי זיהוי נוסף.', + 'mfa_gen_backup_codes_download' => 'הורדת קודים', + 'mfa_gen_backup_codes_usage_warning' => 'ניתן להשתמש בכל קוד פעם אחת בלבד', + 'mfa_gen_totp_title' => 'הגדרת אפליקציה לזיהוי', + 'mfa_gen_totp_desc' => 'על מנת להגדיר זיהוי דו-שלבי במכשיר נייד עלייך להשתמש באפליקציה התומכת ב TOTP כגון Google Authenticator, Authy או Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'סרוק את קוד ה QR באמצעות האפליקציה שבה מתבצע הזיהוי על מנת להתחיל.', + 'mfa_gen_totp_verify_setup' => 'אשר את ההגדרה', + 'mfa_gen_totp_verify_setup_desc' => 'על מנת לוודא כי הזיהוי הדו-שלבי עובד יש להכניס את הקוד, הוא מופיע לך על מסך האפליקציה, בשדה מטה:', + 'mfa_gen_totp_provide_code_here' => 'אנא הכנס את הקוד כאן', + 'mfa_verify_access' => 'אשר גישה', + 'mfa_verify_access_desc' => 'חשבון המשתמש שלך דורש ממך לאת את הזהות שלך בשכבת הגנה נוספת על מנת לאפשר לך גישה. יש לאשר גישה דרך אחד האמצעים הקיימים על מנת להמשיך.', + 'mfa_verify_no_methods' => 'אין אפשרויות אימות דו-שלבי מוגדרות', 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', 'mfa_verify_use_totp' => 'Verify using a mobile app', 'mfa_verify_use_backup_codes' => 'Verify using a backup code', diff --git a/lang/he/common.php b/lang/he/common.php index a427df954fe..4e4483ccf01 100644 --- a/lang/he/common.php +++ b/lang/he/common.php @@ -20,7 +20,7 @@ 'description' => 'תיאור', 'role' => 'תפקיד', 'cover_image' => 'תמונת נושא', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'התמונה צריכה להיות לפחות בגודל של 440 על 250 למרות שהיא תוצג בצורה דינמית ותותאם בהתאם להתאים לממשק המשתמש במצבים שונים על פי הדרישה, ולכן המימדים יהיו שונים.', // Actions 'actions' => 'פעולות', @@ -52,7 +52,7 @@ 'filter_clear' => 'נקה מסננים', 'download' => 'הורדה', 'open_in_tab' => 'פתח בכרטיסייה חדשה', - 'open' => 'Open', + 'open' => 'פתח', // Sort Options 'sort_options' => 'אפשרויות מיון', @@ -76,7 +76,7 @@ 'grid_view' => 'תצוגת רשת', 'list_view' => 'תצוגת רשימה', 'default' => 'ברירת מחדל', - 'breadcrumb' => 'Breadcrumb', + 'breadcrumb' => 'סימון מסלול', 'status' => 'סטטוס', 'status_active' => 'פעיל', 'status_inactive' => 'לא פעיל', @@ -109,5 +109,5 @@ 'terms_of_service' => 'תנאי שימוש', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'חפש :appName', ]; diff --git a/lang/he/components.php b/lang/he/components.php index 9e075d89da9..30b9ab01a09 100644 --- a/lang/he/components.php +++ b/lang/he/components.php @@ -6,36 +6,36 @@ // Image Manager 'image_select' => 'בחירת תמונה', - 'image_list' => 'Image List', - 'image_details' => 'Image Details', - 'image_upload' => 'Upload Image', - 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', - 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_list' => 'רשימת תמונות', + 'image_details' => 'פרטי תמונה', + 'image_upload' => 'העלאת תמונה', + 'image_intro' => 'כאן ניתן לבחור ולנהל תמונות אשר הועלו למערכת.', + 'image_intro_upload' => 'ניתן לגרור תמונות לחלון זה, או על ידי לחיצה על כפתור "העלאת תמונות" למעלה.', 'image_all' => 'הכל', 'image_all_title' => 'הצג את כל התמונות', 'image_book_title' => 'הצג תמונות שהועלו לספר זה', 'image_page_title' => 'הצג תמונות שהועלו לדף זה', 'image_search_hint' => 'חפש תמונה לפי שם', 'image_uploaded' => 'הועלה :uploadedDate', - 'image_uploaded_by' => 'Uploaded by :userName', - 'image_uploaded_to' => 'Uploaded to :pageLink', - 'image_updated' => 'Updated :updateDate', + 'image_uploaded_by' => 'הועלה על ידי :userName', + 'image_uploaded_to' => 'הועלה ל :pageLink', + 'image_updated' => 'עודכן :updateDate', 'image_load_more' => 'טען עוד', 'image_image_name' => 'שם התמונה', 'image_delete_used' => 'תמונה זו בשימוש בדפים שמתחת', 'image_delete_confirm_text' => 'האם את/ה בטוח/ה שברצונך למחוק את התמונה הזו?', 'image_select_image' => 'בחר תמונה', 'image_dropzone' => 'גרור תמונות או לחץ כאן להעלאה', - 'image_dropzone_drop' => 'Drop images here to upload', + 'image_dropzone_drop' => 'גרור תמונות לכאן על מנת להעלאות אותם', 'images_deleted' => 'התמונות נמחקו', 'image_preview' => 'תצוגה מקדימה', 'image_upload_success' => 'התמונה עלתה בהצלחה', 'image_update_success' => 'פרטי התמונה עודכנו בהצלחה', 'image_delete_success' => 'התמונה נמחקה בהצלחה', - 'image_replace' => 'Replace Image', - 'image_replace_success' => 'Image file successfully updated', - 'image_rebuild_thumbs' => 'Regenerate Size Variations', - 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + 'image_replace' => 'החלפת תמונה', + 'image_replace_success' => 'תמונה עודכנה בהצלחה', + 'image_rebuild_thumbs' => 'יצר ווריאציות גודל מחדש', + 'image_rebuild_thumbs_success' => 'ווריאציות גודל תמונה יוצרו מחדש בהצלחה!', // Code Editor 'code_editor' => 'ערוך קוד', diff --git a/lang/he/passwords.php b/lang/he/passwords.php index a94d7c30b36..3909680e3d4 100644 --- a/lang/he/passwords.php +++ b/lang/he/passwords.php @@ -6,10 +6,10 @@ */ return [ - 'password' => 'הסיסמה חייבת להיות בעלת 8 תווים לפחות ולהתאים לאימות.', - 'user' => "לא נמצא משתמש עם כתובת דוא\"ל זו.", - 'token' => 'אסימון איפוס הסיסמה לא תקף עבור כתובת דוא"ל זו.', - 'sent' => 'שלחנו לך דוא"ל עם קישור לאיפוס הסיסמה!', + 'password' => 'הסיסמא חייבת להיות בעלת 8 תווים לפחות ולהתאים לאימות.', + 'user' => "לא נמצא משתמש קיים עם כתובת דוא\"ל זו.", + 'token' => 'הקישור לאיפוס הסיסמה לא תקף עבור כתובת דוא"ל זו.', + 'sent' => 'שלחנו לך דוא"ל עם קישור לאיפוס הסיסמא!', 'reset' => 'איפוס הסיסמה הושלם בהצלחה!', ]; diff --git a/lang/he/preferences.php b/lang/he/preferences.php index f0f997f26d2..9e05eefb3ab 100644 --- a/lang/he/preferences.php +++ b/lang/he/preferences.php @@ -5,10 +5,10 @@ */ return [ - 'my_account' => 'My Account', + 'my_account' => 'החשבון שלי', 'shortcuts' => 'קיצורי דרך', - 'shortcuts_interface' => 'UI Shortcut Preferences', + 'shortcuts_interface' => 'העדפות קיצורי ממשק משתמש', 'shortcuts_toggle_desc' => 'כאן תוכל להפעיל או לבטל קיצורי דרך לממשק מערכת המקלדת, המשמשים לניווט ולפעולות.', 'shortcuts_customize_desc' => 'אתה יכול להתאים אישית כל אחד מקיצורי הדרך למטה. פשוט לחץ על צירוף המקשים הרצוי לאחר בחירת הקלט לקיצור דרך.', 'shortcuts_toggle_label' => 'קיצורי מקשים מופעלים', @@ -17,35 +17,35 @@ 'shortcuts_save' => 'שמור קיצורי דרך', 'shortcuts_overlay_desc' => 'הערה: כאשר קיצורי דרך מופעלים, שכבת-על מסייעת זמינה באמצעות לחיצה על "?" אשר ידגיש את קיצורי הדרך הזמינים לפעולות הנראות כעת על המסך.', 'shortcuts_update_success' => 'העדפותיך נשמרו!', - 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + 'shortcuts_overview_desc' => 'ניהול קיצורי מקלדת שבשימוש לניווט מהיר בממשק המשתמש.', - 'notifications' => 'Notification Preferences', - 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', - 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', - 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', - 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', - 'notifications_save' => 'Save Preferences', - 'notifications_update_success' => 'Notification preferences have been updated!', - 'notifications_watched' => 'Watched & Ignored Items', - 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + 'notifications' => 'הגדרת התראות', + 'notifications_desc' => 'העדפות קבלת מייל והתראות כאשר מבוצעת פעולה מסויימת במערכת.', + 'notifications_opt_own_page_changes' => 'עדכן אותי כאשר מתבצעים שינויים לדפים שבבעלותי', + 'notifications_opt_own_page_comments' => 'עדכן אותי כאשר נוספות הערות לדפים שבבעלותי', + 'notifications_opt_comment_replies' => 'עדכן אותי כאשר מתקבלות תגובות להערות שלי', + 'notifications_save' => 'שמור העדפות', + 'notifications_update_success' => 'הגדרת התראות עודכנו!', + 'notifications_watched' => 'פריטים נצפים וברשימת התעלמות', + 'notifications_watched_desc' => 'להן רשימת הפריטים אשר קיימת עבורם הגדרות מותאמות אישית. על מנת לעדכן רשימה זו, צפה בפריט ומצא את אפשרות הצפייה / מעקב בפאנל הצידי.', - 'auth' => 'Access & Security', - 'auth_change_password' => 'Change Password', - 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', - 'auth_change_password_success' => 'Password has been updated!', + 'auth' => 'גישה ואבטחה', + 'auth_change_password' => 'שינוי סיסמה', + 'auth_change_password_desc' => 'שינוי הסיסמה לכניסה למערכת. הסיסמה חייבת להיות לפחות 8 תווים.', + 'auth_change_password_success' => 'הסיסמה עודכנה בהצלחה!', - 'profile' => 'Profile Details', - 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', - 'profile_view_public' => 'View Public Profile', - 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', - 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', - 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', - 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', - 'profile_admin_options' => 'Administrator Options', - 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + 'profile' => 'פרטי הפרופיל', + 'profile_desc' => 'נהל את פרטי החשבון שלך אשר יוצגו לאחרים במערכת, בנוסף לפרטים אשר נועדו להתאמה אישית במערכת ויצירת קשר.', + 'profile_view_public' => 'צפה בפרופיל הציבורי', + 'profile_name_desc' => 'הגדר את שם התצוגה שלך אשר יהיה זמין לצפייה לשאר משתמשי המערכת ובכל פעולה אשר תבצע במערכת, ובתוכן אשר שייך לך.', + 'profile_email_desc' => 'כתובת הדוא"ל תשמש להתראות, ובהתאם למערכות ההזדהות הפעילות לגישה למערכת.', + 'profile_email_no_permission' => 'לצערנו אין לך גישה לביצוע שינוי כתובת הדוא"ל שלך, אם ברצונך לבצע שינוי בכתובת אנא פנה למנהל המערכת על מנת שיבצע שינוי זה עבורך.', + 'profile_avatar_desc' => 'אנא בחר תמונה שתייצג אותך כלפי אחרים במערכת, תמונה אידיאלית צריכה להיות בגובה ואורך של 256 פיקסלים.', + 'profile_admin_options' => 'אפשרויות מנהל', + 'profile_admin_options_desc' => 'אפשרויות ברמת מנהל נוספות, כגון הוספת תפקידים והרשאות למשתמשים, אפשר למצוא עבור המשתמש שלך באיזור "הגדרות > משתמשים" במערכת.', - 'delete_account' => 'Delete Account', - 'delete_my_account' => 'Delete My Account', - 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', - 'delete_my_account_warning' => 'Are you sure you want to delete your account?', + 'delete_account' => 'הסר חשבון', + 'delete_my_account' => 'הסר את החשבון שלי', + 'delete_my_account_desc' => 'פעולה זו תמחק את החשבון שלך כולל כל ההגדרות עבור החשבון שלך מהמערכת. אין דרך לשחזר את החשבון לאחר המחיקה. כל תוכן שיצרת כולל קבצים שהעלת לאתר ישארו פעילים.', + 'delete_my_account_warning' => 'הפעולה אינה ניתנת לביטול, האם למחוק את החשבון?', ]; diff --git a/lang/he/settings.php b/lang/he/settings.php index 3ad9da5ffbf..bf585287cf8 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -9,8 +9,8 @@ // Common Messages 'settings' => 'הגדרות', 'settings_save' => 'שמור הגדרות', - 'system_version' => 'System Version', - 'categories' => 'Categories', + 'system_version' => 'גירסת מערכת', + 'categories' => 'קטגוריות', // App Settings 'app_customization' => 'התאמה אישית', @@ -26,7 +26,7 @@ 'app_secure_images' => 'העלאת תמונות מאובטחת', 'app_secure_images_toggle' => 'אפשר העלאת תמונות מאובטחת', 'app_secure_images_desc' => 'משיקולי ביצועים, כל התמונות הינן ציבוריות. אפשרות זו מוסיפה מחרוזת אקראית שקשה לנחש לכל כתובת של תמונה. אנא ודא שאפשרות הצגת תוכן התיקייה מבוטל.', - 'app_default_editor' => 'Default Page Editor', + 'app_default_editor' => 'עורך דפים ברירת מחדל', 'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.', 'app_custom_html' => 'HTML מותאם אישית לחלק העליון', 'app_custom_html_desc' => 'כל קוד שיתווסף כאן, יופיע בתחתית תגית ה head של כל דף. חלק זה שימושי על מנת להגדיר עיצובי CSS והתקנת קוד Analytics', diff --git a/lang/it/activities.php b/lang/it/activities.php index 39b6859e422..61a22518551 100644 --- a/lang/it/activities.php +++ b/lang/it/activities.php @@ -31,19 +31,19 @@ 'book_create' => 'ha creato il libro', 'book_create_notification' => 'Libro creato con successo', 'book_create_from_chapter' => 'ha convertito da capitolo a libro', - 'book_create_from_chapter_notification' => 'Capitolo convertito con successo in libro', + 'book_create_from_chapter_notification' => 'Capitolo convertito in libro con successo', 'book_update' => 'ha aggiornato il libro', 'book_update_notification' => 'Libro aggiornato con successo', 'book_delete' => 'ha eliminato il libro', 'book_delete_notification' => 'Libro eliminato con successo', 'book_sort' => 'ha ordinato il libro', - 'book_sort_notification' => 'Libro reindicizzato con successo', + 'book_sort_notification' => 'Libro riordinato con successo', // Bookshelves 'bookshelf_create' => 'ha creato la libreria', 'bookshelf_create_notification' => 'Libreria creata con successo', - 'bookshelf_create_from_book' => 'ha convertito libro in libreria', - 'bookshelf_create_from_book_notification' => 'Libro convertito con successo in libreria', + 'bookshelf_create_from_book' => 'ha convertito il libro in libreria', + 'bookshelf_create_from_book_notification' => 'Libro convertito in libreria con successo', 'bookshelf_update' => 'ha aggiornato la libreria', 'bookshelf_update_notification' => 'Libreria aggiornata con successo', 'bookshelf_delete' => 'ha eliminato la libreria', @@ -72,53 +72,53 @@ 'mfa_remove_method_notification' => 'Metodo multi-fattore rimosso con successo', // Settings - 'settings_update' => 'impostazioni aggiornate', + 'settings_update' => 'ha aggiornato le impostazioni', 'settings_update_notification' => 'Impostazioni aggiornate con successo', - 'maintenance_action_run' => 'eseguita azione di manutenzione', + 'maintenance_action_run' => 'ha eseguito un\'azione di manutenzione', // Webhooks - 'webhook_create' => 'webhook creato', + 'webhook_create' => 'ha creato un webhook', 'webhook_create_notification' => 'Webhook creato con successo', - 'webhook_update' => 'webhook aggiornato', + 'webhook_update' => 'ha aggiornato un webhook', 'webhook_update_notification' => 'Webhook aggiornato con successo', - 'webhook_delete' => 'webhook eliminato', + 'webhook_delete' => 'ha eliminato un webhook', 'webhook_delete_notification' => 'Webhook eliminato con successo', // Users - 'user_create' => 'utente creato', + 'user_create' => 'ha creato un utente', 'user_create_notification' => 'Utente creato con successo', - 'user_update' => 'utente aggiornato', + 'user_update' => 'ha aggiornato un utente', 'user_update_notification' => 'Utente aggiornato con successo', - 'user_delete' => 'utente eliminato', + 'user_delete' => 'ha eliminato un utente', 'user_delete_notification' => 'Utente rimosso con successo', // API Tokens - 'api_token_create' => 'token API creato', + 'api_token_create' => 'ha creato un token API', 'api_token_create_notification' => 'Token API creato con successo', - 'api_token_update' => 'token API aggiornato', + 'api_token_update' => 'ha aggiornato un token API', 'api_token_update_notification' => 'Token API aggiornato correttamente', - 'api_token_delete' => 'token API eliminato', + 'api_token_delete' => 'ha eliminato token API', 'api_token_delete_notification' => 'Token API eliminato con successo', // Roles - 'role_create' => 'creato ruolo', + 'role_create' => 'ha creato un ruolo', 'role_create_notification' => 'Ruolo creato con successo', - 'role_update' => 'aggiornato ruolo', + 'role_update' => 'ha aggiornato un ruolo', 'role_update_notification' => 'Ruolo aggiornato con successo', - 'role_delete' => 'eliminato ruolo', + 'role_delete' => 'ha eliminato un ruolo', 'role_delete_notification' => 'Ruolo eliminato con successo', // Recycle Bin - 'recycle_bin_empty' => 'cestino svuotato', - 'recycle_bin_restore' => 'ripristinato dal cestino', - 'recycle_bin_destroy' => 'rimosso dal cestino', + 'recycle_bin_empty' => 'ha svuotato il cestino', + 'recycle_bin_restore' => 'ha ripristinato dal cestino', + 'recycle_bin_destroy' => 'ha rimosso dal cestino', // Comments 'commented_on' => 'ha commentato in', - 'comment_create' => 'commento aggiunto', - 'comment_update' => 'commento aggiornato', - 'comment_delete' => 'commento rimosso', + 'comment_create' => 'ha aggiunto un commento', + 'comment_update' => 'ha aggiornato un commento', + 'comment_delete' => 'ha rimosso un commento', // Other - 'permissions_update' => 'autorizzazioni aggiornate', + 'permissions_update' => 'ha aggiornate le autorizzazioni', ]; diff --git a/lang/it/auth.php b/lang/it/auth.php index 94f1ebd53b0..9191ecba794 100644 --- a/lang/it/auth.php +++ b/lang/it/auth.php @@ -20,11 +20,11 @@ 'username' => 'Username', 'email' => 'Email', 'password' => 'Password', - 'password_confirm' => 'Conferma Password', + 'password_confirm' => 'Conferma password', 'password_hint' => 'Deve essere lunga almeno 8 caratteri', 'forgot_password' => 'Password dimenticata?', 'remember_me' => 'Ricordami', - 'ldap_email_hint' => 'Inserisci un email per usare quest\'account.', + 'ldap_email_hint' => 'Inserisci un\'email per usare quest\'account.', 'create_account' => 'Crea un account', 'already_have_account' => 'Hai già un account?', 'dont_have_account' => 'Non hai un account?', @@ -34,54 +34,54 @@ 'register_thanks' => 'Grazie per esserti registrato!', 'register_confirm' => 'Controlla la tua mail e clicca il pulsante di conferma per accedere a :appName.', - 'registrations_disabled' => 'La registrazione è disabilitata', - 'registration_email_domain_invalid' => 'Questo dominio della mail non ha accesso a questa applicazione', - 'register_success' => 'Grazie per la registrazione! Sei registrato e loggato.', + 'registrations_disabled' => 'La registrazione è disabilitata al momento', + 'registration_email_domain_invalid' => 'Questo dominio email non ha accesso a questa applicazione', + 'register_success' => 'Grazie per la registrazione! Sei registrato e connesso.', // Login auto-initiation 'auto_init_starting' => 'Tentativo di accesso', - 'auto_init_starting_desc' => 'Stiamo contattando il vostro sistema di autenticazione per avviare il processo di login. Se dopo 5 secondi non si verifica alcun progresso, si può provare a fare clic sul link sottostante.', + 'auto_init_starting_desc' => 'Stiamo contattando il tuo sistema di autenticazione per avviare il processo di login. Se non ci sono progressi dopo 5 secondi, puoi provare a cliccare sul link qui sotto.', 'auto_init_start_link' => 'Procedi con l\'autenticazione', // Password Reset - 'reset_password' => 'Reimposta Password', - 'reset_password_send_instructions' => 'Inserisci il tuo indirizzo sotto e ti verrà inviata una mail contenente un link per resettare la tua password.', - 'reset_password_send_button' => 'Invia Link Reset', - 'reset_password_sent' => 'Un link di reset della password verrà inviato a :email se la mail verrà trovata nel sistema.', - 'reset_password_success' => 'La tua password è stata resettata correttamente.', + 'reset_password' => 'Reimposta la password', + 'reset_password_send_instructions' => 'Inserisci il tuo indirizzo e ti verrà inviata una mail contenente un link per reimpostare la tua password.', + 'reset_password_send_button' => 'Invia link di ripristino', + 'reset_password_sent' => 'Se la mail verrà trovata nel sistema, verrà inviato a :email un link di ripristino della password.', + 'reset_password_success' => 'La tua password è stata ripristinata correttamente.', 'email_reset_subject' => 'Reimposta la password di :appName', - 'email_reset_text' => 'Stai ricevendo questa mail perché abbiamo ricevuto una richiesta di reset della password per il tuo account.', - 'email_reset_not_requested' => 'Se non hai richiesto un reset della password, ignora questa mail.', + 'email_reset_text' => 'Stai ricevendo questa mail perché abbiamo ricevuto una richiesta di ripristino della password per il tuo account.', + 'email_reset_not_requested' => 'Se non hai richiesto un ripristino della password, ignora questa mail.', // Email Confirmation 'email_confirm_subject' => 'Conferma email per :appName', 'email_confirm_greeting' => 'Grazie per esserti registrato a :appName!', 'email_confirm_text' => 'Conferma il tuo indirizzo email cliccando il pulsante sotto:', - 'email_confirm_action' => 'Conferma Email', + 'email_confirm_action' => 'Conferma email', 'email_confirm_send_error' => 'La conferma della mail è richiesta ma non è stato possibile mandare la mail. Contatta l\'amministratore.', 'email_confirm_success' => 'La tua email è stata confermata! Ora dovresti essere in grado di effettuare il login utilizzando questo indirizzo email.', 'email_confirm_resent' => 'Mail di conferma reinviata, controlla la tua posta.', 'email_confirm_thanks' => 'Grazie per la conferma!', - 'email_confirm_thanks_desc' => 'Attendere un momento mentre la conferma viene gestita. Se non si è reindirizzati dopo 3 secondi, premere il link "Continua" qui sotto per procedere.', + 'email_confirm_thanks_desc' => 'Attendi un momento mentre la conferma viene gestita. Se non vieni reindirizzato dopo 3 secondi, clicca sul link "Continua" qui sotto per procedere.', - 'email_not_confirmed' => 'Indirizzo Email Non Confermato', + 'email_not_confirmed' => 'Indirizzo email non confermato', 'email_not_confirmed_text' => 'Il tuo indirizzo email non è ancora stato confermato.', 'email_not_confirmed_click_link' => 'Clicca il link nella mail mandata subito dopo la tua registrazione.', 'email_not_confirmed_resend' => 'Se non riesci a trovare la mail puoi rimandarla cliccando il pulsante sotto.', - 'email_not_confirmed_resend_button' => 'Reinvia Conferma', + 'email_not_confirmed_resend_button' => 'Reinvia conferma', // User Invite 'user_invite_email_subject' => 'Sei stato invitato a unirti a :appName!', 'user_invite_email_greeting' => 'Un account è stato creato per te su :appName.', 'user_invite_email_text' => 'Clicca sul pulsante qui sotto per impostare una password e ottenere l\'accesso:', - 'user_invite_email_action' => 'Imposta Password', + 'user_invite_email_action' => 'Imposta password', 'user_invite_page_welcome' => 'Benvenuto in :appName!', - 'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso devi impostare una password che verrà utilizzata per accedere a :appName in futuro.', - 'user_invite_page_confirm_button' => 'Conferma Password', + 'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso, devi impostare una password che verrà utilizzata per accedere a :appName in futuro.', + 'user_invite_page_confirm_button' => 'Conferma password', 'user_invite_success_login' => 'Password impostata, ora dovresti essere in grado di effettuare il login utilizzando la password impostata per accedere a :appName!', // Multi-factor Authentication - 'mfa_setup' => 'Imposta Autenticazione Multi-Fattore', + 'mfa_setup' => 'Imposta autenticazione multi-fattore', 'mfa_setup_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.', 'mfa_setup_configured' => 'Già configurata', 'mfa_setup_reconfigure' => 'Riconfigura', @@ -97,7 +97,7 @@ 'mfa_gen_backup_codes_desc' => 'Conserva l\'elenco di codici qui sotto in un luogo sicuro. Quando accedi al sistema potrai utilizzare uno dei codici come meccanismo di autenticazione secondario.', 'mfa_gen_backup_codes_download' => 'Scarica codici', 'mfa_gen_backup_codes_usage_warning' => 'Ogni codice può essere utilizzato solo una volta', - 'mfa_gen_totp_title' => 'Impostazione App Mobile', + 'mfa_gen_totp_title' => 'Impostazione app mobile', 'mfa_gen_totp_desc' => 'Per utilizzare l\'autenticazione multi-fattore avrai bisogno di un\'applicazione mobile che supporti TOTP come Google Authenticator, Authy o Microsoft Authenticator.', 'mfa_gen_totp_scan' => 'Scansiona il codice QR qui sotto utilizzando la tua app di autenticazione preferita per iniziare.', 'mfa_gen_totp_verify_setup' => 'Verifica configurazione', diff --git a/lang/it/common.php b/lang/it/common.php index 113ddb01405..d45f93c5e42 100644 --- a/lang/it/common.php +++ b/lang/it/common.php @@ -31,7 +31,7 @@ 'update' => 'Aggiorna', 'edit' => 'Modifica', 'sort' => 'Ordina', - 'move' => 'Muovi', + 'move' => 'Sposta', 'copy' => 'Copia', 'reply' => 'Rispondi', 'delete' => 'Elimina', @@ -50,31 +50,31 @@ 'previous' => 'Precedente', 'filter_active' => 'Filtro attivo:', 'filter_clear' => 'Pulisci filtro', - 'download' => 'Download', + 'download' => 'Scarica', 'open_in_tab' => 'Apri nella scheda', 'open' => 'Apri', // Sort Options - 'sort_options' => 'Opzioni Ordinamento', + 'sort_options' => 'Opzioni ordinamento', 'sort_direction_toggle' => 'Inverti direzione ordinamento', - 'sort_ascending' => 'Ordine ascendente', - 'sort_descending' => 'Ordine discendente', + 'sort_ascending' => 'Ordine crescente', + 'sort_descending' => 'Ordine decrescente', 'sort_name' => 'Nome', 'sort_default' => 'Predefinito', - 'sort_created_at' => 'Data Creazione', - 'sort_updated_at' => 'Data Aggiornamento', + 'sort_created_at' => 'Data creazione', + 'sort_updated_at' => 'Data aggiornamento', // Misc - 'deleted_user' => 'Utente Eliminato', + 'deleted_user' => 'Utente eliminato', 'no_activity' => 'Nessuna attività da mostrare', 'no_items' => 'Nessun elemento disponibile', 'back_to_top' => 'Torna in alto', 'skip_to_main_content' => 'Passa al contenuto principale', - 'toggle_details' => 'Mostra Dettagli', - 'toggle_thumbnails' => 'Mostra Miniature', + 'toggle_details' => 'Mostra dettagli', + 'toggle_thumbnails' => 'Mostra miniature', 'details' => 'Dettagli', - 'grid_view' => 'Visualizzazione Griglia', - 'list_view' => 'Visualizzazione Lista', + 'grid_view' => 'Visualizzazione griglia', + 'list_view' => 'Visualizzazione lista', 'default' => 'Predefinito', 'breadcrumb' => 'Navigazione', 'status' => 'Stato', @@ -85,29 +85,29 @@ // Header 'homepage' => 'Homepage', - 'header_menu_expand' => 'Espandi Menù Intestazione', + 'header_menu_expand' => 'Espandi menù intestazione', 'profile_menu' => 'Menù del profilo', - 'view_profile' => 'Visualizza Profilo', - 'edit_profile' => 'Modifica Profilo', - 'dark_mode' => 'Modalità Scura', - 'light_mode' => 'Modalità Chiara', - 'global_search' => 'Ricerca Globale', + 'view_profile' => 'Visualizza profilo', + 'edit_profile' => 'Modifica profilo', + 'dark_mode' => 'Modalità scura', + 'light_mode' => 'Modalità chiara', + 'global_search' => 'Ricerca globale', // Layout tabs - 'tab_info' => 'Info', - 'tab_info_label' => 'Tab: Mostra Informazioni Secondarie', + 'tab_info' => 'Informazioni', + 'tab_info_label' => 'Scheda: Mostra informazioni secondarie', 'tab_content' => 'Contenuto', - 'tab_content_label' => 'Tab: Mostra Contenuto Principale', + 'tab_content_label' => 'Scheda: Mostra contenuto principale', // Email Content - 'email_action_help' => 'Se hai problemi nel cliccare il pulsante ":actionText", copia e incolla lo URL sotto nel tuo browser:', + 'email_action_help' => 'Se hai problemi nel cliccare il pulsante ":actionText", copia e incolla l\'URL qui sotto nel tuo browser:', 'email_rights' => 'Tutti i diritti riservati', // Footer Link Options // Not directly used but available for convenience to users. 'privacy_policy' => 'Norme sulla privacy', - 'terms_of_service' => 'Condizioni del Servizio', + 'terms_of_service' => 'Condizioni del servizio', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Cerca :appName', ]; diff --git a/lang/it/components.php b/lang/it/components.php index b336893b5d2..e067db7033a 100644 --- a/lang/it/components.php +++ b/lang/it/components.php @@ -5,10 +5,10 @@ return [ // Image Manager - 'image_select' => 'Selezione Immagine', - 'image_list' => 'Elenco Immagini', - 'image_details' => 'Dettagli Immagine', - 'image_upload' => 'Carica Immagine', + 'image_select' => 'Seleziona un\'immagine', + 'image_list' => 'Elenco immagini', + 'image_details' => 'Dettagli immagine', + 'image_upload' => 'Carica immagine', 'image_intro' => 'Qui è possibile selezionare e gestire le immagini che sono state precedentemente caricate nel sistema.', 'image_intro_upload' => 'Carica una nuova immagine trascinando un file immagine in questa finestra oppure utilizzando il pulsante "Carica immagine" in alto.', 'image_all' => 'Tutte', @@ -20,27 +20,27 @@ 'image_uploaded_by' => 'Caricato da :userName', 'image_uploaded_to' => 'Caricato su :pageLink', 'image_updated' => 'Aggiornato il :updateDate', - 'image_load_more' => 'Carica Altre', - 'image_image_name' => 'Nome Immagine', + 'image_load_more' => 'Carica altre', + 'image_image_name' => 'Nome immagine', 'image_delete_used' => 'Questa immagine è usata nelle pagine elencate.', 'image_delete_confirm_text' => 'Sei sicuro di voler eliminare questa immagine?', - 'image_select_image' => 'Seleziona Immagine', + 'image_select_image' => 'Seleziona immagine', 'image_dropzone' => 'Rilascia immagini o clicca qui per caricarle', 'image_dropzone_drop' => 'Trascina qui le immagini da caricare', - 'images_deleted' => 'Immagini Eliminate', - 'image_preview' => 'Anteprima Immagine', + 'images_deleted' => 'Immagini eliminate', + 'image_preview' => 'Anteprima immagine', 'image_upload_success' => 'Immagine caricata correttamente', 'image_update_success' => 'Dettagli immagine aggiornati correttamente', 'image_delete_success' => 'Immagine eliminata correttamente', - 'image_replace' => 'Sostituisci Immagine', + 'image_replace' => 'Sostituisci immagine', 'image_replace_success' => 'File immagine aggiornato con successo', - 'image_rebuild_thumbs' => 'Rigenera Variazioni Dimensione', + 'image_rebuild_thumbs' => 'Rigenera variazioni dimensione', 'image_rebuild_thumbs_success' => 'Variazioni di dimensione immagine ricostruite con successo!', // Code Editor - 'code_editor' => 'Modifica Codice', - 'code_language' => 'Linguaggio Codice', - 'code_content' => 'Contenuto Codice', - 'code_session_history' => 'Cronologia Sessione', - 'code_save' => 'Salva Codice', + 'code_editor' => 'Modifica codice', + 'code_language' => 'Linguaggio codice', + 'code_content' => 'Contenuto codice', + 'code_session_history' => 'Cronologia sessione', + 'code_save' => 'Salva codice', ]; diff --git a/lang/it/editor.php b/lang/it/editor.php index dc05fcb4314..f9a6e47ae03 100644 --- a/lang/it/editor.php +++ b/lang/it/editor.php @@ -28,22 +28,22 @@ // Toolbar 'formats' => 'Formati', - 'header_large' => 'Intestazione Grande', - 'header_medium' => 'Intestazione Media', - 'header_small' => 'Intestazione Piccola', - 'header_tiny' => 'Intestazione Minuscola', + 'header_large' => 'Intestazione grande', + 'header_medium' => 'Intestazione media', + 'header_small' => 'Intestazione piccola', + 'header_tiny' => 'Intestazione minuscola', 'paragraph' => 'Paragrafo', 'blockquote' => 'Virgolettato', 'inline_code' => 'Codice in linea', 'callouts' => 'Didascalie', - 'callout_information' => 'Informazioni', + 'callout_information' => 'Informazione', 'callout_success' => 'Fatto', - 'callout_warning' => 'Attenzione', + 'callout_warning' => 'Avviso', 'callout_danger' => 'Pericolo', 'bold' => 'Grassetto', 'italic' => 'Corsivo', 'underline' => 'Sottolineato', - 'strikethrough' => 'Testo barrato', + 'strikethrough' => 'Barrato', 'superscript' => 'Apice', 'subscript' => 'Pedice', 'text_color' => 'Colore del testo', @@ -61,20 +61,20 @@ 'indent_decrease' => 'Riduci rientro', 'table' => 'Tabella', 'insert_image' => 'Inserisci immagine', - 'insert_image_title' => 'Inserisci/Modifica Immagine', - 'insert_link' => 'Inserisci/Modifica Collegamento', - 'insert_link_title' => 'Inserisci/Modifica Collegamento', - 'insert_horizontal_line' => 'Inserisci Riga Orizzontale', + 'insert_image_title' => 'Inserisci/modifica immagine', + 'insert_link' => 'Inserisci/modifica collegamento', + 'insert_link_title' => 'Inserisci/modifica collegamento', + 'insert_horizontal_line' => 'Inserisci riga orizzontale', 'insert_code_block' => 'Inserisci blocco di codice', 'edit_code_block' => 'Modifica blocco di codice', - 'insert_drawing' => 'Inserisci/Modifica Disegno', + 'insert_drawing' => 'Inserisci/modifica disegno', 'drawing_manager' => 'Gestore disegni', 'insert_media' => 'Inserisci/modifica media', - 'insert_media_title' => 'Inserisci/Modifica Media', + 'insert_media_title' => 'Inserisci/modifica media', 'clear_formatting' => 'Cancella formattazione', 'source_code' => 'Codice sorgente', - 'source_code_title' => 'Codice Sorgente', - 'fullscreen' => 'Schermo Intero', + 'source_code_title' => 'Codice sorgente', + 'fullscreen' => 'Schermo intero', 'image_options' => 'Opzioni immagine', // Tables @@ -92,39 +92,39 @@ 'delete_column' => 'Elimina colonna', 'table_cell' => 'Cella', 'table_row' => 'Riga', - 'table_column' => 'Column', + 'table_column' => 'Colonna', 'cell_properties' => 'Proprietà cella', - 'cell_properties_title' => 'Proprietà Cella', + 'cell_properties_title' => 'Proprietà cella', 'cell_type' => 'Tipo di cella', 'cell_type_cell' => 'Cella', 'cell_scope' => 'Ambito', 'cell_type_header' => 'Cella intestazione', 'merge_cells' => 'Unisci celle', 'split_cell' => 'Dividi cella', - 'table_row_group' => 'Gruppo Riga', - 'table_column_group' => 'Gruppo Colonna', + 'table_row_group' => 'Gruppo riga', + 'table_column_group' => 'Gruppo colonna', 'horizontal_align' => 'Allineamento orizzontale', 'vertical_align' => 'Allineamento verticale', 'border_width' => 'Spessore bordo', - 'border_style' => 'Stile del Bordo', - 'border_color' => 'Colore del bordo', + 'border_style' => 'Stile bordo', + 'border_color' => 'Colore bordo', 'row_properties' => 'Proprietà riga', - 'row_properties_title' => 'Proprietà Riga', + 'row_properties_title' => 'Proprietà riga', 'cut_row' => 'Taglia riga', 'copy_row' => 'Copia riga', - 'paste_row_before' => 'Inserisci la riga prima', + 'paste_row_before' => 'Incolla riga prima', 'paste_row_after' => 'Incolla riga dopo', - 'row_type' => 'Tipo di riga', + 'row_type' => 'Tipo riga', 'row_type_header' => 'Intestazione', 'row_type_body' => 'Corpo', - 'row_type_footer' => 'Piè di Pagina', + 'row_type_footer' => 'Piè di pagina', 'alignment' => 'Allineamento', 'cut_column' => 'Taglia colonna', 'copy_column' => 'Copia colonna', 'paste_column_before' => 'Incolla colonna prima', - 'paste_column_after' => 'Inserisci colonna dopo', - 'cell_padding' => 'Spaziatura cella', - 'cell_spacing' => 'Spaziatura celle', + 'paste_column_after' => 'Incolla colonna dopo', + 'cell_padding' => 'Padding cella', + 'cell_spacing' => 'Spaziatura cella', 'caption' => 'Didascalia', 'show_caption' => 'Mostra didascalia', 'constrain' => 'Mantieni proporzioni', @@ -132,10 +132,10 @@ 'cell_border_dotted' => 'Punteggiato', 'cell_border_dashed' => 'Tratteggiato', 'cell_border_double' => 'Doppio', - 'cell_border_groove' => 'Bordo Incassato', - 'cell_border_ridge' => 'Bordo In Rilievo', + 'cell_border_groove' => 'Bordo incassato', + 'cell_border_ridge' => 'In rilievo', 'cell_border_inset' => 'Sfondo Incassato', - 'cell_border_outset' => 'Sfondo In rilievo', + 'cell_border_outset' => 'Sfondo in rilievo', 'cell_border_none' => 'Nessuno', 'cell_border_hidden' => 'Nascosto', @@ -155,17 +155,17 @@ 'insert_collapsible' => 'Inserisci blocco collassabile', 'collapsible_unwrap' => 'Espandi', 'edit_label' => 'Modifica etichetta', - 'toggle_open_closed' => 'Espandi/Comprimi', + 'toggle_open_closed' => 'Espandi/comprimi', 'collapsible_edit' => 'Modifica blocco collassabile', - 'toggle_label' => 'Attiva/Disattiva etichetta', + 'toggle_label' => 'Attiva/disattiva etichetta', // About view 'about' => 'Informazioni sull\'editor', 'about_title' => 'Informazioni sull\'editor di WYSIWYG', - 'editor_license' => 'Licenza & Copyright Dell\'Editor', + 'editor_license' => 'Licenza e copyright dell\'editor', 'editor_tiny_license' => 'Questo editor è realizzato usando :tinyLink che è fornito sotto la licenza MIT.', 'editor_tiny_license_link' => 'I dettagli del copyright e della licenza di TinyMCE sono disponibili qui.', - 'save_continue' => 'Salva Pagina E Continua', + 'save_continue' => 'Salva pagina e continua', 'callouts_cycle' => '(Continua a premere per passare da un tipo all\'altro)', 'link_selector' => 'Link al contenuto', 'shortcuts' => 'Scorciatoie', diff --git a/lang/it/entities.php b/lang/it/entities.php index 83ea85d266f..d7542b1ba11 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -11,36 +11,36 @@ 'recently_updated_pages' => 'Pagine aggiornate di recente', 'recently_created_chapters' => 'Capitoli creati di recente', 'recently_created_books' => 'Libri creati di recente', - 'recently_created_shelves' => 'Librerie Create Di Recente', + 'recently_created_shelves' => 'Librerie create di recente', 'recently_update' => 'Aggiornati di recente', 'recently_viewed' => 'Visti di recente', - 'recent_activity' => 'Attività Recente', + 'recent_activity' => 'Attività recente', 'create_now' => 'Creane uno ora', - 'revisions' => 'Versioni', - 'meta_revision' => 'Versione #:revisionCount', + 'revisions' => 'Revisioni', + 'meta_revision' => 'Revisione #:revisionCount', 'meta_created' => 'Creato :timeLength', 'meta_created_name' => 'Creato :timeLength da :user', 'meta_updated' => 'Aggiornato :timeLength', 'meta_updated_name' => 'Aggiornato :timeLength da :user', 'meta_owned_name' => 'Creati da :user', 'meta_reference_count' => 'Referenziato da :count item|Referenziato da :count items', - 'entity_select' => 'Selezione Entità', + 'entity_select' => 'Seleziona l\'entità', 'entity_select_lack_permission' => 'Non hai i permessi necessari per selezionare questo elemento', 'images' => 'Immagini', - 'my_recent_drafts' => 'Bozze Recenti', + 'my_recent_drafts' => 'Bozze recenti', 'my_recently_viewed' => 'Visti di recente', - 'my_most_viewed_favourites' => 'I Miei Preferiti Più Visti', - 'my_favourites' => 'I miei Preferiti', + 'my_most_viewed_favourites' => 'I miei preferiti più visti', + 'my_favourites' => 'I miei preferiti', 'no_pages_viewed' => 'Non hai visto nessuna pagina', - 'no_pages_recently_created' => 'Nessuna pagina è stata creata di recente', - 'no_pages_recently_updated' => 'Nessuna pagina è stata aggiornata di recente', + 'no_pages_recently_created' => 'Nessuna pagina creata di recente', + 'no_pages_recently_updated' => 'Nessuna pagina aggiornata di recente', 'export' => 'Esporta', - 'export_html' => 'File Contenuto Web', + 'export_html' => 'File contenuto web', 'export_pdf' => 'File PDF', 'export_text' => 'File di testo', 'export_md' => 'File Markdown', - 'default_template' => 'Modello di Pagina Predefinito', - 'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che questo verrà utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.', + 'default_template' => 'Modello di pagina predefinito', + 'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che potrà essere utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.', 'default_template_select' => 'Seleziona una pagina modello', // Permissions and restrictions @@ -48,25 +48,25 @@ 'permissions_desc' => 'Imposta qui i permessi per sovrascrivere i permessi predefiniti forniti dai ruoli utente.', 'permissions_book_cascade' => 'I permessi impostati sui libri si trasmettono automaticamente a cascata ai capitoli e alle pagine figli, a meno che non siano stati definiti permessi propri.', 'permissions_chapter_cascade' => 'I permessi impostati sui capitoli si trasmettono automaticamente a cascata alle pagine figlie, a meno che non siano stati definiti permessi propri.', - 'permissions_save' => 'Salva Permessi', + 'permissions_save' => 'Salva permessi', 'permissions_owner' => 'Proprietario', - 'permissions_role_everyone_else' => 'Tutti Gli Altri', + 'permissions_role_everyone_else' => 'Tutti gli altri', 'permissions_role_everyone_else_desc' => 'Imposta i permessi per tutti i ruoli non specificamente sovrascritti.', 'permissions_role_override' => 'Sovrascrivere i permessi per il ruolo', 'permissions_inherit_defaults' => 'Eredita predefinite', // Search - 'search_results' => 'Risultati Ricerca', + 'search_results' => 'Risultati della ricerca', 'search_total_results_found' => ':count risultato trovato|:count risultati trovati', - 'search_clear' => 'Pulisci Ricerca', + 'search_clear' => 'Pulisci ricerca', 'search_no_pages' => 'Nessuna pagina corrisponde alla ricerca', 'search_for_term' => 'Ricerca per :term', - 'search_more' => 'Più risultati', - 'search_advanced' => 'Ricerca Avanzata', - 'search_terms' => 'Termini Ricerca', - 'search_content_type' => 'Tipo di Contenuto', - 'search_exact_matches' => 'Corrispondenza Esatta', - 'search_tags' => 'Ricerche Tag', + 'search_more' => 'Altri risultati', + 'search_advanced' => 'Ricerca avanzata', + 'search_terms' => 'Termini di ricerca', + 'search_content_type' => 'Tipo di contenuto', + 'search_exact_matches' => 'Corrispondenza esatta', + 'search_tags' => 'Ricerche per tag', 'search_options' => 'Opzioni', 'search_viewed_by_me' => 'Visti da me', 'search_not_viewed_by_me' => 'Non visti da me', @@ -74,68 +74,68 @@ 'search_created_by_me' => 'Creati da me', 'search_updated_by_me' => 'Aggiornati da me', 'search_owned_by_me' => 'Creati da me', - 'search_date_options' => 'Opzioni Data', + 'search_date_options' => 'Opzioni data', 'search_updated_before' => 'Aggiornati prima del', 'search_updated_after' => 'Aggiornati dopo il', 'search_created_before' => 'Creati prima del', 'search_created_after' => 'Creati dopo il', - 'search_set_date' => 'Imposta Data', - 'search_update' => 'Aggiorna Ricerca', + 'search_set_date' => 'Imposta data', + 'search_update' => 'Aggiorna ricerca', // Shelves 'shelf' => 'Libreria', 'shelves' => 'Librerie', - 'x_shelves' => ':count Libreria|:count Librerie', - 'shelves_empty' => 'Nessuna libreria è stata creata', - 'shelves_create' => 'Crea Nuova Libreria', - 'shelves_popular' => 'Librerie Popolari', - 'shelves_new' => 'Nuove Librerie', - 'shelves_new_action' => 'Nuova Libreria', + 'x_shelves' => ':count libreria|:count librerie', + 'shelves_empty' => 'Nessuna libreria creata', + 'shelves_create' => 'Crea nuova libreria', + 'shelves_popular' => 'Librerie popolari', + 'shelves_new' => 'Nuove librerie', + 'shelves_new_action' => 'Nuova libreria', 'shelves_popular_empty' => 'Le librerie più popolari appariranno qui.', 'shelves_new_empty' => 'Le librerie create più di recente appariranno qui.', - 'shelves_save' => 'Salva Libreria', + 'shelves_save' => 'Salva libreria', 'shelves_books' => 'Libri in questa libreria', 'shelves_add_books' => 'Aggiungi libri a questa libreria', 'shelves_drag_books' => 'Trascina i libri qui sotto per aggiungerli a questa libreria', 'shelves_empty_contents' => 'Questa libreria non ha libri assegnati', 'shelves_edit_and_assign' => 'Modifica la libreria per assegnare i libri', - 'shelves_edit_named' => 'Modifica Libreria :name', - 'shelves_edit' => 'Modifica Libreria', - 'shelves_delete' => 'Elimina Libreria', - 'shelves_delete_named' => 'Elimina Libreria :name', + 'shelves_edit_named' => 'Modifica libreria :name', + 'shelves_edit' => 'Modifica libreria', + 'shelves_delete' => 'Elimina libreria', + 'shelves_delete_named' => 'Elimina libreria :name', 'shelves_delete_explain' => "La libreria ':name' verrà eliminata. I libri al suo interno non verranno eliminati.", 'shelves_delete_confirmation' => 'Sei sicuro di voler eliminare questa libreria?', - 'shelves_permissions' => 'Permessi Libreria', - 'shelves_permissions_updated' => 'Permessi Libreria Aggiornati', - 'shelves_permissions_active' => 'Permessi Libreria Attivi', + 'shelves_permissions' => 'Permessi libreria', + 'shelves_permissions_updated' => 'Permessi libreria aggiornati', + 'shelves_permissions_active' => 'Permessi libreria attivi', 'shelves_permissions_cascade_warning' => 'I permessi delle librerie non si estendono automaticamente ai libri contenuti. Questo perché un libro può essere presente su più scaffali. I permessi possono comunque essere copiati ai libri al suo interno usando l\'opzione sottostante.', 'shelves_permissions_create' => 'Le autorizzazioni per la creazione di librerie sono utilizzate solo per copiare le autorizzazioni ai libri figli utilizzando l\'azione sottostante. Non controllano la capacità di creare libri.', - 'shelves_copy_permissions_to_books' => 'Copia Permessi ai Libri', - 'shelves_copy_permissions' => 'Copia Permessi', + 'shelves_copy_permissions_to_books' => 'Copia permessi ai libri', + 'shelves_copy_permissions' => 'Copia permessi', 'shelves_copy_permissions_explain' => 'Verranno applicati tutti i permessi della libreria ai libri al suo interno. Prima dell\'attivazione, assicurati di aver salvato le modifiche ai permessi di questa libreria.', 'shelves_copy_permission_success' => 'Permessi della libreria copiati in :count libri', // Books 'book' => 'Libro', 'books' => 'Libri', - 'x_books' => ':count Libro|:count Libri', - 'books_empty' => 'Nessun libro è stato creato', - 'books_popular' => 'Libri Popolari', - 'books_recent' => 'Libri Recenti', - 'books_new' => 'Nuovi Libri', - 'books_new_action' => 'Nuovo Libro', + 'x_books' => ':count libro|:count libri', + 'books_empty' => 'Nessun libro creato', + 'books_popular' => 'Libri popolari', + 'books_recent' => 'Libri recenti', + 'books_new' => 'Nuovi libri', + 'books_new_action' => 'Nuovo libro', 'books_popular_empty' => 'I libri più popolari appariranno qui.', - 'books_new_empty' => 'I libri creati più di recente appariranno qui.', - 'books_create' => 'Crea Nuovo Libro', - 'books_delete' => 'Elimina Libro', + 'books_new_empty' => 'I libri creati di recente appariranno qui.', + 'books_create' => 'Crea nuovo libro', + 'books_delete' => 'Elimina libro', 'books_delete_named' => 'Elimina il libro :bookName', 'books_delete_explain' => 'Questo eliminerà il libro di nome \':bookName\'. Tutte le pagine e i capitoli saranno rimossi.', 'books_delete_confirmation' => 'Sei sicuro di voler eliminare questo libro?', - 'books_edit' => 'Modifica Libro', + 'books_edit' => 'Modifica libro', 'books_edit_named' => 'Modifica il libro :bookName', - 'books_form_book_name' => 'Nome Libro', - 'books_save' => 'Salva Libro', - 'books_permissions' => 'Permessi Libro', + 'books_form_book_name' => 'Nome libro', + 'books_save' => 'Salva libro', + 'books_permissions' => 'Permessi libro', 'books_permissions_updated' => 'Permessi del libro aggiornati', 'books_empty_contents' => 'Non ci sono pagine o capitoli per questo libro.', 'books_empty_create_page' => 'Crea una nuova pagina', @@ -143,16 +143,16 @@ 'books_empty_add_chapter' => 'Aggiungi un capitolo', 'books_permissions_active' => 'Permessi libro attivi', 'books_search_this' => 'Cerca in questo libro', - 'books_navigation' => 'Navigazione Libro', + 'books_navigation' => 'Navigazione libro', 'books_sort' => 'Ordina il contenuto del libro', 'books_sort_desc' => 'Sposta capitoli e pagine all\'interno di un libro per riorganizzarne il contenuto. È possibile aggiungere altri libri, per spostare facilmente capitoli e pagine da un libro all\'altro.', 'books_sort_named' => 'Ordina il libro :bookName', 'books_sort_name' => 'Ordina per Nome', - 'books_sort_created' => 'Ordina per Data di Creazione', - 'books_sort_updated' => 'Ordina per Data di Aggiornamento', - 'books_sort_chapters_first' => 'Capitoli Per Primi', - 'books_sort_chapters_last' => 'Capitoli Per Ultimi', - 'books_sort_show_other' => 'Mostra Altri Libri', + 'books_sort_created' => 'Ordina per Data di creazione', + 'books_sort_updated' => 'Ordina per Data di aggiornamento', + 'books_sort_chapters_first' => 'Capitoli per primi', + 'books_sort_chapters_last' => 'Capitoli per ultimi', + 'books_sort_show_other' => 'Mostra altri libri', 'books_sort_save' => 'Salva il nuovo ordine', 'books_sort_show_other_desc' => 'Aggiungi qui altri libri per includerli nell\'operazione di ordinamento e consentire una facile riorganizzazione incrociata dei libri.', 'books_sort_move_up' => 'Muovi su', @@ -165,126 +165,126 @@ 'books_sort_move_book_end' => 'Passa alla fine del libro', 'books_sort_move_before_chapter' => 'Passa al capitolo precedente', 'books_sort_move_after_chapter' => 'Passa al capitolo successivo', - 'books_copy' => 'Copia Libro', + 'books_copy' => 'Copia libro', 'books_copy_success' => 'Libro copiato con successo', // Chapters 'chapter' => 'Capitolo', 'chapters' => 'Capitoli', - 'x_chapters' => ':count Capitolo|:count Capitoli', - 'chapters_popular' => 'Capitoli Popolari', - 'chapters_new' => 'Nuovo Capitolo', + 'x_chapters' => ':count capitolo|:count capitoli', + 'chapters_popular' => 'Capitoli popolari', + 'chapters_new' => 'Nuovo capitolo', 'chapters_create' => 'Crea un nuovo capitolo', - 'chapters_delete' => 'Elimina Capitolo', + 'chapters_delete' => 'Elimina capitolo', 'chapters_delete_named' => 'Elimina il capitolo :chapterName', - 'chapters_delete_explain' => 'Procedendo si eliminerà il capitolo denominato \':chapterName\'. Anche le pagine all\'interno saranno eliminate.', + 'chapters_delete_explain' => 'Verrà eliminato il capitolo denominato \':chapterName\'. Saranno eliminate anche le pagine all\'interno.', 'chapters_delete_confirm' => 'Sei sicuro di voler eliminare questo capitolo?', - 'chapters_edit' => 'Elimina Capitolo', + 'chapters_edit' => 'Elimina capitolo', 'chapters_edit_named' => 'Modifica il capitolo :chapterName', - 'chapters_save' => 'Salva Capitolo', - 'chapters_move' => 'Muovi Capitolo', - 'chapters_move_named' => 'Muovi il capitolo :chapterName', - 'chapters_copy' => 'Copia Capitolo', + 'chapters_save' => 'Salva capitolo', + 'chapters_move' => 'Sposta capitolo', + 'chapters_move_named' => 'Sposta il capitolo :chapterName', + 'chapters_copy' => 'Copia capitolo', 'chapters_copy_success' => 'Capitolo copiato con successo', - 'chapters_permissions' => 'Permessi Capitolo', + 'chapters_permissions' => 'Permessi capitolo', 'chapters_empty' => 'Non ci sono pagine in questo capitolo.', - 'chapters_permissions_active' => 'Permessi Capitolo Attivi', - 'chapters_permissions_success' => 'Permessi Capitolo Aggiornati', + 'chapters_permissions_active' => 'Permessi capitolo attivi', + 'chapters_permissions_success' => 'Permessi capitolo aggiornati', 'chapters_search_this' => 'Cerca in questo capitolo', - 'chapter_sort_book' => 'Ordina Libro', + 'chapter_sort_book' => 'Ordina libro', // Pages 'page' => 'Pagina', 'pages' => 'Pagine', - 'x_pages' => ':count Pagina|:count Pagine', - 'pages_popular' => 'Pagine Popolari', - 'pages_new' => 'Nuova Pagina', + 'x_pages' => ':count pagina|:count pagine', + 'pages_popular' => 'Pagine popolari', + 'pages_new' => 'Nuova pagina', 'pages_attachments' => 'Allegati', - 'pages_navigation' => 'Navigazione Pagine', - 'pages_delete' => 'Elimina Pagina', + 'pages_navigation' => 'Navigazione pagine', + 'pages_delete' => 'Elimina pagina', 'pages_delete_named' => 'Elimina la pagina :pageName', 'pages_delete_draft_named' => 'Elimina bozza della pagina :pageName', - 'pages_delete_draft' => 'Elimina Bozza Pagina', + 'pages_delete_draft' => 'Elimina bozza pagina', 'pages_delete_success' => 'Pagina eliminata', 'pages_delete_draft_success' => 'Bozza di una pagina eliminata', 'pages_delete_warning_template' => 'Questa pagina è in uso come modello di pagina predefinito del libro o del capitolo. Questi libri o capitoli non avranno più un modello di pagina predefinito assegnato dopo che questa pagina sarà eliminata.', 'pages_delete_confirm' => 'Sei sicuro di voler eliminare questa pagina?', 'pages_delete_draft_confirm' => 'Sei sicuro di voler eliminare la bozza di questa pagina?', 'pages_editing_named' => 'Modifica :pageName', - 'pages_edit_draft_options' => 'Opzioni Bozza', - 'pages_edit_save_draft' => 'Salva Bozza', - 'pages_edit_draft' => 'Modifica Bozza della pagina', - 'pages_editing_draft' => 'Modifica Bozza', - 'pages_editing_page' => 'Modifica Pagina', + 'pages_edit_draft_options' => 'Opzioni bozza', + 'pages_edit_save_draft' => 'Salva bozza', + 'pages_edit_draft' => 'Modifica bozza della pagina', + 'pages_editing_draft' => 'Modifica bozza', + 'pages_editing_page' => 'Modifica pagina', 'pages_edit_draft_save_at' => 'Bozza salvata alle ', - 'pages_edit_delete_draft' => 'Elimina Bozza', + 'pages_edit_delete_draft' => 'Elimina bozza', 'pages_edit_delete_draft_confirm' => 'Si è sicuri di voler eliminare le modifiche alla pagina bozza? Tutte le modifiche apportate dall\'ultimo salvataggio completo andranno perse e l\'editor verrà aggiornato con l\'ultimo stato di salvataggio della pagina non in bozza.', - 'pages_edit_discard_draft' => 'Scarta Bozza', + 'pages_edit_discard_draft' => 'Scarta bozza', 'pages_edit_switch_to_markdown' => 'Passa all\'editor Markdown', 'pages_edit_switch_to_markdown_clean' => '(Contenuto Chiaro)', - 'pages_edit_switch_to_markdown_stable' => '(Contenuto Stabile)', + 'pages_edit_switch_to_markdown_stable' => '(Contenuto stabile)', 'pages_edit_switch_to_wysiwyg' => 'Passa all\'editor WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', - 'pages_edit_set_changelog' => 'Imposta Changelog', + 'pages_edit_switch_to_new_wysiwyg' => 'Passa al nuovo WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In test alpha)', + 'pages_edit_set_changelog' => 'Imposta changelog', 'pages_edit_enter_changelog_desc' => 'Inserisci una breve descrizione dei cambiamenti che hai apportato', - 'pages_edit_enter_changelog' => 'Inserisci Changelog', - 'pages_editor_switch_title' => 'Cambia Editor', + 'pages_edit_enter_changelog' => 'Inserisci changelog', + 'pages_editor_switch_title' => 'Cambia editor', 'pages_editor_switch_are_you_sure' => 'Sei sicuro di voler cambiare l\'editor di questa pagina?', 'pages_editor_switch_consider_following' => 'Considera quanto segue quando si cambia editor:', 'pages_editor_switch_consideration_a' => 'Una volta salvata, la nuova opzione di editor sarà utilizzata da chi modificherà in futuro, inclusi quelli che potrebbero non essere in grado di cambiare il tipo di editor da soli.', 'pages_editor_switch_consideration_b' => 'Ciò può potenzialmente portare a una perdita di dettagli e sintassi in determinate circostanze.', - 'pages_editor_switch_consideration_c' => 'Le modifiche al tag o al changelog, fatte dall\'ultimo salvataggio, non persisteranno in questa modifica.', - 'pages_save' => 'Salva Pagina', - 'pages_title' => 'Titolo Pagina', - 'pages_name' => 'Nome Pagina', + 'pages_editor_switch_consideration_c' => 'Le modifiche ai tag o al changelog, fatte dall\'ultimo salvataggio, non persisteranno in questa modifica.', + 'pages_save' => 'Salva pagina', + 'pages_title' => 'Titolo pagina', + 'pages_name' => 'Nome pagina', 'pages_md_editor' => 'Editor', 'pages_md_preview' => 'Anteprima', - 'pages_md_insert_image' => 'Inserisci Immagina', - 'pages_md_insert_link' => 'Inserisci Link Entità', - 'pages_md_insert_drawing' => 'Inserisci Disegno', + 'pages_md_insert_image' => 'Inserisci immagine', + 'pages_md_insert_link' => 'Inserisci collegamento entità', + 'pages_md_insert_drawing' => 'Inserisci disegno', 'pages_md_show_preview' => 'Visualizza anteprima', 'pages_md_sync_scroll' => 'Sincronizza scorrimento anteprima', - 'pages_drawing_unsaved' => 'Trovato Disegno Non Salvato', - 'pages_drawing_unsaved_confirm' => 'Sono stati trovati i dati di un disegno non salvati da un precedente tentativo di salvataggio di disegno non riuscito. Ripristinare e continuare a modificare questo disegno non salvato?', + 'pages_drawing_unsaved' => 'Trovato disegno non salvato', + 'pages_drawing_unsaved_confirm' => 'Sono stati trovati i dati di un disegno non salvato da un precedente tentativo di salvataggio di disegno non riuscito. Ripristinare e continuare a modificare questo disegno non salvato?', 'pages_not_in_chapter' => 'La pagina non è in un capitolo', - 'pages_move' => 'Muovi Pagina', - 'pages_copy' => 'Copia Pagina', - 'pages_copy_desination' => 'Copia Destinazione', + 'pages_move' => 'Sposta pagina', + 'pages_copy' => 'Copia pagina', + 'pages_copy_desination' => 'Copia destinazione', 'pages_copy_success' => 'Pagina copiata correttamente', - 'pages_permissions' => 'Permessi Pagina', + 'pages_permissions' => 'Permessi pagina', 'pages_permissions_success' => 'Permessi pagina aggiornati', 'pages_revision' => 'Versione', - 'pages_revisions' => 'Versioni Pagina', - 'pages_revisions_desc' => 'Di seguito sono elencate tutte le revisioni precedenti di questa pagina. È possibile consultare, confrontare e ripristinare le vecchie versioni della pagina, se le autorizzazioni lo consentono. La cronologia completa della pagina potrebbe non essere riportata qui, poiché, a seconda della configurazione del sistema, le vecchie revisioni potrebbero essere cancellate automaticamente.', - 'pages_revisions_named' => 'Versioni della pagina :pageName', - 'pages_revision_named' => 'Versione della pagina :pageName', + 'pages_revisions' => 'Revisioni pagina', + 'pages_revisions_desc' => 'Di seguito sono elencate tutte le revisioni precedenti di questa pagina. È possibile consultare, confrontare e ripristinare le vecchie versioni della pagina, se hai i permessi. La cronologia completa della pagina potrebbe non essere riportata qui, poiché a seconda della configurazione del sistema le vecchie revisioni potrebbero essere cancellate automaticamente.', + 'pages_revisions_named' => 'Revisioni della pagina :pageName', + 'pages_revision_named' => 'Revisione della pagina :pageName', 'pages_revision_restored_from' => 'Ripristinato da #:id; :summary', - 'pages_revisions_created_by' => 'Creata Da', - 'pages_revisions_date' => 'Data Versione', + 'pages_revisions_created_by' => 'Creata da', + 'pages_revisions_date' => 'Data versione', 'pages_revisions_number' => '#', - 'pages_revisions_sort_number' => 'Numero Revisione', + 'pages_revisions_sort_number' => 'Numero revisione', 'pages_revisions_numbered' => 'Revisione #:id', - 'pages_revisions_numbered_changes' => 'Modifiche Revisione #:id', - 'pages_revisions_editor' => 'Tipo Di Editor', - 'pages_revisions_changelog' => 'Cambiamenti', + 'pages_revisions_numbered_changes' => 'Modifiche revisione #:id', + 'pages_revisions_editor' => 'Tipo di editor', + 'pages_revisions_changelog' => 'Changelog', 'pages_revisions_changes' => 'Cambiamenti', - 'pages_revisions_current' => 'Versione Corrente', + 'pages_revisions_current' => 'Versione corrente', 'pages_revisions_preview' => 'Anteprima', 'pages_revisions_restore' => 'Ripristina', - 'pages_revisions_none' => 'Questa pagina non ha versioni', - 'pages_copy_link' => 'Copia Link', + 'pages_revisions_none' => 'Questa pagina non ha revisioni', + 'pages_copy_link' => 'Copia collegamento', 'pages_edit_content_link' => 'Vai alla sezione nell\'editor', - 'pages_pointer_enter_mode' => 'Accedi alla modalità di selezione della sezione', - 'pages_pointer_label' => 'Opzioni Sezione Pagina', - 'pages_pointer_permalink' => 'Permalink Sezione Pagina', - 'pages_pointer_include_tag' => 'Sezione Pagina Includi Tag', - 'pages_pointer_toggle_link' => 'Modalità Permalink, Premi per mostrare includi tag', + 'pages_pointer_enter_mode' => 'Vai alla modalità di selezione della sezione', + 'pages_pointer_label' => 'Opzioni sezione pagina', + 'pages_pointer_permalink' => 'Sezione pagina Permalink', + 'pages_pointer_include_tag' => 'Sezione pagina includi tag', + 'pages_pointer_toggle_link' => 'Modalità Permalink, premi per mostrare includi tag', 'pages_pointer_toggle_include' => 'Modalità includi tag, premi per mostrare permalink', - 'pages_permissions_active' => 'Permessi Pagina Attivi', + 'pages_permissions_active' => 'Permessi pagina attivi', 'pages_initial_revision' => 'Pubblicazione iniziale', 'pages_references_update_revision' => 'Aggiornamento automatico di sistema dei collegamenti interni', - 'pages_initial_name' => 'Nuova Pagina', + 'pages_initial_name' => 'Nuova pagina', 'pages_editing_draft_notification' => 'Stai modificando una bozza che è stata salvata il :timeDiff.', 'pages_draft_edited_notification' => 'Questa pagina è stata aggiornata. Si consiglia di scartare questa bozza.', 'pages_draft_page_changed_since_creation' => 'Questa pagina è stata aggiornata da quando è stata creata questa bozza. Si consiglia di scartare questa bozza o di fare attenzione a non sovrascrivere alcun cambiamento alla pagina.', @@ -297,15 +297,15 @@ ], 'pages_draft_discarded' => 'Bozza scartata! L\'editor è stato aggiornato con il contenuto della pagina corrente', 'pages_draft_deleted' => 'Bozza eliminata! L\'editor è stato aggiornato con il contenuto della pagina corrente', - 'pages_specific' => 'Pagina Specifica', - 'pages_is_template' => 'Template Pagina', + 'pages_specific' => 'Pagina specifica', + 'pages_is_template' => 'Modello pagina', // Editor Sidebar 'toggle_sidebar' => 'Attiva/disattiva barra laterale', - 'page_tags' => 'Tag Pagina', - 'chapter_tags' => 'Tag Capitolo', - 'book_tags' => 'Tag Libro', - 'shelf_tags' => 'Tag Libreria', + 'page_tags' => 'Tag pagina', + 'chapter_tags' => 'Tag capitolo', + 'book_tags' => 'Tag libro', + 'shelf_tags' => 'Tag libreria', 'tag' => 'Tag', 'tags' => 'Tag', 'tags_index_desc' => 'I tag possono essere applicati ai contenuti del sistema per applicare una forma flessibile di categorizzazione. I tag possono avere una chiave e un valore, il valore è opzionale. Una volta applicati, i contenuti possono essere cercati utilizzando il nome e il valore del tag.', @@ -314,7 +314,7 @@ 'tags_explain' => "Aggiungi tag per categorizzare meglio il contenuto. \n Puoi assegnare un valore ai tag per una migliore organizzazione.", 'tags_add' => 'Aggiungi un altro tag', 'tags_remove' => 'Rimuovi questo tag', - 'tags_usages' => 'Utilizzo totale dei tag', + 'tags_usages' => 'Utilizzi totali dei tag', 'tags_assigned_pages' => 'Assegnato alle pagine', 'tags_assigned_chapters' => 'Assegnato ai capitoli', 'tags_assigned_books' => 'Assegnato ai libri', @@ -325,22 +325,22 @@ 'tags_view_existing_tags' => 'Usa i tag esistenti', 'tags_list_empty_hint' => 'I tag possono essere assegnati tramite la barra laterale dell\'editor di pagina o durante la modifica dei dettagli di un libro, di un capitolo o di una libreria.', 'attachments' => 'Allegati', - 'attachments_explain' => 'Carica alcuni file o allega link per visualizzarli nella pagina. Questi sono visibili nella barra laterale della pagina.', + 'attachments_explain' => 'Carica alcuni file o allega dei collegamenti per visualizzarli nella pagina. Questi sono visibili nella barra laterale della pagina.', 'attachments_explain_instant_save' => 'I cambiamenti qui sono salvati istantaneamente.', - 'attachments_upload' => 'Carica File', - 'attachments_link' => 'Allega Link', + 'attachments_upload' => 'Carica file', + 'attachments_link' => 'Allega collegamento', 'attachments_upload_drop' => 'In alternativa puoi trascinare un file qui per caricarlo come allegato.', - 'attachments_set_link' => 'Imposta Link', - 'attachments_delete' => 'Sei sicuro di voler eliminare questo allegato?', + 'attachments_set_link' => 'Imposta collegamento', + 'attachments_delete' => 'Vuoi davvero eliminare questo allegato?', 'attachments_dropzone' => 'Trascina qui i file da caricare', 'attachments_no_files' => 'Nessun file è stato caricato', - 'attachments_explain_link' => 'Puoi allegare un link se preferisci non caricare un file. Questo può essere un link a un\'altra pagina o a un file nel cloud.', - 'attachments_link_name' => 'Nome Link', - 'attachment_link' => 'Link allegato', - 'attachments_link_url' => 'Link al file', + 'attachments_explain_link' => 'Puoi allegare un collegamento se preferisci non caricare un file. Questo può essere un collegamento a un\'altra pagina o a un file nel cloud.', + 'attachments_link_name' => 'Nome collegamento', + 'attachment_link' => 'Collegamento allegato', + 'attachments_link_url' => 'Collegamento al file', 'attachments_link_url_hint' => 'Url del sito o del file', 'attach' => 'Allega', - 'attachments_insert_link' => 'Aggiungi link allegato alla pagina', + 'attachments_insert_link' => 'Aggiungi allegato collegamento alla pagina', 'attachments_edit_file' => 'Modifica file', 'attachments_edit_file_name' => 'Nome file', 'attachments_edit_drop_upload' => 'Trascina file qui o clicca per caricare e sovrascrivere', @@ -349,7 +349,7 @@ 'attachments_deleted' => 'Allegato eliminato', 'attachments_file_uploaded' => 'File caricato correttamente', 'attachments_file_updated' => 'File aggiornato correttamente', - 'attachments_link_attached' => 'Link allegato correttamente alla pagina', + 'attachments_link_attached' => 'Collegamento allegato correttamente alla pagina', 'templates' => 'Modello', 'templates_set_as_template' => 'La pagina è un modello', 'templates_explain_set_as_template' => 'Puoi impostare questa pagina come modello in modo da utilizzare il suo contenuto quando si creano altre pagine. Gli altri utenti potranno utilizzare questo modello se avranno i permessi di visualizzazione per questa pagina.', @@ -359,7 +359,7 @@ // Profile View 'profile_user_for_x' => 'Utente da :time', - 'profile_created_content' => 'Contenuti Creati', + 'profile_created_content' => 'Contenuti creati', 'profile_not_created_pages' => ':userName non ha creato pagine', 'profile_not_created_chapters' => ':userName non ha creato capitoli', 'profile_not_created_books' => ':userName non ha creato libri', @@ -368,18 +368,18 @@ // Comments 'comment' => 'Commento', 'comments' => 'Commenti', - 'comment_add' => 'Aggiungi Commento', + 'comment_add' => 'Aggiungi commento', 'comment_placeholder' => 'Scrivi un commento', - 'comment_count' => '{0} Nessun Commento|{1} 1 Commento|[2,*] :count Commenti', - 'comment_save' => 'Salva Commento', - 'comment_new' => 'Nuovo Commento', + 'comment_count' => '{0} Nessun commento|{1} 1 commento|[2,*] :count commenti', + 'comment_save' => 'Salva commento', + 'comment_new' => 'Nuovo commento', 'comment_created' => 'ha commentato :createDiff', 'comment_updated' => 'Aggiornato :updateDiff da :username', 'comment_updated_indicator' => 'Aggiornato', 'comment_deleted_success' => 'Commento eliminato', 'comment_created_success' => 'Commento aggiunto', 'comment_updated_success' => 'Commento aggiornato', - 'comment_delete_confirm' => 'Sei sicuro di voler elminare questo commento?', + 'comment_delete_confirm' => 'Sei sicuro di voler eliminare questo commento?', 'comment_in_reply_to' => 'In risposta a :commentId', 'comment_editor_explain' => 'Ecco i commenti che sono stati lasciati in questa pagina. I commenti possono essere aggiunti e gestiti quando si visualizza la pagina salvata.', @@ -394,18 +394,18 @@ 'copy_consider_owner' => 'Diventerai il proprietario di tutti i contenuti copiati.', 'copy_consider_images' => 'I file delle immagini delle pagine non saranno duplicati e le immagini originali manterranno la loro relazione con la pagina su cui sono state originariamente caricate.', 'copy_consider_attachments' => 'Gli allegati della pagina non saranno copiati.', - 'copy_consider_access' => 'Un cambiamento di posizione, di proprietario o di autorizzazioni può far sì che questo contenuto sia accessibile a chi prima non aveva accesso.', + 'copy_consider_access' => 'Un cambiamento di posizione, di proprietario o di autorizzazioni potrebbe rendere questo contenuto accessibile a chi prima non aveva accesso.', // Conversions - 'convert_to_shelf' => 'Converti in Libreria', + 'convert_to_shelf' => 'Converti in libreria', 'convert_to_shelf_contents_desc' => 'Puoi convertire questo libro in una nuova libreria con gli stessi contenuti. I capitoli contenuti in questo libro saranno convertiti in nuovi libri. Se il libro contiene pagine che non fanno parte di un capitolo, questo libro verrà rinominato e conterrà tali pagine e diventerà parte della nuova libreria.', 'convert_to_shelf_permissions_desc' => 'Tutti i permessi impostati su questo libro saranno copiati sulla nuova libreria e su tutti i nuovi libri figli che non hanno i loro permessi applicati. Nota che i permessi delle librerie non si trasmettono automaticamente ai contenuti al loro interno, come avviene per i libri.', - 'convert_book' => 'Converti Libro', + 'convert_book' => 'Converti libro', 'convert_book_confirm' => 'Sei sicuro di voler convertire questo libro?', 'convert_undo_warning' => 'Questo non può essere annullato con la stessa facilità.', 'convert_to_book' => 'Converti in libro', 'convert_to_book_desc' => 'È possibile convertire questo capitolo in un nuovo libro con gli stessi contenuti. Tutti i permessi impostati su questo capitolo saranno copiati nel nuovo libro, ma i permessi ereditati dal libro principale non saranno copiati, il che potrebbe portare a una modifica del controllo degli accessi.', - 'convert_chapter' => 'Converti Capitolo', + 'convert_chapter' => 'Converti capitolo', 'convert_chapter_confirm' => 'Sei sicuro di voler convertire questo capitolo?', // References @@ -415,25 +415,25 @@ // Watch Options 'watch' => 'Osserva', - 'watch_title_default' => 'Preferenze Predefinite', - 'watch_desc_default' => 'Ripristina la visualizzazione delle tue preferenze di notifica predefinite.', + 'watch_title_default' => 'Preferenze predefinite', + 'watch_desc_default' => 'Ripristina l\'osservazione alle tue preferenze di notifica predefinite.', 'watch_title_ignore' => 'Ignora', - 'watch_desc_ignore' => 'Ignora tutte le notifiche, comprese quelle dalle preferenze di livello utente.', - 'watch_title_new' => 'Nuove Pagine', + 'watch_desc_ignore' => 'Ignora tutte le notifiche, incluse quelle dalle preferenze a livello utente.', + 'watch_title_new' => 'Nuove pagine', 'watch_desc_new' => 'Notifica quando viene creata una nuova pagina all\'interno di questo elemento.', - 'watch_title_updates' => 'Tutti Gli Aggiornamenti Della Pagina', - 'watch_desc_updates' => 'Notificare su tutte le nuove pagine e modifiche di pagina.', - 'watch_desc_updates_page' => 'Notifica su tutte le modifiche alla pagina.', - 'watch_title_comments' => 'Tutti Gli Aggiornamenti Della Pagina E Commenti', - 'watch_desc_comments' => 'Notificare su tutte le nuove pagine, modifiche di pagina e nuovi commenti.', - 'watch_desc_comments_page' => 'Notificare le modifiche alla pagina e i nuovi commenti.', + 'watch_title_updates' => 'Tutti gli aggiornamenti della pagina', + 'watch_desc_updates' => 'Notifica tutte le nuove pagine e le modifiche alle pagine.', + 'watch_desc_updates_page' => 'Notifica tutte le modifiche alla pagine.', + 'watch_title_comments' => 'Tutti gli aggiornamenti delle pagine e i commenti', + 'watch_desc_comments' => 'Notifica tutte le nuove pagine, le modifiche alle pagine e i nuovi commenti.', + 'watch_desc_comments_page' => 'Notifica le modifiche alla pagina e i nuovi commenti.', 'watch_change_default' => 'Modifica le preferenze di notifica predefinite', 'watch_detail_ignore' => 'Ignorare le notifiche', - 'watch_detail_new' => 'In attesa di nuove pagine', + 'watch_detail_new' => 'Osservare le nuove pagine', 'watch_detail_updates' => 'Osservare le nuove pagine e gli aggiornamenti', 'watch_detail_comments' => 'Osservare le nuove pagine, aggiornamenti e commenti', - 'watch_detail_parent_book' => 'Osservare tramite il libro madre', - 'watch_detail_parent_book_ignore' => 'Ignorato tramite il libro madre', - 'watch_detail_parent_chapter' => 'Osservare tramite il capitolo madre', - 'watch_detail_parent_chapter_ignore' => 'Ignorato tramite il capitolo madre', + 'watch_detail_parent_book' => 'Osservare tramite il libro che lo contiene', + 'watch_detail_parent_book_ignore' => 'Ignorare tramite il libro che lo contiene', + 'watch_detail_parent_chapter' => 'Osservare tramite il capitolo che lo contiene', + 'watch_detail_parent_chapter_ignore' => 'Ignorato tramite il capitolo che lo contiene', ]; diff --git a/lang/it/errors.php b/lang/it/errors.php index fe8c4c17c33..5120a6d71e9 100644 --- a/lang/it/errors.php +++ b/lang/it/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Non puoi eliminare l\'unico admin', 'users_cannot_delete_guest' => 'Non puoi eliminare l\'utente ospite', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Impossibile creare l\'utente poiché l\'invio dell\'email di invito non è riuscito', // Roles 'role_cannot_be_edited' => 'Questo ruolo non può essere modificato', diff --git a/lang/ja/common.php b/lang/ja/common.php index 097974d8e96..4f15bafbb9d 100644 --- a/lang/ja/common.php +++ b/lang/ja/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => '利用規約', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => ':appName を検索', ]; diff --git a/lang/ja/entities.php b/lang/ja/entities.php index eda30371bac..489032835cc 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(クリーンなコンテンツ)', 'pages_edit_switch_to_markdown_stable' => '(安定したコンテンツ)', 'pages_edit_switch_to_wysiwyg' => 'WYSIWYGエディタに切り替え', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => '新しいWYSIWYGエディタに切り替える', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(アルファテスト版)', 'pages_edit_set_changelog' => '編集内容についての説明', 'pages_edit_enter_changelog_desc' => 'どのような変更を行ったのかを記録してください', 'pages_edit_enter_changelog' => '編集内容を入力', diff --git a/lang/ja/errors.php b/lang/ja/errors.php index e1d7709651c..4eba6365934 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => '唯一の管理者を削除することはできません', 'users_cannot_delete_guest' => 'ゲストユーザを削除することはできません', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => '招待メールの送信に失敗したため、ユーザーを作成できませんでした。', // Roles 'role_cannot_be_edited' => 'この役割は編集できません', diff --git a/lang/ko/activities.php b/lang/ko/activities.php index 7bdcd3c610f..bcf34795622 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -6,26 +6,26 @@ return [ // Pages - 'page_create' => '생성된 페이지', + 'page_create' => '페이지 생성', 'page_create_notification' => '페이지가 성공적으로 생성되었습니다.', - 'page_update' => '페이지 업데이트됨', + 'page_update' => '페이지 업데이트', 'page_update_notification' => '페이지가 성공적으로 업데이트되었습니다.', - 'page_delete' => '삭제된 페이지', + 'page_delete' => '페이지 삭제', 'page_delete_notification' => '페이지가 성공적으로 삭제되었습니다.', - 'page_restore' => '복구된 페이지', + 'page_restore' => '페이지 복원', 'page_restore_notification' => '페이지가 성공적으로 복원되었습니다.', - 'page_move' => '이동된 페이지', + 'page_move' => '페이지 이동', 'page_move_notification' => '페이지가 성공적으로 이동되었습니다.', // Chapters - 'chapter_create' => '챕터 만들기', + 'chapter_create' => '챕터 생성', 'chapter_create_notification' => '챕터가 성공적으로 생성되었습니다.', - 'chapter_update' => '업데이트된 챕터', - 'chapter_update_notification' => '챕터가 성공적으로 업데이트되었습니다.', - 'chapter_delete' => '삭제된 챕터', + 'chapter_update' => '챕터 수정', + 'chapter_update_notification' => '챕터가 성공적으로 수정되었습니다.', + 'chapter_delete' => '챕터 삭제', 'chapter_delete_notification' => '챕터가 성공적으로 삭제되었습니다.', - 'chapter_move' => '이동된 챕터', - 'chapter_move_notification' => '챕터를 성공적으로 이동했습니다.', + 'chapter_move' => '챕터 이동', + 'chapter_move_notification' => '페이지가 성공적으로 이동되었습니다.', // Books 'book_create' => '생성된 책', @@ -36,7 +36,7 @@ 'book_update_notification' => '책이 성공적으로 업데이트되었습니다.', 'book_delete' => '삭제된 책', 'book_delete_notification' => '책이 성공적으로 삭제되었습니다.', - 'book_sort' => '정렬된 책', + 'book_sort' => '책 정렬', 'book_sort_notification' => '책이 성공적으로 재정렬되었습니다.', // Bookshelves @@ -51,8 +51,8 @@ // Revisions 'revision_restore' => '복구된 리비전', - 'revision_delete' => '삭제된 리비전', - 'revision_delete_notification' => '리비전을 성공적으로 삭제하였습니다.', + 'revision_delete' => '버전 삭제', + 'revision_delete_notification' => '버전 삭제 성공', // Favourites 'favourite_add_notification' => '":name" 을 북마크에 추가하였습니다.', @@ -62,14 +62,14 @@ 'watch_update_level_notification' => '주시 환경설정이 성공적으로 업데이트되었습니다.', // Auth - 'auth_login' => '로그인 됨', + 'auth_login' => '로그인 완료', 'auth_register' => '신규 사용자 등록', 'auth_password_reset_request' => '사용자 비밀번호 초기화 요청', 'auth_password_reset_update' => '사용자 비밀번호 초기화', - 'mfa_setup_method' => '구성된 MFA 방법', + 'mfa_setup_method' => '다중인증(MFA)이 구성되었습니다.', 'mfa_setup_method_notification' => '다중 인증 설정함', 'mfa_remove_method' => 'MFA 메서드 제거', - 'mfa_remove_method_notification' => '다중 인증 해제함', + 'mfa_remove_method_notification' => '다중인증(MFA)이 성공적으로 제거되었습니다.', // Settings 'settings_update' => '설정 변경', @@ -80,7 +80,7 @@ 'webhook_create' => '웹 훅 만들기', 'webhook_create_notification' => '웹 훅 생성함', 'webhook_update' => '웹 훅 수정하기', - 'webhook_update_notification' => '웹 훅 수정함', + 'webhook_update_notification' => '웹훅 설정이 수정되었습니다.', 'webhook_delete' => '웹 훅 지우기', 'webhook_delete_notification' => '웹 훅 삭제함', diff --git a/lang/nl/common.php b/lang/nl/common.php index 844fe91d1af..843b02b0fb7 100644 --- a/lang/nl/common.php +++ b/lang/nl/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Algemene voorwaarden', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Zoek in :appName', ]; diff --git a/lang/nl/entities.php b/lang/nl/entities.php index 9daec1c1fc4..36955c8e1f5 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -18,8 +18,8 @@ 'create_now' => 'Maak er nu één', 'revisions' => 'Revisies', 'meta_revision' => 'Revisie #:revisionCount', - 'meta_created' => 'Gemaakt op: :timeLength', - 'meta_created_name' => 'Gemaakt op :timeLength door :user', + 'meta_created' => 'Gemaakt: :timeLength', + 'meta_created_name' => 'Gemaakt: :timeLength door :user', 'meta_updated' => 'Bijgewerkt: :timeLength', 'meta_updated_name' => 'Bijgewerkt: :timeLength door :user', 'meta_owned_name' => 'Eigendom van :user', diff --git a/lang/nl/errors.php b/lang/nl/errors.php index a6ec53883ea..0c83cb7f31f 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Je kunt niet het enige admin account verwijderen', 'users_cannot_delete_guest' => 'Je kunt het gastaccount niet verwijderen', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Kan de gebruiker niet aanmaken, uitnodigingsmail kon niet worden verzonden', // Roles 'role_cannot_be_edited' => 'Deze rol kan niet bewerkt worden', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 3f997c404bc..1d0b14b5cf5 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -208,7 +208,7 @@ 'users_avatar' => 'Avatar', 'users_avatar_desc' => 'Selecteer een afbeelding om deze gebruiker voor te stellen. Deze moet ongeveer 256px breed en vierkant zijn.', 'users_preferred_language' => 'Voorkeurstaal', - 'users_preferred_language_desc' => 'Deze optie wijzigt de taal die gebruikt wordt voor de gebruikersinterface. Dit heeft geen invloed op door gebruiker gemaakte inhoud.', + 'users_preferred_language_desc' => 'Deze optie wijzigt de taal die gebruikt wordt voor de gebruikersinterface. Dit heeft geen invloed op door gebruikers gemaakte inhoud.', 'users_social_accounts' => 'Sociale media accounts', 'users_social_accounts_desc' => 'Bekijk de status van de verbonden socialmedia-accounts voor deze gebruiker. socialmedia-accounts kunnen worden gebruikt naast het primaire authenticatiesysteem voor systeemtoegang.', 'users_social_accounts_info' => 'Hier kun je je andere accounts koppelen om sneller en eenvoudiger in te loggen. Als je hier een account loskoppelt, wordt de eerder gemachtigde toegang niet ingetrokken. Je kunt de toegang intrekken via je profielinstellingen op het gekoppelde socialemedia-account zelf.', diff --git a/lang/pl/auth.php b/lang/pl/auth.php index e56e8ab43f1..445f8198af8 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Aplikacja mobilna', 'mfa_option_totp_desc' => 'Aby korzystać z uwierzytelniania wieloskładnikowego, potrzebujesz aplikacji mobilnej, która obsługuje TOTP, takiej jak Google Authenticator, Authy lub Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Kody zapasowe', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Generuje zestaw jednorazowych kodów zapasowych, które wprowadzisz przy logowaniu, aby zweryfikować Twoją tożsamość. Upewnij się, że przechowujesz je w bezpiecznym miejscu.', 'mfa_gen_confirm_and_enable' => 'Potwierdź i włącz', 'mfa_gen_backup_codes_title' => 'Ustawienia kopii zapasowych kodów', 'mfa_gen_backup_codes_desc' => 'Przechowuj poniższą listę kodów w bezpiecznym miejscu. Przy dostępie do systemu będziesz mógł użyć jednego z kodów jako drugiego mechanizmu uwierzytelniania.', diff --git a/lang/pl/common.php b/lang/pl/common.php index 4d5a346b2b5..a690cdb041e 100644 --- a/lang/pl/common.php +++ b/lang/pl/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Warunki usługi', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Szukaj :appName', ]; diff --git a/lang/pl/entities.php b/lang/pl/entities.php index 0941a1fb992..f0ff5a251f5 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Czysta zawartość)', 'pages_edit_switch_to_markdown_stable' => '(Statyczna zawartość)', 'pages_edit_switch_to_wysiwyg' => 'Przełącz na edytor WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Przełącz na nowy WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Alfa testy)', 'pages_edit_set_changelog' => 'Ustaw dziennik zmian', 'pages_edit_enter_changelog_desc' => 'Opisz zmiany, które zostały wprowadzone', 'pages_edit_enter_changelog' => 'Wyświetl dziennik zmian', diff --git a/lang/pl/errors.php b/lang/pl/errors.php index 5fb78a98334..71f592ad683 100644 --- a/lang/pl/errors.php +++ b/lang/pl/errors.php @@ -37,7 +37,7 @@ 'social_driver_not_found' => 'Funkcja społecznościowa nie została odnaleziona', 'social_driver_not_configured' => 'Ustawienia konta :socialAccount nie są poprawne.', 'invite_token_expired' => 'Zaproszenie wygasło. Możesz spróować zresetować swoje hasło.', - 'login_user_not_found' => 'A user for this action could not be found.', + 'login_user_not_found' => 'Użytkownik dla tej akcji nie został znaleziony.', // System 'path_not_writable' => 'Zapis do ścieżki :filePath jest niemożliwy. Upewnij się że aplikacja ma prawa do zapisu plików na serwerze.', @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Nie możesz usunąć jedynego administratora', 'users_cannot_delete_guest' => 'Nie możesz usunąć użytkownika-gościa', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Nie można utworzyć użytkownika, ponieważ nie udało się wysłać wiadomości e-mail z zaproszeniem', // Roles 'role_cannot_be_edited' => 'Ta rola nie może być edytowana', diff --git a/lang/pt_BR/editor.php b/lang/pt_BR/editor.php index 80ca8e9a310..42e20af87df 100644 --- a/lang/pt_BR/editor.php +++ b/lang/pt_BR/editor.php @@ -31,7 +31,7 @@ 'header_large' => 'Cabeçalho Grande', 'header_medium' => 'Cabeçalho Médio', 'header_small' => 'Cabeçalho Pequeno', - 'header_tiny' => 'Cabeçalho Pequeno', + 'header_tiny' => 'Cabeçalho Minúsculo', 'paragraph' => 'Parágrafo', 'blockquote' => 'Bloco de Citação', 'inline_code' => 'Código embutido', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index 41d4516b900..d23f677d9f5 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -6,88 +6,88 @@ return [ // Shared - 'recently_created' => 'Criados Recentemente', - 'recently_created_pages' => 'Páginas Criadas Recentemente', - 'recently_updated_pages' => 'Páginas Atualizadas Recentemente', - 'recently_created_chapters' => 'Capítulos Criados Recentemente', - 'recently_created_books' => 'Livros Criados Recentemente', + 'recently_created' => 'Criado Recentemente', + 'recently_created_pages' => 'Páginas Recentemente Criadas', + 'recently_updated_pages' => 'Páginas Recentemente Atualizadas', + 'recently_created_chapters' => 'Capítulos Recentemente Criados', + 'recently_created_books' => 'Livros Recentemente Criados', 'recently_created_shelves' => 'Prateleiras Criadas Recentemente', - 'recently_update' => 'Atualizados Recentemente', - 'recently_viewed' => 'Visualizados Recentemente', - 'recent_activity' => 'Atividade Recente', - 'create_now' => 'Criar um agora', + 'recently_update' => 'Recentemente Atualizado', + 'recently_viewed' => 'Recentemente Visualizado', + 'recent_activity' => 'Atividades recentes', + 'create_now' => 'Criar agora', 'revisions' => 'Revisões', - 'meta_revision' => 'Revisão #:revisionCount', - 'meta_created' => 'Criado :timeLength', - 'meta_created_name' => 'Criado :timeLength por :user', - 'meta_updated' => 'Atualizado :timeLength', - 'meta_updated_name' => 'Atualizado :timeLength por :user', - 'meta_owned_name' => 'De :user', - 'meta_reference_count' => 'Referenciado no item :count|Referenciado nos items :count', - 'entity_select' => 'Seleção de Entidade', - 'entity_select_lack_permission' => 'Você não tem as permissões necessárias para selecionar este item', + 'meta_revision' => 'Revisão #: contagem de revisões', + 'meta_created' => 'Criado: duração de tempo', + 'meta_created_name' => 'Criado: duração de tempo por usuário', + 'meta_updated' => 'Atualizado: duração de tempo', + 'meta_updated_name' => 'Atualizado: duração de tempo por usuário', + 'meta_owned_name' => 'Propriedade de: usuário', + 'meta_reference_count' => 'Referenciado por: count item|Referenciado por: count itens', + 'entity_select' => 'Seleção de entidade', + 'entity_select_lack_permission' => 'Você não tem as permissões necessárias para selecionar este ‘item’', 'images' => 'Imagens', 'my_recent_drafts' => 'Meus Rascunhos Recentes', - 'my_recently_viewed' => 'Visualizados por mim Recentemente', + 'my_recently_viewed' => 'Visualizados Recentemente', 'my_most_viewed_favourites' => 'Meus Favoritos Mais Visualizados', - 'my_favourites' => 'Meus Favoritos', + 'my_favourites' => 'Favoritos', 'no_pages_viewed' => 'Você não visualizou nenhuma página', - 'no_pages_recently_created' => 'Nenhuma página criada recentemente', - 'no_pages_recently_updated' => 'Nenhuma página atualizada recentemente', + 'no_pages_recently_created' => 'Nenhuma página foi criada recentemente', + 'no_pages_recently_updated' => 'Nenhuma página foi atualizada recentemente', 'export' => 'Exportar', - 'export_html' => 'Arquivo Web Contained', + 'export_html' => 'Arquivo Web Contido', 'export_pdf' => 'Arquivo PDF', - 'export_text' => 'Arquivo Texto', - 'export_md' => 'Arquivos para remarcar', - 'default_template' => 'Modelo de página padrão', - 'default_template_explain' => 'Atribuir o modelo de página que sera usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.', - 'default_template_select' => 'Selecione o modelo de página', + 'export_text' => 'Arquivo de texto simples', + 'export_md' => 'Arquivo de redução', + 'default_template' => 'Modelo padrão de página', + 'default_template_explain' => 'Atribuir o modelo de página que será usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.', + 'default_template_select' => 'Selecione uma página de modelo', // Permissions and restrictions 'permissions' => 'Permissões', - 'permissions_desc' => 'Defina as permissões aqui para substituir as permissões padrão fornecidas pelas funções do usuário.', - 'permissions_book_cascade' => 'As permissões definidas em livros serão automaticamente em cascata para capítulos e páginas filho, a menos que tenham suas próprias permissões definidas.', - 'permissions_chapter_cascade' => 'As permissões definidas nos capítulos serão automaticamente em cascata para as páginas filhas, a menos que tenham suas próprias permissões definidas.', - 'permissions_save' => 'Salvar Permissões', - 'permissions_owner' => 'Proprietário', - 'permissions_role_everyone_else' => 'Todos os outros', - 'permissions_role_everyone_else_desc' => 'Defina permissões para todas as funções não especificamente substituídas.', - 'permissions_role_override' => 'Substituir permissões para função', - 'permissions_inherit_defaults' => 'Herdar padrões', + 'permissions_desc' => 'Defina permissões aqui para substituir as permissões padrão fornecidas pelas funções do usuário.', + 'permissions_book_cascade' => 'As permissões definidas nos livros serão automaticamente transmitidas aos capítulos e páginas secundários, a menos que eles tenham suas próprias permissões definidas.', + 'permissions_chapter_cascade' => 'As permissões definidas nos capítulos serão automaticamente transmitidas às páginas secundárias, a menos que elas tenham suas próprias permissões definidas.', + 'permissions_save' => 'Salvar permissões', + 'permissions_owner' => 'Dono', + 'permissions_role_everyone_else' => 'Todos os Outros', + 'permissions_role_everyone_else_desc' => 'Defina permissões para todas as funções não substituídas especificamente.', + 'permissions_role_override' => 'Substituir permissões para o papel', + 'permissions_inherit_defaults' => 'Herdar Padrões', // Search 'search_results' => 'Resultado(s) da Pesquisa', - 'search_total_results_found' => ':count resultado encontrado|:count resultados encontrados', + 'search_total_results_found' => ':count resultado encontrado (contagem de resultados) :count resultados totais encontrados', 'search_clear' => 'Limpar Pesquisa', - 'search_no_pages' => 'Nenhuma página corresponde à pesquisa', - 'search_for_term' => 'Pesquisar por :term', - 'search_more' => 'Mais Resultados', - 'search_advanced' => 'Pesquisa Avançada', - 'search_terms' => 'Termos da Pesquisa', - 'search_content_type' => 'Tipo de Conteúdo', - 'search_exact_matches' => 'Correspondências Exatas', - 'search_tags' => 'Persquisar Tags', + 'search_no_pages' => 'Nenhuma página corresponde a esta pesquisa', + 'search_for_term' => 'Pesquisar por: termo', + 'search_more' => 'Mais resultados', + 'search_advanced' => 'Pesquisa avançada', + 'search_terms' => 'Termos da pesquisa', + 'search_content_type' => 'Categoria de conteúdo', + 'search_exact_matches' => 'Correspondências exatas', + 'search_tags' => 'Etiqueta de buscas', 'search_options' => 'Opções', - 'search_viewed_by_me' => 'Visualizado por mim', - 'search_not_viewed_by_me' => 'Não visualizado por mim', - 'search_permissions_set' => 'Permissão definida', - 'search_created_by_me' => 'Criado por mim', - 'search_updated_by_me' => 'Atualizado por mim', - 'search_owned_by_me' => 'Possuído por mim', + 'search_viewed_by_me' => 'Visto por mim', + 'search_not_viewed_by_me' => 'Não visto por mim', + 'search_permissions_set' => 'Permissões definidas', + 'search_created_by_me' => 'Criados por mim', + 'search_updated_by_me' => 'Atualizados por mim', + 'search_owned_by_me' => 'Meus itens', 'search_date_options' => 'Opções de Data', - 'search_updated_before' => 'Atualizado antes de', - 'search_updated_after' => 'Atualizado depois de', - 'search_created_before' => 'Criado antes de', - 'search_created_after' => 'Criado depois de', + 'search_updated_before' => 'Atualizado antes', + 'search_updated_after' => 'Atualizado depois', + 'search_created_before' => 'Criado antes', + 'search_created_after' => 'Criado depois', 'search_set_date' => 'Definir Data', - 'search_update' => 'Refazer Pesquisa', + 'search_update' => 'Atualizar pesquisa', // Shelves 'shelf' => 'Estante', 'shelves' => 'Estantes', - 'x_shelves' => ':count Estante|:count Estantes', + 'x_shelves' => ': count Estante|: count Estantes', 'shelves_empty' => 'Nenhuma estante foi criada', - 'shelves_create' => 'Criar Nova Estante', + 'shelves_create' => 'Criar Prateleira', 'shelves_popular' => 'Estantes Populares', 'shelves_new' => 'Novas Estantes', 'shelves_new_action' => 'Nova Estante', @@ -111,7 +111,7 @@ 'shelves_permissions_cascade_warning' => 'As permissões nas prateleiras não são automaticamente em cascata para os livros contidos. Isso ocorre porque um livro pode existir em várias prateleiras. No entanto, as permissões podem ser copiadas para livros filhos usando a opção encontrada abaixo.', 'shelves_permissions_create' => 'As permissões de criação de prateleira são usadas apenas para copiar livros filhos usando a ação abaixo. Eles não controlam a capacidade de criar livros.', 'shelves_copy_permissions_to_books' => 'Copiar Permissões para Livros', - 'shelves_copy_permissions' => 'Copiar Permissões', + 'shelves_copy_permissions' => 'Copiar permissões', 'shelves_copy_permissions_explain' => 'Isso aplicará as configurações de permissão atuais desta estante a todos os livros contidos nela. Antes de ativar, verifique se todas as alterações nas permissões desta prateleira foram salvas.', 'shelves_copy_permission_success' => 'Permissões de prateleira copiadas para :count books', @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Conteúdo Limpo)', 'pages_edit_switch_to_markdown_stable' => '(Conteúdo Estável)', 'pages_edit_switch_to_wysiwyg' => 'Alternar para o Editor WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Mudar para o novo WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(No Teste Alfa)', 'pages_edit_set_changelog' => 'Relatar Alterações', 'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por você', 'pages_edit_enter_changelog' => 'Insira Alterações', diff --git a/lang/tk/activities.php b/lang/tk/activities.php new file mode 100644 index 00000000000..092398ef0e1 --- /dev/null +++ b/lang/tk/activities.php @@ -0,0 +1,124 @@ + 'created page', + 'page_create_notification' => 'Page successfully created', + 'page_update' => 'updated page', + 'page_update_notification' => 'Page successfully updated', + 'page_delete' => 'deleted page', + 'page_delete_notification' => 'Page successfully deleted', + 'page_restore' => 'restored page', + 'page_restore_notification' => 'Page successfully restored', + 'page_move' => 'moved page', + 'page_move_notification' => 'Page successfully moved', + + // Chapters + 'chapter_create' => 'created chapter', + 'chapter_create_notification' => 'Chapter successfully created', + 'chapter_update' => 'updated chapter', + 'chapter_update_notification' => 'Chapter successfully updated', + 'chapter_delete' => 'deleted chapter', + 'chapter_delete_notification' => 'Chapter successfully deleted', + 'chapter_move' => 'moved chapter', + 'chapter_move_notification' => 'Chapter successfully moved', + + // Books + 'book_create' => 'created book', + 'book_create_notification' => 'Book successfully created', + 'book_create_from_chapter' => 'converted chapter to book', + 'book_create_from_chapter_notification' => 'Chapter successfully converted to a book', + 'book_update' => 'updated book', + 'book_update_notification' => 'Book successfully updated', + 'book_delete' => 'deleted book', + 'book_delete_notification' => 'Book successfully deleted', + 'book_sort' => 'sorted book', + 'book_sort_notification' => 'Book successfully re-sorted', + + // Bookshelves + 'bookshelf_create' => 'created shelf', + 'bookshelf_create_notification' => 'Shelf successfully created', + 'bookshelf_create_from_book' => 'converted book to shelf', + 'bookshelf_create_from_book_notification' => 'Book successfully converted to a shelf', + 'bookshelf_update' => 'updated shelf', + 'bookshelf_update_notification' => 'Shelf successfully updated', + 'bookshelf_delete' => 'deleted shelf', + 'bookshelf_delete_notification' => 'Shelf successfully deleted', + + // Revisions + 'revision_restore' => 'restored revision', + 'revision_delete' => 'deleted revision', + 'revision_delete_notification' => 'Revision successfully deleted', + + // Favourites + 'favourite_add_notification' => '":name" has been added to your favourites', + 'favourite_remove_notification' => '":name" has been removed from your favourites', + + // Watching + 'watch_update_level_notification' => 'Watch preferences successfully updated', + + // Auth + 'auth_login' => 'logged in', + 'auth_register' => 'registered as new user', + 'auth_password_reset_request' => 'requested user password reset', + 'auth_password_reset_update' => 'reset user password', + 'mfa_setup_method' => 'configured MFA method', + 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', + 'mfa_remove_method' => 'removed MFA method', + 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + + // Settings + 'settings_update' => 'updated settings', + 'settings_update_notification' => 'Settings successfully updated', + 'maintenance_action_run' => 'ran maintenance action', + + // Webhooks + 'webhook_create' => 'created webhook', + 'webhook_create_notification' => 'Webhook successfully created', + 'webhook_update' => 'updated webhook', + 'webhook_update_notification' => 'Webhook successfully updated', + 'webhook_delete' => 'deleted webhook', + 'webhook_delete_notification' => 'Webhook successfully deleted', + + // Users + 'user_create' => 'created user', + 'user_create_notification' => 'User successfully created', + 'user_update' => 'updated user', + 'user_update_notification' => 'User successfully updated', + 'user_delete' => 'deleted user', + 'user_delete_notification' => 'User successfully removed', + + // API Tokens + 'api_token_create' => 'created API token', + 'api_token_create_notification' => 'API token successfully created', + 'api_token_update' => 'updated API token', + 'api_token_update_notification' => 'API token successfully updated', + 'api_token_delete' => 'deleted API token', + 'api_token_delete_notification' => 'API token successfully deleted', + + // Roles + 'role_create' => 'created role', + 'role_create_notification' => 'Role successfully created', + 'role_update' => 'updated role', + 'role_update_notification' => 'Role successfully updated', + 'role_delete' => 'deleted role', + 'role_delete_notification' => 'Role successfully deleted', + + // Recycle Bin + 'recycle_bin_empty' => 'emptied recycle bin', + 'recycle_bin_restore' => 'restored from recycle bin', + 'recycle_bin_destroy' => 'removed from recycle bin', + + // Comments + 'commented_on' => 'commented on', + 'comment_create' => 'added comment', + 'comment_update' => 'updated comment', + 'comment_delete' => 'deleted comment', + + // Other + 'permissions_update' => 'updated permissions', +]; diff --git a/lang/tk/auth.php b/lang/tk/auth.php new file mode 100644 index 00000000000..57f0cb5c632 --- /dev/null +++ b/lang/tk/auth.php @@ -0,0 +1,117 @@ + 'These credentials do not match our records.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + + // Login & Register + 'sign_up' => 'Sign up', + 'log_in' => 'Log in', + 'log_in_with' => 'Login with :socialDriver', + 'sign_up_with' => 'Sign up with :socialDriver', + 'logout' => 'Logout', + + 'name' => 'Name', + 'username' => 'Username', + 'email' => 'Email', + 'password' => 'Password', + 'password_confirm' => 'Confirm Password', + 'password_hint' => 'Must be at least 8 characters', + 'forgot_password' => 'Forgot Password?', + 'remember_me' => 'Remember Me', + 'ldap_email_hint' => 'Please enter an email to use for this account.', + 'create_account' => 'Create Account', + 'already_have_account' => 'Already have an account?', + 'dont_have_account' => 'Don\'t have an account?', + 'social_login' => 'Social Login', + 'social_registration' => 'Social Registration', + 'social_registration_text' => 'Register and sign in using another service.', + + 'register_thanks' => 'Thanks for registering!', + 'register_confirm' => 'Please check your email and click the confirmation button to access :appName.', + 'registrations_disabled' => 'Registrations are currently disabled', + 'registration_email_domain_invalid' => 'That email domain does not have access to this application', + 'register_success' => 'Thanks for signing up! You are now registered and signed in.', + + // Login auto-initiation + 'auto_init_starting' => 'Attempting Login', + 'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.', + 'auto_init_start_link' => 'Proceed with authentication', + + // Password Reset + 'reset_password' => 'Reset Password', + 'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.', + 'reset_password_send_button' => 'Send Reset Link', + 'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.', + 'reset_password_success' => 'Your password has been successfully reset.', + 'email_reset_subject' => 'Reset your :appName password', + 'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.', + 'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.', + + // Email Confirmation + 'email_confirm_subject' => 'Confirm your email on :appName', + 'email_confirm_greeting' => 'Thanks for joining :appName!', + 'email_confirm_text' => 'Please confirm your email address by clicking the button below:', + 'email_confirm_action' => 'Confirm Email', + 'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.', + 'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.', + 'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.', + 'email_confirm_thanks' => 'Thanks for confirming!', + 'email_confirm_thanks_desc' => 'Please wait a moment while your confirmation is handled. If you are not redirected after 3 seconds press the "Continue" link below to proceed.', + + 'email_not_confirmed' => 'Email Address Not Confirmed', + 'email_not_confirmed_text' => 'Your email address has not yet been confirmed.', + 'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.', + 'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.', + 'email_not_confirmed_resend_button' => 'Resend Confirmation Email', + + // User Invite + 'user_invite_email_subject' => 'You have been invited to join :appName!', + 'user_invite_email_greeting' => 'An account has been created for you on :appName.', + 'user_invite_email_text' => 'Click the button below to set an account password and gain access:', + 'user_invite_email_action' => 'Set Account Password', + 'user_invite_page_welcome' => 'Welcome to :appName!', + 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', + 'user_invite_page_confirm_button' => 'Confirm Password', + 'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!', + + // Multi-factor Authentication + 'mfa_setup' => 'Setup Multi-Factor Authentication', + 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'mfa_setup_configured' => 'Already configured', + 'mfa_setup_reconfigure' => 'Reconfigure', + 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', + 'mfa_setup_action' => 'Setup', + 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', + 'mfa_option_totp_title' => 'Mobile App', + 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Backup Codes', + 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', + 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', + 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', + 'mfa_gen_backup_codes_download' => 'Download Codes', + 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', + 'mfa_gen_totp_title' => 'Mobile App Setup', + 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', + 'mfa_gen_totp_verify_setup' => 'Verify Setup', + 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', + 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', + 'mfa_verify_access' => 'Verify Access', + 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', + 'mfa_verify_no_methods' => 'No Methods Configured', + 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', + 'mfa_verify_use_totp' => 'Verify using a mobile app', + 'mfa_verify_use_backup_codes' => 'Verify using a backup code', + 'mfa_verify_backup_code' => 'Backup Code', + 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', + 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', + 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', + 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', +]; diff --git a/lang/tk/common.php b/lang/tk/common.php new file mode 100644 index 00000000000..b05169bb2c4 --- /dev/null +++ b/lang/tk/common.php @@ -0,0 +1,113 @@ + 'Cancel', + 'close' => 'Close', + 'confirm' => 'Confirm', + 'back' => 'Back', + 'save' => 'Save', + 'continue' => 'Continue', + 'select' => 'Select', + 'toggle_all' => 'Toggle All', + 'more' => 'More', + + // Form Labels + 'name' => 'Name', + 'description' => 'Description', + 'role' => 'Role', + 'cover_image' => 'Cover image', + 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + + // Actions + 'actions' => 'Actions', + 'view' => 'View', + 'view_all' => 'View All', + 'new' => 'New', + 'create' => 'Create', + 'update' => 'Update', + 'edit' => 'Edit', + 'sort' => 'Sort', + 'move' => 'Move', + 'copy' => 'Copy', + 'reply' => 'Reply', + 'delete' => 'Delete', + 'delete_confirm' => 'Confirm Deletion', + 'search' => 'Search', + 'search_clear' => 'Clear Search', + 'reset' => 'Reset', + 'remove' => 'Remove', + 'add' => 'Add', + 'configure' => 'Configure', + 'manage' => 'Manage', + 'fullscreen' => 'Fullscreen', + 'favourite' => 'Favourite', + 'unfavourite' => 'Unfavourite', + 'next' => 'Next', + 'previous' => 'Previous', + 'filter_active' => 'Active Filter:', + 'filter_clear' => 'Clear Filter', + 'download' => 'Download', + 'open_in_tab' => 'Open in Tab', + 'open' => 'Open', + + // Sort Options + 'sort_options' => 'Sort Options', + 'sort_direction_toggle' => 'Sort Direction Toggle', + 'sort_ascending' => 'Sort Ascending', + 'sort_descending' => 'Sort Descending', + 'sort_name' => 'Name', + 'sort_default' => 'Default', + 'sort_created_at' => 'Created Date', + 'sort_updated_at' => 'Updated Date', + + // Misc + 'deleted_user' => 'Deleted User', + 'no_activity' => 'No activity to show', + 'no_items' => 'No items available', + 'back_to_top' => 'Back to top', + 'skip_to_main_content' => 'Skip to main content', + 'toggle_details' => 'Toggle Details', + 'toggle_thumbnails' => 'Toggle Thumbnails', + 'details' => 'Details', + 'grid_view' => 'Grid View', + 'list_view' => 'List View', + 'default' => 'Default', + 'breadcrumb' => 'Breadcrumb', + 'status' => 'Status', + 'status_active' => 'Active', + 'status_inactive' => 'Inactive', + 'never' => 'Never', + 'none' => 'None', + + // Header + 'homepage' => 'Homepage', + 'header_menu_expand' => 'Expand Header Menu', + 'profile_menu' => 'Profile Menu', + 'view_profile' => 'View Profile', + 'edit_profile' => 'Edit Profile', + 'dark_mode' => 'Dark Mode', + 'light_mode' => 'Light Mode', + 'global_search' => 'Global Search', + + // Layout tabs + 'tab_info' => 'Info', + 'tab_info_label' => 'Tab: Show Secondary Information', + 'tab_content' => 'Content', + 'tab_content_label' => 'Tab: Show Primary Content', + + // Email Content + 'email_action_help' => 'If you’re having trouble clicking the ":actionText" button, copy and paste the URL below into your web browser:', + 'email_rights' => 'All rights reserved', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'Privacy Policy', + 'terms_of_service' => 'Terms of Service', + + // OpenSearch + 'opensearch_description' => 'Search :appName', +]; diff --git a/lang/tk/components.php b/lang/tk/components.php new file mode 100644 index 00000000000..c33b1d0b791 --- /dev/null +++ b/lang/tk/components.php @@ -0,0 +1,46 @@ + 'Image Select', + 'image_list' => 'Image List', + 'image_details' => 'Image Details', + 'image_upload' => 'Upload Image', + 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', + 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_all' => 'All', + 'image_all_title' => 'View all images', + 'image_book_title' => 'View images uploaded to this book', + 'image_page_title' => 'View images uploaded to this page', + 'image_search_hint' => 'Search by image name', + 'image_uploaded' => 'Uploaded :uploadedDate', + 'image_uploaded_by' => 'Uploaded by :userName', + 'image_uploaded_to' => 'Uploaded to :pageLink', + 'image_updated' => 'Updated :updateDate', + 'image_load_more' => 'Load More', + 'image_image_name' => 'Image Name', + 'image_delete_used' => 'This image is used in the pages below.', + 'image_delete_confirm_text' => 'Are you sure you want to delete this image?', + 'image_select_image' => 'Select Image', + 'image_dropzone' => 'Drop images or click here to upload', + 'image_dropzone_drop' => 'Drop images here to upload', + 'images_deleted' => 'Images Deleted', + 'image_preview' => 'Image Preview', + 'image_upload_success' => 'Image uploaded successfully', + 'image_update_success' => 'Image details successfully updated', + 'image_delete_success' => 'Image successfully deleted', + 'image_replace' => 'Replace Image', + 'image_replace_success' => 'Image file successfully updated', + 'image_rebuild_thumbs' => 'Regenerate Size Variations', + 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + + // Code Editor + 'code_editor' => 'Edit Code', + 'code_language' => 'Code Language', + 'code_content' => 'Code Content', + 'code_session_history' => 'Session History', + 'code_save' => 'Save Code', +]; diff --git a/lang/tk/editor.php b/lang/tk/editor.php new file mode 100644 index 00000000000..de9aa0ece10 --- /dev/null +++ b/lang/tk/editor.php @@ -0,0 +1,177 @@ + 'General', + 'advanced' => 'Advanced', + 'none' => 'None', + 'cancel' => 'Cancel', + 'save' => 'Save', + 'close' => 'Close', + 'undo' => 'Undo', + 'redo' => 'Redo', + 'left' => 'Left', + 'center' => 'Center', + 'right' => 'Right', + 'top' => 'Top', + 'middle' => 'Middle', + 'bottom' => 'Bottom', + 'width' => 'Width', + 'height' => 'Height', + 'More' => 'More', + 'select' => 'Select...', + + // Toolbar + 'formats' => 'Formats', + 'header_large' => 'Large Header', + 'header_medium' => 'Medium Header', + 'header_small' => 'Small Header', + 'header_tiny' => 'Tiny Header', + 'paragraph' => 'Paragraph', + 'blockquote' => 'Blockquote', + 'inline_code' => 'Inline code', + 'callouts' => 'Callouts', + 'callout_information' => 'Information', + 'callout_success' => 'Success', + 'callout_warning' => 'Warning', + 'callout_danger' => 'Danger', + 'bold' => 'Bold', + 'italic' => 'Italic', + 'underline' => 'Underline', + 'strikethrough' => 'Strikethrough', + 'superscript' => 'Superscript', + 'subscript' => 'Subscript', + 'text_color' => 'Text color', + 'custom_color' => 'Custom color', + 'remove_color' => 'Remove color', + 'background_color' => 'Background color', + 'align_left' => 'Align left', + 'align_center' => 'Align center', + 'align_right' => 'Align right', + 'align_justify' => 'Justify', + 'list_bullet' => 'Bullet list', + 'list_numbered' => 'Numbered list', + 'list_task' => 'Task list', + 'indent_increase' => 'Increase indent', + 'indent_decrease' => 'Decrease indent', + 'table' => 'Table', + 'insert_image' => 'Insert image', + 'insert_image_title' => 'Insert/Edit Image', + 'insert_link' => 'Insert/edit link', + 'insert_link_title' => 'Insert/Edit Link', + 'insert_horizontal_line' => 'Insert horizontal line', + 'insert_code_block' => 'Insert code block', + 'edit_code_block' => 'Edit code block', + 'insert_drawing' => 'Insert/edit drawing', + 'drawing_manager' => 'Drawing manager', + 'insert_media' => 'Insert/edit media', + 'insert_media_title' => 'Insert/Edit Media', + 'clear_formatting' => 'Clear formatting', + 'source_code' => 'Source code', + 'source_code_title' => 'Source Code', + 'fullscreen' => 'Fullscreen', + 'image_options' => 'Image options', + + // Tables + 'table_properties' => 'Table properties', + 'table_properties_title' => 'Table Properties', + 'delete_table' => 'Delete table', + 'table_clear_formatting' => 'Clear table formatting', + 'resize_to_contents' => 'Resize to contents', + 'row_header' => 'Row header', + 'insert_row_before' => 'Insert row before', + 'insert_row_after' => 'Insert row after', + 'delete_row' => 'Delete row', + 'insert_column_before' => 'Insert column before', + 'insert_column_after' => 'Insert column after', + 'delete_column' => 'Delete column', + 'table_cell' => 'Cell', + 'table_row' => 'Row', + 'table_column' => 'Column', + 'cell_properties' => 'Cell properties', + 'cell_properties_title' => 'Cell Properties', + 'cell_type' => 'Cell type', + 'cell_type_cell' => 'Cell', + 'cell_scope' => 'Scope', + 'cell_type_header' => 'Header cell', + 'merge_cells' => 'Merge cells', + 'split_cell' => 'Split cell', + 'table_row_group' => 'Row Group', + 'table_column_group' => 'Column Group', + 'horizontal_align' => 'Horizontal align', + 'vertical_align' => 'Vertical align', + 'border_width' => 'Border width', + 'border_style' => 'Border style', + 'border_color' => 'Border color', + 'row_properties' => 'Row properties', + 'row_properties_title' => 'Row Properties', + 'cut_row' => 'Cut row', + 'copy_row' => 'Copy row', + 'paste_row_before' => 'Paste row before', + 'paste_row_after' => 'Paste row after', + 'row_type' => 'Row type', + 'row_type_header' => 'Header', + 'row_type_body' => 'Body', + 'row_type_footer' => 'Footer', + 'alignment' => 'Alignment', + 'cut_column' => 'Cut column', + 'copy_column' => 'Copy column', + 'paste_column_before' => 'Paste column before', + 'paste_column_after' => 'Paste column after', + 'cell_padding' => 'Cell padding', + 'cell_spacing' => 'Cell spacing', + 'caption' => 'Caption', + 'show_caption' => 'Show caption', + 'constrain' => 'Constrain proportions', + 'cell_border_solid' => 'Solid', + 'cell_border_dotted' => 'Dotted', + 'cell_border_dashed' => 'Dashed', + 'cell_border_double' => 'Double', + 'cell_border_groove' => 'Groove', + 'cell_border_ridge' => 'Ridge', + 'cell_border_inset' => 'Inset', + 'cell_border_outset' => 'Outset', + 'cell_border_none' => 'None', + 'cell_border_hidden' => 'Hidden', + + // Images, links, details/summary & embed + 'source' => 'Source', + 'alt_desc' => 'Alternative description', + 'embed' => 'Embed', + 'paste_embed' => 'Paste your embed code below:', + 'url' => 'URL', + 'text_to_display' => 'Text to display', + 'title' => 'Title', + 'open_link' => 'Open link', + 'open_link_in' => 'Open link in...', + 'open_link_current' => 'Current window', + 'open_link_new' => 'New window', + 'remove_link' => 'Remove link', + 'insert_collapsible' => 'Insert collapsible block', + 'collapsible_unwrap' => 'Unwrap', + 'edit_label' => 'Edit label', + 'toggle_open_closed' => 'Toggle open/closed', + 'collapsible_edit' => 'Edit collapsible block', + 'toggle_label' => 'Toggle label', + + // About view + 'about' => 'About the editor', + 'about_title' => 'About the WYSIWYG Editor', + 'editor_license' => 'Editor License & Copyright', + 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', + 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', + 'save_continue' => 'Save Page & Continue', + 'callouts_cycle' => '(Keep pressing to toggle through types)', + 'link_selector' => 'Link to content', + 'shortcuts' => 'Shortcuts', + 'shortcut' => 'Shortcut', + 'shortcuts_intro' => 'The following shortcuts are available in the editor:', + 'windows_linux' => '(Windows/Linux)', + 'mac' => '(Mac)', + 'description' => 'Description', +]; diff --git a/lang/tk/entities.php b/lang/tk/entities.php new file mode 100644 index 00000000000..35e6f050bb8 --- /dev/null +++ b/lang/tk/entities.php @@ -0,0 +1,439 @@ + 'Recently Created', + 'recently_created_pages' => 'Recently Created Pages', + 'recently_updated_pages' => 'Recently Updated Pages', + 'recently_created_chapters' => 'Recently Created Chapters', + 'recently_created_books' => 'Recently Created Books', + 'recently_created_shelves' => 'Recently Created Shelves', + 'recently_update' => 'Recently Updated', + 'recently_viewed' => 'Recently Viewed', + 'recent_activity' => 'Recent Activity', + 'create_now' => 'Create one now', + 'revisions' => 'Revisions', + 'meta_revision' => 'Revision #:revisionCount', + 'meta_created' => 'Created :timeLength', + 'meta_created_name' => 'Created :timeLength by :user', + 'meta_updated' => 'Updated :timeLength', + 'meta_updated_name' => 'Updated :timeLength by :user', + 'meta_owned_name' => 'Owned by :user', + 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'entity_select' => 'Entity Select', + 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', + 'images' => 'Images', + 'my_recent_drafts' => 'My Recent Drafts', + 'my_recently_viewed' => 'My Recently Viewed', + 'my_most_viewed_favourites' => 'My Most Viewed Favourites', + 'my_favourites' => 'My Favourites', + 'no_pages_viewed' => 'You have not viewed any pages', + 'no_pages_recently_created' => 'No pages have been recently created', + 'no_pages_recently_updated' => 'No pages have been recently updated', + 'export' => 'Export', + 'export_html' => 'Contained Web File', + 'export_pdf' => 'PDF File', + 'export_text' => 'Plain Text File', + 'export_md' => 'Markdown File', + 'default_template' => 'Default Page Template', + 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', + 'default_template_select' => 'Select a template page', + + // Permissions and restrictions + 'permissions' => 'Permissions', + 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', + 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', + 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', + 'permissions_save' => 'Save Permissions', + 'permissions_owner' => 'Owner', + 'permissions_role_everyone_else' => 'Everyone Else', + 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', + 'permissions_role_override' => 'Override permissions for role', + 'permissions_inherit_defaults' => 'Inherit defaults', + + // Search + 'search_results' => 'Search Results', + 'search_total_results_found' => ':count result found|:count total results found', + 'search_clear' => 'Clear Search', + 'search_no_pages' => 'No pages matched this search', + 'search_for_term' => 'Search for :term', + 'search_more' => 'More Results', + 'search_advanced' => 'Advanced Search', + 'search_terms' => 'Search Terms', + 'search_content_type' => 'Content Type', + 'search_exact_matches' => 'Exact Matches', + 'search_tags' => 'Tag Searches', + 'search_options' => 'Options', + 'search_viewed_by_me' => 'Viewed by me', + 'search_not_viewed_by_me' => 'Not viewed by me', + 'search_permissions_set' => 'Permissions set', + 'search_created_by_me' => 'Created by me', + 'search_updated_by_me' => 'Updated by me', + 'search_owned_by_me' => 'Owned by me', + 'search_date_options' => 'Date Options', + 'search_updated_before' => 'Updated before', + 'search_updated_after' => 'Updated after', + 'search_created_before' => 'Created before', + 'search_created_after' => 'Created after', + 'search_set_date' => 'Set Date', + 'search_update' => 'Update Search', + + // Shelves + 'shelf' => 'Shelf', + 'shelves' => 'Shelves', + 'x_shelves' => ':count Shelf|:count Shelves', + 'shelves_empty' => 'No shelves have been created', + 'shelves_create' => 'Create New Shelf', + 'shelves_popular' => 'Popular Shelves', + 'shelves_new' => 'New Shelves', + 'shelves_new_action' => 'New Shelf', + 'shelves_popular_empty' => 'The most popular shelves will appear here.', + 'shelves_new_empty' => 'The most recently created shelves will appear here.', + 'shelves_save' => 'Save Shelf', + 'shelves_books' => 'Books on this shelf', + 'shelves_add_books' => 'Add books to this shelf', + 'shelves_drag_books' => 'Drag books below to add them to this shelf', + 'shelves_empty_contents' => 'This shelf has no books assigned to it', + 'shelves_edit_and_assign' => 'Edit shelf to assign books', + 'shelves_edit_named' => 'Edit Shelf :name', + 'shelves_edit' => 'Edit Shelf', + 'shelves_delete' => 'Delete Shelf', + 'shelves_delete_named' => 'Delete Shelf :name', + 'shelves_delete_explain' => "This will delete the shelf with the name ':name'. Contained books will not be deleted.", + 'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?', + 'shelves_permissions' => 'Shelf Permissions', + 'shelves_permissions_updated' => 'Shelf Permissions Updated', + 'shelves_permissions_active' => 'Shelf Permissions Active', + 'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', + 'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.', + 'shelves_copy_permissions_to_books' => 'Copy Permissions to Books', + 'shelves_copy_permissions' => 'Copy Permissions', + 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', + 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + + // Books + 'book' => 'Book', + 'books' => 'Books', + 'x_books' => ':count Book|:count Books', + 'books_empty' => 'No books have been created', + 'books_popular' => 'Popular Books', + 'books_recent' => 'Recent Books', + 'books_new' => 'New Books', + 'books_new_action' => 'New Book', + 'books_popular_empty' => 'The most popular books will appear here.', + 'books_new_empty' => 'The most recently created books will appear here.', + 'books_create' => 'Create New Book', + 'books_delete' => 'Delete Book', + 'books_delete_named' => 'Delete Book :bookName', + 'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.', + 'books_delete_confirmation' => 'Are you sure you want to delete this book?', + 'books_edit' => 'Edit Book', + 'books_edit_named' => 'Edit Book :bookName', + 'books_form_book_name' => 'Book Name', + 'books_save' => 'Save Book', + 'books_permissions' => 'Book Permissions', + 'books_permissions_updated' => 'Book Permissions Updated', + 'books_empty_contents' => 'No pages or chapters have been created for this book.', + 'books_empty_create_page' => 'Create a new page', + 'books_empty_sort_current_book' => 'Sort the current book', + 'books_empty_add_chapter' => 'Add a chapter', + 'books_permissions_active' => 'Book Permissions Active', + 'books_search_this' => 'Search this book', + 'books_navigation' => 'Book Navigation', + 'books_sort' => 'Sort Book Contents', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_named' => 'Sort Book :bookName', + 'books_sort_name' => 'Sort by Name', + 'books_sort_created' => 'Sort by Created Date', + 'books_sort_updated' => 'Sort by Updated Date', + 'books_sort_chapters_first' => 'Chapters First', + 'books_sort_chapters_last' => 'Chapters Last', + 'books_sort_show_other' => 'Show Other Books', + 'books_sort_save' => 'Save New Order', + 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', + 'books_sort_move_up' => 'Move Up', + 'books_sort_move_down' => 'Move Down', + 'books_sort_move_prev_book' => 'Move to Previous Book', + 'books_sort_move_next_book' => 'Move to Next Book', + 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', + 'books_sort_move_next_chapter' => 'Move Into Next Chapter', + 'books_sort_move_book_start' => 'Move to Start of Book', + 'books_sort_move_book_end' => 'Move to End of Book', + 'books_sort_move_before_chapter' => 'Move to Before Chapter', + 'books_sort_move_after_chapter' => 'Move to After Chapter', + 'books_copy' => 'Copy Book', + 'books_copy_success' => 'Book successfully copied', + + // Chapters + 'chapter' => 'Chapter', + 'chapters' => 'Chapters', + 'x_chapters' => ':count Chapter|:count Chapters', + 'chapters_popular' => 'Popular Chapters', + 'chapters_new' => 'New Chapter', + 'chapters_create' => 'Create New Chapter', + 'chapters_delete' => 'Delete Chapter', + 'chapters_delete_named' => 'Delete Chapter :chapterName', + 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.', + 'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?', + 'chapters_edit' => 'Edit Chapter', + 'chapters_edit_named' => 'Edit Chapter :chapterName', + 'chapters_save' => 'Save Chapter', + 'chapters_move' => 'Move Chapter', + 'chapters_move_named' => 'Move Chapter :chapterName', + 'chapters_copy' => 'Copy Chapter', + 'chapters_copy_success' => 'Chapter successfully copied', + 'chapters_permissions' => 'Chapter Permissions', + 'chapters_empty' => 'No pages are currently in this chapter.', + 'chapters_permissions_active' => 'Chapter Permissions Active', + 'chapters_permissions_success' => 'Chapter Permissions Updated', + 'chapters_search_this' => 'Search this chapter', + 'chapter_sort_book' => 'Sort Book', + + // Pages + 'page' => 'Page', + 'pages' => 'Pages', + 'x_pages' => ':count Page|:count Pages', + 'pages_popular' => 'Popular Pages', + 'pages_new' => 'New Page', + 'pages_attachments' => 'Attachments', + 'pages_navigation' => 'Page Navigation', + 'pages_delete' => 'Delete Page', + 'pages_delete_named' => 'Delete Page :pageName', + 'pages_delete_draft_named' => 'Delete Draft Page :pageName', + 'pages_delete_draft' => 'Delete Draft Page', + 'pages_delete_success' => 'Page deleted', + 'pages_delete_draft_success' => 'Draft page deleted', + 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_confirm' => 'Are you sure you want to delete this page?', + 'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?', + 'pages_editing_named' => 'Editing Page :pageName', + 'pages_edit_draft_options' => 'Draft Options', + 'pages_edit_save_draft' => 'Save Draft', + 'pages_edit_draft' => 'Edit Page Draft', + 'pages_editing_draft' => 'Editing Draft', + 'pages_editing_page' => 'Editing Page', + 'pages_edit_draft_save_at' => 'Draft saved at ', + 'pages_edit_delete_draft' => 'Delete Draft', + 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.', + 'pages_edit_discard_draft' => 'Discard Draft', + 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', + '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', + 'pages_editor_switch_title' => 'Switch Editor', + 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', + 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', + 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', + 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', + 'pages_save' => 'Save Page', + 'pages_title' => 'Page Title', + 'pages_name' => 'Page Name', + 'pages_md_editor' => 'Editor', + 'pages_md_preview' => 'Preview', + 'pages_md_insert_image' => 'Insert Image', + 'pages_md_insert_link' => 'Insert Entity Link', + 'pages_md_insert_drawing' => 'Insert Drawing', + 'pages_md_show_preview' => 'Show preview', + 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_drawing_unsaved' => 'Unsaved Drawing Found', + 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', + 'pages_not_in_chapter' => 'Page is not in a chapter', + 'pages_move' => 'Move Page', + 'pages_copy' => 'Copy Page', + 'pages_copy_desination' => 'Copy Destination', + 'pages_copy_success' => 'Page successfully copied', + 'pages_permissions' => 'Page Permissions', + 'pages_permissions_success' => 'Page permissions updated', + 'pages_revision' => 'Revision', + 'pages_revisions' => 'Page Revisions', + 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', + 'pages_revisions_named' => 'Page Revisions for :pageName', + 'pages_revision_named' => 'Page Revision for :pageName', + 'pages_revision_restored_from' => 'Restored from #:id; :summary', + 'pages_revisions_created_by' => 'Created By', + 'pages_revisions_date' => 'Revision Date', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'Revision Number', + 'pages_revisions_numbered' => 'Revision #:id', + 'pages_revisions_numbered_changes' => 'Revision #:id Changes', + 'pages_revisions_editor' => 'Editor Type', + 'pages_revisions_changelog' => 'Changelog', + 'pages_revisions_changes' => 'Changes', + 'pages_revisions_current' => 'Current Version', + 'pages_revisions_preview' => 'Preview', + 'pages_revisions_restore' => 'Restore', + 'pages_revisions_none' => 'This page has no revisions', + 'pages_copy_link' => 'Copy Link', + 'pages_edit_content_link' => 'Jump to section in editor', + 'pages_pointer_enter_mode' => 'Enter section select mode', + 'pages_pointer_label' => 'Page Section Options', + 'pages_pointer_permalink' => 'Page Section Permalink', + 'pages_pointer_include_tag' => 'Page Section Include Tag', + 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', + 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_permissions_active' => 'Page Permissions Active', + 'pages_initial_revision' => 'Initial publish', + 'pages_references_update_revision' => 'System auto-update of internal links', + 'pages_initial_name' => 'New Page', + 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', + 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', + 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count users have started editing this page', + 'start_b' => ':userName has started editing this page', + 'time_a' => 'since the page was last updated', + 'time_b' => 'in the last :minCount minutes', + 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + ], + 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', + 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', + 'pages_specific' => 'Specific Page', + 'pages_is_template' => 'Page Template', + + // Editor Sidebar + 'toggle_sidebar' => 'Toggle Sidebar', + 'page_tags' => 'Page Tags', + 'chapter_tags' => 'Chapter Tags', + 'book_tags' => 'Book Tags', + 'shelf_tags' => 'Shelf Tags', + 'tag' => 'Tag', + 'tags' => 'Tags', + 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', + 'tag_name' => 'Tag Name', + 'tag_value' => 'Tag Value (Optional)', + 'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.", + 'tags_add' => 'Add another tag', + 'tags_remove' => 'Remove this tag', + 'tags_usages' => 'Total tag usages', + 'tags_assigned_pages' => 'Assigned to Pages', + 'tags_assigned_chapters' => 'Assigned to Chapters', + 'tags_assigned_books' => 'Assigned to Books', + 'tags_assigned_shelves' => 'Assigned to Shelves', + 'tags_x_unique_values' => ':count unique values', + 'tags_all_values' => 'All values', + 'tags_view_tags' => 'View Tags', + 'tags_view_existing_tags' => 'View existing tags', + 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', + 'attachments' => 'Attachments', + 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', + 'attachments_explain_instant_save' => 'Changes here are saved instantly.', + 'attachments_upload' => 'Upload File', + 'attachments_link' => 'Attach Link', + 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', + 'attachments_set_link' => 'Set Link', + 'attachments_delete' => 'Are you sure you want to delete this attachment?', + 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_no_files' => 'No files have been uploaded', + 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', + 'attachments_link_name' => 'Link Name', + 'attachment_link' => 'Attachment link', + 'attachments_link_url' => 'Link to file', + 'attachments_link_url_hint' => 'Url of site or file', + 'attach' => 'Attach', + 'attachments_insert_link' => 'Add Attachment Link to Page', + 'attachments_edit_file' => 'Edit File', + 'attachments_edit_file_name' => 'File Name', + 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', + 'attachments_order_updated' => 'Attachment order updated', + 'attachments_updated_success' => 'Attachment details updated', + 'attachments_deleted' => 'Attachment deleted', + 'attachments_file_uploaded' => 'File successfully uploaded', + 'attachments_file_updated' => 'File successfully updated', + 'attachments_link_attached' => 'Link successfully attached to page', + 'templates' => 'Templates', + 'templates_set_as_template' => 'Page is a template', + 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', + 'templates_replace_content' => 'Replace page content', + 'templates_append_content' => 'Append to page content', + 'templates_prepend_content' => 'Prepend to page content', + + // Profile View + 'profile_user_for_x' => 'User for :time', + 'profile_created_content' => 'Created Content', + 'profile_not_created_pages' => ':userName has not created any pages', + 'profile_not_created_chapters' => ':userName has not created any chapters', + 'profile_not_created_books' => ':userName has not created any books', + 'profile_not_created_shelves' => ':userName has not created any shelves', + + // Comments + 'comment' => 'Comment', + 'comments' => 'Comments', + 'comment_add' => 'Add Comment', + 'comment_placeholder' => 'Leave a comment here', + 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', + 'comment_save' => 'Save Comment', + 'comment_new' => 'New Comment', + 'comment_created' => 'commented :createDiff', + 'comment_updated' => 'Updated :updateDiff by :username', + 'comment_updated_indicator' => 'Updated', + 'comment_deleted_success' => 'Comment deleted', + 'comment_created_success' => 'Comment added', + 'comment_updated_success' => 'Comment updated', + 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', + 'comment_in_reply_to' => 'In reply to :commentId', + 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', + + // Revision + 'revision_delete_confirm' => 'Are you sure you want to delete this revision?', + 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', + 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + + // Conversions + 'convert_to_shelf' => 'Convert to Shelf', + 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', + 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', + 'convert_book' => 'Convert Book', + 'convert_book_confirm' => 'Are you sure you want to convert this book?', + 'convert_undo_warning' => 'This cannot be as easily undone.', + 'convert_to_book' => 'Convert to Book', + 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', + 'convert_chapter' => 'Convert Chapter', + 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + + // References + 'references' => 'References', + 'references_none' => 'There are no tracked references to this item.', + 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + + // Watch Options + 'watch' => 'Watch', + 'watch_title_default' => 'Default Preferences', + 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_title_ignore' => 'Ignore', + 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_title_new' => 'New Pages', + 'watch_desc_new' => 'Notify when any new page is created within this item.', + 'watch_title_updates' => 'All Page Updates', + 'watch_desc_updates' => 'Notify upon all new pages and page changes.', + 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', + 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', + 'watch_change_default' => 'Change default notification preferences', + 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_detail_new' => 'Watching for new pages', + 'watch_detail_updates' => 'Watching new pages and updates', + 'watch_detail_comments' => 'Watching new pages, updates & comments', + 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', + 'watch_detail_parent_chapter' => 'Watching via parent chapter', + 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', +]; diff --git a/lang/tk/errors.php b/lang/tk/errors.php new file mode 100644 index 00000000000..9c40aa9ed33 --- /dev/null +++ b/lang/tk/errors.php @@ -0,0 +1,121 @@ + 'You do not have permission to access the requested page.', + 'permissionJson' => 'You do not have permission to perform the requested action.', + + // Auth + 'error_user_exists_different_creds' => 'A user with the email :email already exists but with different credentials.', + 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', + 'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.', + 'email_confirmation_expired' => 'The confirmation token has expired, A new confirmation email has been sent.', + 'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed', + 'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind', + 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', + 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', + 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', + 'saml_already_logged_in' => 'Already logged in', + 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', + 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'social_no_action_defined' => 'No action defined', + 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", + 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', + 'social_account_email_in_use' => 'The email :email is already in use. If you already have an account you can connect your :socialAccount account from your profile settings.', + 'social_account_existing' => 'This :socialAccount is already attached to your profile.', + 'social_account_already_used_existing' => 'This :socialAccount account is already used by another user.', + 'social_account_not_used' => 'This :socialAccount account is not linked to any users. Please attach it in your profile settings. ', + 'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.', + 'social_driver_not_found' => 'Social driver not found', + 'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.', + 'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.', + 'login_user_not_found' => 'A user for this action could not be found.', + + // System + 'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.', + 'cannot_get_image_from_url' => 'Cannot get image from :url', + 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', + 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', + 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', + + // Drawing & Images + 'image_upload_error' => 'An error occurred uploading the image', + 'image_upload_type_error' => 'The image type being uploaded is invalid', + 'image_upload_replace_type' => 'Image file replacements must be of the same type', + 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', + 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', + 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', + 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', + + // Attachments + 'attachment_not_found' => 'Attachment not found', + 'attachment_upload_error' => 'An error occurred uploading the attachment file', + + // Pages + 'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', + 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', + 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage', + + // Entities + 'entity_not_found' => 'Entity not found', + 'bookshelf_not_found' => 'Shelf not found', + 'book_not_found' => 'Book not found', + 'page_not_found' => 'Page not found', + 'chapter_not_found' => 'Chapter not found', + 'selected_book_not_found' => 'The selected book was not found', + 'selected_book_chapter_not_found' => 'The selected Book or Chapter was not found', + 'guests_cannot_save_drafts' => 'Guests cannot save drafts', + + // Users + 'users_cannot_delete_only_admin' => 'You cannot delete the only admin', + 'users_cannot_delete_guest' => 'You cannot delete the guest user', + 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + + // Roles + 'role_cannot_be_edited' => 'This role cannot be edited', + 'role_system_cannot_be_deleted' => 'This role is a system role and cannot be deleted', + 'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role', + 'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.', + + // Comments + 'comment_list' => 'An error occurred while fetching the comments.', + 'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.', + 'comment_add' => 'An error occurred while adding / updating the comment.', + 'comment_delete' => 'An error occurred while deleting the comment.', + 'empty_comment' => 'Cannot add an empty comment.', + + // Error pages + '404_page_not_found' => 'Page Not Found', + 'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.', + 'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.', + 'image_not_found' => 'Image Not Found', + 'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.', + 'image_not_found_details' => 'If you expected this image to exist it might have been deleted.', + 'return_home' => 'Return to home', + 'error_occurred' => 'An Error Occurred', + 'app_down' => ':appName is down right now', + 'back_soon' => 'It will be back up soon.', + + // API errors + 'api_no_authorization_found' => 'No authorization token found on the request', + 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', + 'api_user_token_not_found' => 'No matching API token was found for the provided authorization token', + 'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect', + 'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls', + 'api_user_token_expired' => 'The authorization token used has expired', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Error thrown when sending a test email:', + + // HTTP errors + 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', +]; diff --git a/lang/tk/notifications.php b/lang/tk/notifications.php new file mode 100644 index 00000000000..1afd23f1dc4 --- /dev/null +++ b/lang/tk/notifications.php @@ -0,0 +1,27 @@ + 'New comment on page: :pageName', + 'new_comment_intro' => 'A user has commented on a page in :appName:', + 'new_page_subject' => 'New page: :pageName', + 'new_page_intro' => 'A new page has been created in :appName:', + 'updated_page_subject' => 'Updated page: :pageName', + 'updated_page_intro' => 'A page has been updated in :appName:', + 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + + 'detail_page_name' => 'Page Name:', + 'detail_page_path' => 'Page Path:', + 'detail_commenter' => 'Commenter:', + 'detail_comment' => 'Comment:', + 'detail_created_by' => 'Created By:', + 'detail_updated_by' => 'Updated By:', + + 'action_view_comment' => 'View Comment', + 'action_view_page' => 'View Page', + + 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', + 'footer_reason_link' => 'your notification preferences', +]; diff --git a/lang/tk/pagination.php b/lang/tk/pagination.php new file mode 100644 index 00000000000..85bd12fc319 --- /dev/null +++ b/lang/tk/pagination.php @@ -0,0 +1,12 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/tk/passwords.php b/lang/tk/passwords.php new file mode 100644 index 00000000000..b408f3c2fda --- /dev/null +++ b/lang/tk/passwords.php @@ -0,0 +1,15 @@ + 'Passwords must be at least eight characters and match the confirmation.', + 'user' => "We can't find a user with that e-mail address.", + 'token' => 'The password reset token is invalid for this email address.', + 'sent' => 'We have e-mailed your password reset link!', + 'reset' => 'Your password has been reset!', + +]; diff --git a/lang/tk/preferences.php b/lang/tk/preferences.php new file mode 100644 index 00000000000..2872f5f3c65 --- /dev/null +++ b/lang/tk/preferences.php @@ -0,0 +1,51 @@ + 'My Account', + + 'shortcuts' => 'Shortcuts', + 'shortcuts_interface' => 'UI Shortcut Preferences', + 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', + 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', + 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', + 'shortcuts_section_navigation' => 'Navigation', + 'shortcuts_section_actions' => 'Common Actions', + 'shortcuts_save' => 'Save Shortcuts', + 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', + 'shortcuts_update_success' => 'Shortcut preferences have been updated!', + 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + + 'notifications' => 'Notification Preferences', + 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', + 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', + 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', + 'notifications_save' => 'Save Preferences', + 'notifications_update_success' => 'Notification preferences have been updated!', + 'notifications_watched' => 'Watched & Ignored Items', + 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + + 'auth' => 'Access & Security', + 'auth_change_password' => 'Change Password', + 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', + 'auth_change_password_success' => 'Password has been updated!', + + 'profile' => 'Profile Details', + 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', + 'profile_view_public' => 'View Public Profile', + 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', + 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', + 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', + 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', + 'profile_admin_options' => 'Administrator Options', + 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + + 'delete_account' => 'Delete Account', + 'delete_my_account' => 'Delete My Account', + 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', + 'delete_my_account_warning' => 'Are you sure you want to delete your account?', +]; diff --git a/lang/tk/settings.php b/lang/tk/settings.php new file mode 100644 index 00000000000..5427cb9419e --- /dev/null +++ b/lang/tk/settings.php @@ -0,0 +1,338 @@ + 'Settings', + 'settings_save' => 'Save Settings', + 'system_version' => 'System Version', + 'categories' => 'Categories', + + // App Settings + 'app_customization' => 'Customization', + 'app_features_security' => 'Features & Security', + 'app_name' => 'Application Name', + 'app_name_desc' => 'This name is shown in the header and in any system-sent emails.', + 'app_name_header' => 'Show name in header', + 'app_public_access' => 'Public Access', + 'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.', + 'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.', + 'app_public_access_toggle' => 'Allow public access', + 'app_public_viewing' => 'Allow public viewing?', + 'app_secure_images' => 'Higher Security Image Uploads', + 'app_secure_images_toggle' => 'Enable higher security image uploads', + 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', + 'app_default_editor' => 'Default Page Editor', + 'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.', + 'app_custom_html' => 'Custom HTML Head Content', + 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', + 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', + 'app_logo' => 'Application Logo', + 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', + 'app_icon' => 'Application Icon', + 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', + 'app_homepage' => 'Application Homepage', + 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage_select' => 'Select a page', + 'app_footer_links' => 'Footer Links', + 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', + 'app_footer_links_label' => 'Link Label', + 'app_footer_links_url' => 'Link URL', + 'app_footer_links_add' => 'Add Footer Link', + 'app_disable_comments' => 'Disable Comments', + 'app_disable_comments_toggle' => 'Disable comments', + 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
                                Existing comments are not shown.', + + // Color settings + 'color_scheme' => 'Application Color Scheme', + 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', + 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', + 'app_color' => 'Primary Color', + 'link_color' => 'Default Link Color', + 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', + 'bookshelf_color' => 'Shelf Color', + 'book_color' => 'Book Color', + 'chapter_color' => 'Chapter Color', + 'page_color' => 'Page Color', + 'page_draft_color' => 'Page Draft Color', + + // Registration Settings + 'reg_settings' => 'Registration', + 'reg_enable' => 'Enable Registration', + 'reg_enable_toggle' => 'Enable registration', + 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', + 'reg_default_role' => 'Default user role after registration', + 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', + 'reg_email_confirmation' => 'Email Confirmation', + 'reg_email_confirmation_toggle' => 'Require email confirmation', + 'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.', + 'reg_confirm_restrict_domain' => 'Domain Restriction', + 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
                                Note that users will be able to change their email addresses after successful registration.', + 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + + // Maintenance settings + 'maint' => 'Maintenance', + 'maint_image_cleanup' => 'Cleanup Images', + 'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.', + 'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions', + 'maint_image_cleanup_run' => 'Run Cleanup', + 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', + 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', + 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', + 'maint_send_test_email' => 'Send a Test Email', + 'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.', + 'maint_send_test_email_run' => 'Send test email', + 'maint_send_test_email_success' => 'Email sent to :address', + 'maint_send_test_email_mail_subject' => 'Test Email', + 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', + 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', + 'maint_recycle_bin_open' => 'Open Recycle Bin', + 'maint_regen_references' => 'Regenerate References', + 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', + 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + + // Recycle Bin + 'recycle_bin' => 'Recycle Bin', + 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'recycle_bin_deleted_item' => 'Deleted Item', + 'recycle_bin_deleted_parent' => 'Parent', + 'recycle_bin_deleted_by' => 'Deleted By', + 'recycle_bin_deleted_at' => 'Deletion Time', + 'recycle_bin_permanently_delete' => 'Permanently Delete', + 'recycle_bin_restore' => 'Restore', + 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', + 'recycle_bin_empty' => 'Empty Recycle Bin', + 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', + 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_list' => 'Items to be Destroyed', + 'recycle_bin_restore_list' => 'Items to be Restored', + 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', + 'recycle_bin_restore_parent' => 'Restore Parent', + 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + + // Audit Log + 'audit' => 'Audit Log', + 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_event_filter' => 'Event Filter', + 'audit_event_filter_no_filter' => 'No Filter', + 'audit_deleted_item' => 'Deleted Item', + 'audit_deleted_item_name' => 'Name: :name', + 'audit_table_user' => 'User', + 'audit_table_event' => 'Event', + 'audit_table_related' => 'Related Item or Detail', + 'audit_table_ip' => 'IP Address', + 'audit_table_date' => 'Activity Date', + 'audit_date_from' => 'Date Range From', + 'audit_date_to' => 'Date Range To', + + // Role Settings + 'roles' => 'Roles', + 'role_user_roles' => 'User Roles', + 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', + 'roles_x_users_assigned' => ':count user assigned|:count users assigned', + 'roles_x_permissions_provided' => ':count permission|:count permissions', + 'roles_assigned_users' => 'Assigned Users', + 'roles_permissions_provided' => 'Provided Permissions', + 'role_create' => 'Create New Role', + 'role_delete' => 'Delete Role', + 'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.', + 'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', + 'role_delete_no_migration' => "Don't migrate users", + 'role_delete_sure' => 'Are you sure you want to delete this role?', + 'role_edit' => 'Edit Role', + 'role_details' => 'Role Details', + 'role_name' => 'Role Name', + 'role_desc' => 'Short Description of Role', + 'role_mfa_enforced' => 'Requires Multi-Factor Authentication', + 'role_external_auth_id' => 'External Authentication IDs', + 'role_system' => 'System Permissions', + 'role_manage_users' => 'Manage users', + 'role_manage_roles' => 'Manage roles & role permissions', + 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', + 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', + 'role_manage_page_templates' => 'Manage page templates', + 'role_access_api' => 'Access system API', + 'role_manage_settings' => 'Manage app settings', + 'role_export_content' => 'Export content', + 'role_editor_change' => 'Change page editor', + 'role_notifications' => 'Receive & manage notifications', + 'role_asset' => 'Asset Permissions', + 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', + 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', + 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', + 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_all' => 'All', + 'role_own' => 'Own', + 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_save' => 'Save Role', + 'role_users' => 'Users in this role', + 'role_users_none' => 'No users are currently assigned to this role', + + // Users + 'users' => 'Users', + 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', + 'user_profile' => 'User Profile', + 'users_add_new' => 'Add New User', + 'users_search' => 'Search Users', + 'users_latest_activity' => 'Latest Activity', + 'users_details' => 'User Details', + 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', + 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', + 'users_role' => 'User Roles', + 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', + 'users_password' => 'User Password', + 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.', + 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', + 'users_send_invite_option' => 'Send user invite email', + 'users_external_auth_id' => 'External Authentication ID', + 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', + 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', + 'users_delete' => 'Delete User', + 'users_delete_named' => 'Delete user :userName', + 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', + 'users_delete_confirm' => 'Are you sure you want to delete this user?', + 'users_migrate_ownership' => 'Migrate Ownership', + 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', + 'users_none_selected' => 'No user selected', + 'users_edit' => 'Edit User', + 'users_edit_profile' => 'Edit Profile', + 'users_avatar' => 'User Avatar', + 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', + 'users_preferred_language' => 'Preferred Language', + 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', + 'users_social_accounts' => 'Social Accounts', + 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Connect Account', + 'users_social_disconnect' => 'Disconnect Account', + 'users_social_status_connected' => 'Connected', + 'users_social_status_disconnected' => 'Disconnected', + 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', + 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', + 'users_api_tokens' => 'API Tokens', + 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_none' => 'No API tokens have been created for this user', + 'users_api_tokens_create' => 'Create Token', + 'users_api_tokens_expires' => 'Expires', + 'users_api_tokens_docs' => 'API Documentation', + 'users_mfa' => 'Multi-Factor Authentication', + 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_configure' => 'Configure Methods', + + // API Tokens + 'user_api_token_create' => 'Create API Token', + 'user_api_token_name' => 'Name', + 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', + 'user_api_token_expiry' => 'Expiry Date', + 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', + 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', + 'user_api_token' => 'API Token', + 'user_api_token_id' => 'Token ID', + 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', + 'user_api_token_secret' => 'Token Secret', + 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', + 'user_api_token_created' => 'Token created :timeAgo', + 'user_api_token_updated' => 'Token updated :timeAgo', + 'user_api_token_delete' => 'Delete Token', + 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', + 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', + 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_create' => 'Create New Webhook', + 'webhooks_none_created' => 'No webhooks have yet been created.', + 'webhooks_edit' => 'Edit Webhook', + 'webhooks_save' => 'Save Webhook', + 'webhooks_details' => 'Webhook Details', + 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', + 'webhooks_events' => 'Webhook Events', + 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', + 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_events_all' => 'All system events', + 'webhooks_name' => 'Webhook Name', + 'webhooks_timeout' => 'Webhook Request Timeout (Seconds)', + 'webhooks_endpoint' => 'Webhook Endpoint', + 'webhooks_active' => 'Webhook Active', + 'webhook_events_table_header' => 'Events', + 'webhooks_delete' => 'Delete Webhook', + 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', + 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', + 'webhooks_format_example' => 'Webhook Format Example', + 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + 'webhooks_status' => 'Webhook Status', + 'webhooks_last_called' => 'Last Called:', + 'webhooks_last_errored' => 'Last Errored:', + 'webhooks_last_error_message' => 'Last Error Message:', + + // Licensing + 'licenses' => 'Licenses', + 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', + 'licenses_bookstack' => 'BookStack License', + 'licenses_php' => 'PHP Library Licenses', + 'licenses_js' => 'JavaScript Library Licenses', + 'licenses_other' => 'Other Licenses', + 'license_details' => 'License Details', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'cy' => 'Cymraeg', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'el' => 'ελληνικά', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'et' => 'Eesti keel', + 'eu' => 'Euskara', + 'fa' => 'فارسی', + 'fi' => 'Suomi', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nb' => 'Norsk (Bokmål)', + 'nn' => 'Nynorsk', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ro' => 'Română', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'uz' => 'O‘zbekcha', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ], + //!//////////////////////////////// +]; diff --git a/lang/tk/validation.php b/lang/tk/validation.php new file mode 100644 index 00000000000..2a676c7c4cc --- /dev/null +++ b/lang/tk/validation.php @@ -0,0 +1,117 @@ + 'The :attribute must be accepted.', + 'active_url' => 'The :attribute is not a valid URL.', + 'after' => 'The :attribute must be a date after :date.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'array' => 'The :attribute must be an array.', + 'backup_codes' => 'The provided code is not valid or has already been used.', + 'before' => 'The :attribute must be a date before :date.', + 'between' => [ + 'numeric' => 'The :attribute must be between :min and :max.', + 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'string' => 'The :attribute must be between :min and :max characters.', + 'array' => 'The :attribute must have between :min and :max items.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'email' => 'The :attribute must be a valid email address.', + 'ends_with' => 'The :attribute must end with one of the following: :values', + 'file' => 'The :attribute must be provided as a valid file.', + 'filled' => 'The :attribute field is required.', + 'gt' => [ + 'numeric' => 'The :attribute must be greater than :value.', + 'file' => 'The :attribute must be greater than :value kilobytes.', + 'string' => 'The :attribute must be greater than :value characters.', + 'array' => 'The :attribute must have more than :value items.', + ], + 'gte' => [ + 'numeric' => 'The :attribute must be greater than or equal :value.', + 'file' => 'The :attribute must be greater than or equal :value kilobytes.', + 'string' => 'The :attribute must be greater than or equal :value characters.', + 'array' => 'The :attribute must have :value items or more.', + ], + 'exists' => 'The selected :attribute is invalid.', + 'image' => 'The :attribute must be an image.', + 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'in' => 'The selected :attribute is invalid.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'ipv4' => 'The :attribute must be a valid IPv4 address.', + 'ipv6' => 'The :attribute must be a valid IPv6 address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'lt' => [ + 'numeric' => 'The :attribute must be less than :value.', + 'file' => 'The :attribute must be less than :value kilobytes.', + 'string' => 'The :attribute must be less than :value characters.', + 'array' => 'The :attribute must have less than :value items.', + ], + 'lte' => [ + 'numeric' => 'The :attribute must be less than or equal :value.', + 'file' => 'The :attribute must be less than or equal :value kilobytes.', + 'string' => 'The :attribute must be less than or equal :value characters.', + 'array' => 'The :attribute must not have more than :value items.', + ], + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute format is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values is present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'safe_url' => 'The provided link may not be safe.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'totp' => 'The provided code is not valid or has expired.', + 'unique' => 'The :attribute has already been taken.', + 'url' => 'The :attribute format is invalid.', + 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Password confirmation required', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/lang/vi/activities.php b/lang/vi/activities.php index fda4899b368..d11de40e4a6 100644 --- a/lang/vi/activities.php +++ b/lang/vi/activities.php @@ -85,7 +85,7 @@ 'webhook_delete_notification' => 'Webhook đã được xóa thành công', // Users - 'user_create' => 'người dùng đã tạo', + 'user_create' => 'đã tạo người dùng', 'user_create_notification' => 'Người dùng được tạo thành công', 'user_update' => 'người dùng được cập nhật', 'user_update_notification' => 'Người dùng được cập nhật thành công', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index c726920eaff..f8f6ca293bb 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -107,7 +107,7 @@ 'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?', 'shelves_permissions' => 'Shelf Permissions', 'shelves_permissions_updated' => 'Shelf Permissions Updated', - 'shelves_permissions_active' => 'Shelf Permissions Active', + 'shelves_permissions_active' => 'Quyền của kệ đang hoạt động', 'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', 'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.', 'shelves_copy_permissions_to_books' => 'Sao chép các quyền cho sách', @@ -207,7 +207,7 @@ 'pages_delete_draft' => 'Xóa Trang Nháp', 'pages_delete_success' => 'Đã xóa Trang', 'pages_delete_draft_success' => 'Đã xóa trang Nháp', - 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_warning_template' => '.', 'pages_delete_confirm' => 'Bạn có chắc chắn muốn xóa trang này?', 'pages_delete_draft_confirm' => 'Bạn có chắc chắn muốn xóa trang nháp này?', 'pages_editing_named' => 'Đang chỉnh sửa Trang :pageName', @@ -223,7 +223,7 @@ 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', '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_wysiwyg' => 'Chuyển sang trình soạn thảo WYSIWYG', 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', 'pages_edit_set_changelog' => 'Đặt Changelog', @@ -232,7 +232,7 @@ 'pages_editor_switch_title' => 'Switch Editor', 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', - 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_a' => 'Sau khi lưu, tùy chọn trình soạn thảo mới sẽ được sử dụng bởi bất kỳ trình chỉnh sửa nào trong tương lai, kể cả những người không thể tự thay đổi loại trình chỉnh sửa.', 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', 'pages_save' => 'Lưu Trang', From e5a6ccc4d464cf728bb425701717607782dcfce6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 Nov 2024 13:31:21 +0000 Subject: [PATCH 210/800] Translators: Updated before patch release --- .github/translators.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/translators.txt b/.github/translators.txt index d0ab518ebdc..65ea00fe8f7 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -449,3 +449,7 @@ Avishay Rapp (AvishayRapp) :: Hebrew matthias4217 :: French Berke BOYLU2 (berkeboylu2) :: Turkish etwas7B :: German +Mohammed srhiri (m.sghiri20) :: Arabic +YongMin Kim (kym0118) :: Korean +Rivo Zängov (Eraser) :: Estonian +Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian From ac27e1893388c39f2b43c92fb91a0fca3d6ae7b3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 8 Nov 2024 13:46:57 +0000 Subject: [PATCH 211/800] Languages: Added Turkmen to locale manager --- app/Translation/LocaleManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php index e087dd9fa5e..ca71df64931 100644 --- a/app/Translation/LocaleManager.php +++ b/app/Translation/LocaleManager.php @@ -60,6 +60,7 @@ class LocaleManager 'sq' => 'sq_AL', 'sr' => 'sr_RS', 'sv' => 'sv_SE', + 'tk' => 'tk_TM', 'tr' => 'tr_TR', 'uk' => 'uk_UA', 'uz' => 'uz_UZ', From d13e4d2eefeed427c0377be04761a639e9fdb8fc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 Nov 2024 14:01:24 +0000 Subject: [PATCH 212/800] ZIP imports: Started actual import logic --- app/Entities/Tools/Cloner.php | 17 +-- .../ZipExports/Models/ZipExportAttachment.php | 6 +- .../ZipExports/Models/ZipExportTag.php | 6 +- app/Exports/ZipExports/ZipExportReader.php | 8 ++ app/Exports/ZipExports/ZipImportRunner.php | 107 ++++++++++++++++++ dev/docs/portable-zip-file-format.md | 4 +- 6 files changed, 124 insertions(+), 24 deletions(-) diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 2030b050c4b..2be6083e3dd 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -18,17 +18,12 @@ class Cloner { - protected PageRepo $pageRepo; - protected ChapterRepo $chapterRepo; - protected BookRepo $bookRepo; - protected ImageService $imageService; - - public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService) - { - $this->pageRepo = $pageRepo; - $this->chapterRepo = $chapterRepo; - $this->bookRepo = $bookRepo; - $this->imageService = $imageService; + public function __construct( + protected PageRepo $pageRepo, + protected ChapterRepo $chapterRepo, + protected BookRepo $bookRepo, + protected ImageService $imageService, + ) { } /** diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 1dbdc7333e8..c6615e1dc49 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -10,13 +10,12 @@ class ZipExportAttachment extends ZipExportModel { public ?int $id = null; public string $name; - public ?int $order = null; public ?string $link = null; public ?string $file = null; public function metadataOnly(): void { - $this->order = $this->link = $this->file = null; + $this->link = $this->file = null; } public static function fromModel(Attachment $model, ZipExportFiles $files): self @@ -24,7 +23,6 @@ public static function fromModel(Attachment $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->order = $model->order; if ($model->external) { $instance->link = $model->path; @@ -47,7 +45,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra $rules = [ 'id' => ['nullable', 'int'], 'name' => ['required', 'string', 'min:1'], - 'order' => ['nullable', 'integer'], 'link' => ['required_without:file', 'nullable', 'string'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; @@ -61,7 +58,6 @@ public static function fromArray(array $data): self $model->id = $data['id'] ?? null; $model->name = $data['name']; - $model->order = isset($data['order']) ? intval($data['order']) : null; $model->link = $data['link'] ?? null; $model->file = $data['file'] ?? null; diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index b6c9e338aef..6b4720fca7e 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -9,11 +9,10 @@ class ZipExportTag extends ZipExportModel { public string $name; public ?string $value = null; - public ?int $order = null; public function metadataOnly(): void { - $this->value = $this->order = null; + $this->value = null; } public static function fromModel(Tag $model): self @@ -21,7 +20,6 @@ public static function fromModel(Tag $model): self $instance = new self(); $instance->name = $model->name; $instance->value = $model->value; - $instance->order = $model->order; return $instance; } @@ -36,7 +34,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra $rules = [ 'name' => ['required', 'string', 'min:1'], 'value' => ['nullable', 'string'], - 'order' => ['nullable', 'integer'], ]; return $context->validateData($data, $rules); @@ -48,7 +45,6 @@ public static function fromArray(array $data): self $model->name = $data['name']; $model->value = $data['value'] ?? null; - $model->order = isset($data['order']) ? intval($data['order']) : null; return $model; } diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index c3e47da048d..ebc2fbbc9e1 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -73,6 +73,14 @@ public function fileExists(string $fileName): bool return $this->zip->statName("files/{$fileName}") !== false; } + /** + * @return false|resource + */ + public function streamFile(string $fileName) + { + return $this->zip->getStream("files/{$fileName}"); + } + /** * @throws ZipExportException */ diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 2f784ebea6e..2b897ff9167 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -5,18 +5,33 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; +use BookStack\Entities\Repos\BookRepo; +use BookStack\Entities\Repos\ChapterRepo; +use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipImportException; use BookStack\Exports\Import; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportPage; +use BookStack\Exports\ZipExports\Models\ZipExportTag; use BookStack\Uploads\FileStorage; +use BookStack\Uploads\ImageService; +use Illuminate\Http\UploadedFile; class ZipImportRunner { + protected array $tempFilesToCleanup = []; // TODO + protected array $createdImages = []; // TODO + protected array $createdAttachments = []; // TODO + public function __construct( protected FileStorage $storage, + protected PageRepo $pageRepo, + protected ChapterRepo $chapterRepo, + protected BookRepo $bookRepo, + protected ImageService $imageService, ) { } @@ -51,6 +66,98 @@ public function run(Import $import, ?Entity $parent = null): void $this->ensurePermissionsPermitImport($exportModel); // TODO - Run import + // TODO - In transaction? + // TODO - Revert uploaded files if goes wrong + } + + protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book + { + $book = $this->bookRepo->create([ + 'name' => $exportBook->name, + 'description_html' => $exportBook->description_html ?? '', + 'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null, + 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), + ]); + + // TODO - Parse/format description_html references + + if ($book->cover) { + $this->createdImages[] = $book->cover; + } + + // TODO - Pages + foreach ($exportBook->chapters as $exportChapter) { + $this->importChapter($exportChapter, $book); + } + // TODO - Sort chapters/pages by order + + return $book; + } + + protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter + { + $chapter = $this->chapterRepo->create([ + 'name' => $exportChapter->name, + 'description_html' => $exportChapter->description_html ?? '', + 'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []), + ], $parent); + + // TODO - Parse/format description_html references + + $exportPages = $exportChapter->pages; + usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + foreach ($exportPages as $exportPage) { + // + } + // TODO - Pages + + return $chapter; + } + + protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page + { + $page = $this->pageRepo->getNewDraftPage($parent); + + // TODO - Import attachments + // TODO - Import images + // TODO - Parse/format HTML + + $this->pageRepo->publishDraft($page, [ + 'name' => $exportPage->name, + 'markdown' => $exportPage->markdown, + 'html' => $exportPage->html, + 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), + ]); + + return $page; + } + + protected function exportTagsToInputArray(array $exportTags): array + { + $tags = []; + + /** @var ZipExportTag $tag */ + foreach ($exportTags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value ?? '']; + } + + return $tags; + } + + protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile + { + $tempPath = tempnam(sys_get_temp_dir(), 'bszipextract'); + $fileStream = $reader->streamFile($fileName); + $tempStream = fopen($tempPath, 'wb'); + stream_copy_to_stream($fileStream, $tempStream); + fclose($tempStream); + + $this->tempFilesToCleanup[] = $tempPath; + + return new UploadedFile($tempPath, $fileName); } /** diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 6cee7356d23..7e5df3f015b 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -135,12 +135,10 @@ embedded within it. - `name` - String, required, name of attachment. - `link` - String, semi-optional, URL of attachment. - `file` - String reference, semi-optional, reference to attachment file. -- `order` - Number, optional, integer order of the attachments (shown low to high). Either `link` or `file` must be present, as that will determine the type of attachment. #### Tag - `name` - String, required, name of the tag. -- `value` - String, optional, value of the tag (can be empty). -- `order` - Number, optional, integer order of the tags (shown low to high). \ No newline at end of file +- `value` - String, optional, value of the tag (can be empty). \ No newline at end of file From f12946d581fe66d5269f992db6573bb892286303 Mon Sep 17 00:00:00 2001 From: czemu Date: Sun, 10 Nov 2024 09:39:33 +0100 Subject: [PATCH 213/800] ExportFormatter: Add book description and check for empty book and chapter descriptions in markdown export --- app/Entities/Tools/ExportFormatter.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index beddfe8e6e0..e85992a9d70 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -315,7 +315,11 @@ public function pageToMarkdown(Page $page): string public function chapterToMarkdown(Chapter $chapter): string { $text = '# ' . $chapter->name . "\n\n"; - $text .= $chapter->description . "\n\n"; + + if (!empty($chapter->description)) { + $text .= $chapter->description . "\n\n"; + } + foreach ($chapter->pages as $page) { $text .= $this->pageToMarkdown($page) . "\n\n"; } @@ -330,6 +334,11 @@ public function bookToMarkdown(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; + + if (!empty($book->description)) { + $text .= $book->description . "\n\n"; + } + foreach ($bookTree as $bookChild) { if ($bookChild instanceof Chapter) { $text .= $this->chapterToMarkdown($bookChild) . "\n\n"; From 378f0d595fe8aa5aca212e1c5ed22944bf8bf1b7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 Nov 2024 16:03:50 +0000 Subject: [PATCH 214/800] ZIP Imports: Built out reference parsing/updating logic --- app/Entities/Repos/PageRepo.php | 13 +- .../ZipExports/ZipExportReferences.php | 8 +- .../ZipExports/ZipImportReferences.php | 142 ++++++++++++++++++ app/Exports/ZipExports/ZipImportRunner.php | 20 ++- app/Exports/ZipExports/ZipReferenceParser.php | 72 +++++++-- 5 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 app/Exports/ZipExports/ZipImportReferences.php diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 1bc15392cec..68b1c398f80 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -87,6 +87,17 @@ public function publishDraft(Page $draft, array $input): Page return $draft; } + /** + * Directly update the content for the given page from the provided input. + * Used for direct content access in a way that performs required changes + * (Search index & reference regen) without performing an official update. + */ + public function setContentFromInput(Page $page, array $input): void + { + $this->updateTemplateStatusAndContentFromInput($page, $input); + $this->baseRepo->update($page, []); + } + /** * Update a page in the system. */ @@ -121,7 +132,7 @@ public function update(Page $page, array $input): Page return $page; } - protected function updateTemplateStatusAndContentFromInput(Page $page, array $input) + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void { if (isset($input['template']) && userCan('templates-manage')) { $page->template = ($input['template'] === 'true'); diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index c630c832b37..0de409fa19a 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -85,9 +85,9 @@ public function buildReferences(ZipExportFiles $files): void // Parse page content first foreach ($this->pages as $page) { $handler = $createHandler($page); - $page->html = $this->parser->parse($page->html ?? '', $handler); + $page->html = $this->parser->parseLinks($page->html ?? '', $handler); if ($page->markdown) { - $page->markdown = $this->parser->parse($page->markdown, $handler); + $page->markdown = $this->parser->parseLinks($page->markdown, $handler); } } @@ -95,7 +95,7 @@ public function buildReferences(ZipExportFiles $files): void foreach ($this->chapters as $chapter) { if ($chapter->description_html) { $handler = $createHandler($chapter); - $chapter->description_html = $this->parser->parse($chapter->description_html, $handler); + $chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler); } } @@ -103,7 +103,7 @@ public function buildReferences(ZipExportFiles $files): void foreach ($this->books as $book) { if ($book->description_html) { $handler = $createHandler($book); - $book->description_html = $this->parser->parse($book->description_html, $handler); + $book->description_html = $this->parser->parseLinks($book->description_html, $handler); } } } diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php new file mode 100644 index 00000000000..8062886e542 --- /dev/null +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -0,0 +1,142 @@ + */ + protected array $referenceMap = []; + + /** @var array */ + protected array $zipExportPageMap = []; + /** @var array */ + protected array $zipExportChapterMap = []; + /** @var array */ + protected array $zipExportBookMap = []; + + public function __construct( + protected ZipReferenceParser $parser, + protected BaseRepo $baseRepo, + protected PageRepo $pageRepo, + protected ImageResizer $imageResizer, + ) { + } + + protected function addReference(string $type, Model $model, ?int $importId): void + { + if ($importId) { + $key = $type . ':' . $importId; + $this->referenceMap[$key] = $model; + } + } + + public function addPage(Page $page, ZipExportPage $exportPage): void + { + $this->pages[] = $page; + $this->zipExportPageMap[$page->id] = $exportPage; + $this->addReference('page', $page, $exportPage->id); + } + + public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void + { + $this->chapters[] = $chapter; + $this->zipExportChapterMap[$chapter->id] = $exportChapter; + $this->addReference('chapter', $chapter, $exportChapter->id); + } + + public function addBook(Book $book, ZipExportBook $exportBook): void + { + $this->books[] = $book; + $this->zipExportBookMap[$book->id] = $exportBook; + $this->addReference('book', $book, $exportBook->id); + } + + public function addAttachment(Attachment $attachment, ?int $importId): void + { + $this->attachments[] = $attachment; + $this->addReference('attachment', $attachment, $importId); + } + + public function addImage(Image $image, ?int $importId): void + { + $this->images[] = $image; + $this->addReference('image', $image, $importId); + } + + protected function handleReference(string $type, int $id): ?string + { + $key = $type . ':' . $id; + $model = $this->referenceMap[$key] ?? null; + if ($model instanceof Entity) { + return $model->getUrl(); + } else if ($model instanceof Image) { + if ($model->type === 'gallery') { + $this->imageResizer->loadGalleryThumbnailsForImage($model, false); + return $model->thumbs['gallery'] ?? $model->url; + } + + return $model->url; + } + + return null; + } + + public function replaceReferences(): void + { + foreach ($this->books as $book) { + $exportBook = $this->zipExportBookMap[$book->id]; + $content = $exportBook->description_html || ''; + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->baseRepo->update($book, [ + 'description_html' => $parsed, + ]); + } + + foreach ($this->chapters as $chapter) { + $exportChapter = $this->zipExportChapterMap[$chapter->id]; + $content = $exportChapter->description_html || ''; + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->baseRepo->update($chapter, [ + 'description_html' => $parsed, + ]); + } + + foreach ($this->pages as $page) { + $exportPage = $this->zipExportPageMap[$page->id]; + $contentType = $exportPage->markdown ? 'markdown' : 'html'; + $content = $exportPage->markdown ?: ($exportPage->html ?: ''); + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->pageRepo->setContentFromInput($page, [ + $contentType => $parsed, + ]); + } + } +} diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 2b897ff9167..345c22be153 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -23,8 +23,6 @@ class ZipImportRunner { protected array $tempFilesToCleanup = []; // TODO - protected array $createdImages = []; // TODO - protected array $createdAttachments = []; // TODO public function __construct( protected FileStorage $storage, @@ -32,6 +30,7 @@ public function __construct( protected ChapterRepo $chapterRepo, protected BookRepo $bookRepo, protected ImageService $imageService, + protected ZipImportReferences $references, ) { } @@ -68,6 +67,11 @@ public function run(Import $import, ?Entity $parent = null): void // TODO - Run import // TODO - In transaction? // TODO - Revert uploaded files if goes wrong + // TODO - Attachments + // TODO - Images + // (Both listed/stored in references) + + $this->references->replaceReferences(); } protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book @@ -82,15 +86,17 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader // TODO - Parse/format description_html references if ($book->cover) { - $this->createdImages[] = $book->cover; + $this->references->addImage($book->cover, null); } // TODO - Pages foreach ($exportBook->chapters as $exportChapter) { - $this->importChapter($exportChapter, $book); + $this->importChapter($exportChapter, $book, $reader); } // TODO - Sort chapters/pages by order + $this->references->addBook($book, $exportBook); + return $book; } @@ -114,6 +120,8 @@ protected function importChapter(ZipExportChapter $exportChapter, Book $parent, } // TODO - Pages + $this->references->addChapter($chapter, $exportChapter); + return $chapter; } @@ -122,7 +130,9 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z $page = $this->pageRepo->getNewDraftPage($parent); // TODO - Import attachments + // TODO - Add attachment references // TODO - Import images + // TODO - Add image references // TODO - Parse/format HTML $this->pageRepo->publishDraft($page, [ @@ -132,6 +142,8 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), ]); + $this->references->addPage($page, $exportPage); + return $page; } diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index da43d1b366b..5929383b4dd 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -15,27 +15,23 @@ class ZipReferenceParser { /** - * @var CrossLinkModelResolver[] + * @var CrossLinkModelResolver[]|null */ - protected array $modelResolvers; + protected ?array $modelResolvers = null; - public function __construct(EntityQueries $queries) - { - $this->modelResolvers = [ - new PagePermalinkModelResolver($queries->pages), - new PageLinkModelResolver($queries->pages), - new ChapterLinkModelResolver($queries->chapters), - new BookLinkModelResolver($queries->books), - new ImageModelResolver(), - new AttachmentModelResolver(), - ]; + public function __construct( + protected EntityQueries $queries + ) { } /** * Parse and replace references in the given content. + * Calls the handler for each model link detected and replaces the link + * with the handler return value if provided. + * Returns the resulting content with links replaced. * @param callable(Model):(string|null) $handler */ - public function parse(string $content, callable $handler): string + public function parseLinks(string $content, callable $handler): string { $escapedBase = preg_quote(url('/'), '/'); $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#()]/"; @@ -59,13 +55,43 @@ public function parse(string $content, callable $handler): string return $content; } + /** + * Parse and replace references in the given content. + * Calls the handler for each reference detected and replaces the link + * with the handler return value if provided. + * Returns the resulting content string with references replaced. + * @param callable(string $type, int $id):(string|null) $handler + */ + public function parseReferences(string $content, callable $handler): string + { + $referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/'; + $matches = []; + preg_match_all($referenceRegex, $content, $matches); + + if (count($matches) < 3) { + return $content; + } + + for ($i = 0; $i < count($matches[0]); $i++) { + $referenceText = $matches[0][$i]; + $type = strtolower($matches[1][$i]); + $id = intval($matches[2][$i]); + $result = $handler($type, $id); + if ($result !== null) { + $content = str_replace($referenceText, $result, $content); + } + } + + return $content; + } + /** * Attempt to resolve the given link to a model using the instance model resolvers. */ protected function linkToModel(string $link): ?Model { - foreach ($this->modelResolvers as $resolver) { + foreach ($this->getModelResolvers() as $resolver) { $model = $resolver->resolve($link); if (!is_null($model)) { return $model; @@ -74,4 +100,22 @@ protected function linkToModel(string $link): ?Model return null; } + + protected function getModelResolvers(): array + { + if (isset($this->modelResolvers)) { + return $this->modelResolvers; + } + + $this->modelResolvers = [ + new PagePermalinkModelResolver($this->queries->pages), + new PageLinkModelResolver($this->queries->pages), + new ChapterLinkModelResolver($this->queries->chapters), + new BookLinkModelResolver($this->queries->books), + new ImageModelResolver(), + new AttachmentModelResolver(), + ]; + + return $this->modelResolvers; + } } From 48c101aa7ab5b77781f4cd536b654d037b5aa55e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 Nov 2024 15:06:46 +0000 Subject: [PATCH 215/800] ZIP Imports: Finished off core import logic --- app/Exceptions/ZipImportException.php | 3 +- app/Exports/Controllers/ImportController.php | 11 +- app/Exports/ImportRepo.php | 6 +- .../ZipExports/ZipImportReferences.php | 4 +- app/Exports/ZipExports/ZipImportRunner.php | 117 +++++++++++++++--- 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php index 2403c514477..452365c6e88 100644 --- a/app/Exceptions/ZipImportException.php +++ b/app/Exceptions/ZipImportException.php @@ -7,6 +7,7 @@ class ZipImportException extends \Exception public function __construct( public array $errors ) { - parent::__construct(); + $message = "Import failed with errors:" . implode("\n", $this->errors); + parent::__construct($message); } } diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index ec5ac80808b..4d2c83090a3 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -79,18 +79,21 @@ public function run(int $id, Request $request) $import = $this->imports->findVisible($id); $parent = null; - if ($import->getType() === 'page' || $import->getType() === 'chapter') { + if ($import->type === 'page' || $import->type === 'chapter') { $data = $this->validate($request, [ 'parent' => ['required', 'string'] ]); $parent = $data['parent']; } - // TODO - Run import - // TODO - Validate again before - // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) + $entity = $this->imports->runImport($import, $parent); + if ($entity) { + $this->logActivity(ActivityType::IMPORT_RUN, $import); + return redirect($entity->getUrl()); + } // TODO - Redirect to result // TODO - Or redirect back with errors + return 'failed'; } /** diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index b94563545a4..d169d4845ab 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,9 +2,11 @@ namespace BookStack\Exports; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\ZipExportException; +use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; @@ -95,9 +97,9 @@ public function storeFromUpload(UploadedFile $file): Import } /** - * @throws ZipValidationException + * @throws ZipValidationException|ZipImportException */ - public function runImport(Import $import, ?string $parent = null) + public function runImport(Import $import, ?string $parent = null): ?Entity { $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index 8062886e542..3bce16bbb13 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -110,7 +110,7 @@ public function replaceReferences(): void { foreach ($this->books as $book) { $exportBook = $this->zipExportBookMap[$book->id]; - $content = $exportBook->description_html || ''; + $content = $exportBook->description_html ?? ''; $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); $this->baseRepo->update($book, [ @@ -120,7 +120,7 @@ public function replaceReferences(): void foreach ($this->chapters as $chapter) { $exportChapter = $this->zipExportChapterMap[$chapter->id]; - $content = $exportChapter->description_html || ''; + $content = $exportChapter->description_html ?? ''; $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); $this->baseRepo->update($chapter, [ diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 345c22be153..9f19f03e2e5 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -12,17 +12,22 @@ use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipImportException; use BookStack\Exports\Import; +use BookStack\Exports\ZipExports\Models\ZipExportAttachment; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\Models\ZipExportTag; +use BookStack\Uploads\Attachment; +use BookStack\Uploads\AttachmentService; use BookStack\Uploads\FileStorage; +use BookStack\Uploads\Image; use BookStack\Uploads\ImageService; use Illuminate\Http\UploadedFile; class ZipImportRunner { - protected array $tempFilesToCleanup = []; // TODO + protected array $tempFilesToCleanup = []; public function __construct( protected FileStorage $storage, @@ -30,14 +35,19 @@ public function __construct( protected ChapterRepo $chapterRepo, protected BookRepo $bookRepo, protected ImageService $imageService, + protected AttachmentService $attachmentService, protected ZipImportReferences $references, ) { } /** + * Run the import. + * Performs re-validation on zip, validation on parent provided, and permissions for importing + * the planned content, before running the import process. + * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): void + public function run(Import $import, ?Entity $parent = null): ?Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); @@ -63,8 +73,16 @@ public function run(Import $import, ?Entity $parent = null): void } $this->ensurePermissionsPermitImport($exportModel); + $entity = null; + + if ($exportModel instanceof ZipExportBook) { + $entity = $this->importBook($exportModel, $reader); + } else if ($exportModel instanceof ZipExportChapter) { + $entity = $this->importChapter($exportModel, $parent, $reader); + } else if ($exportModel instanceof ZipExportPage) { + $entity = $this->importPage($exportModel, $parent, $reader); + } - // TODO - Run import // TODO - In transaction? // TODO - Revert uploaded files if goes wrong // TODO - Attachments @@ -72,6 +90,23 @@ public function run(Import $import, ?Entity $parent = null): void // (Both listed/stored in references) $this->references->replaceReferences(); + + $reader->close(); + $this->cleanup(); + + dd('stop'); + + // TODO - Delete import/zip after import? + // Do this in parent repo? + + return $entity; + } + + protected function cleanup() + { + foreach ($this->tempFilesToCleanup as $file) { + unlink($file); + } } protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book @@ -83,17 +118,26 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), ]); - // TODO - Parse/format description_html references - if ($book->cover) { $this->references->addImage($book->cover, null); } - // TODO - Pages - foreach ($exportBook->chapters as $exportChapter) { - $this->importChapter($exportChapter, $book, $reader); + $children = [ + ...$exportBook->chapters, + ...$exportBook->pages, + ]; + + usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + foreach ($children as $child) { + if ($child instanceof ZipExportChapter) { + $this->importChapter($child, $book, $reader); + } else if ($child instanceof ZipExportPage) { + $this->importPage($child, $book, $reader); + } } - // TODO - Sort chapters/pages by order $this->references->addBook($book, $exportBook); @@ -108,17 +152,14 @@ protected function importChapter(ZipExportChapter $exportChapter, Book $parent, 'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []), ], $parent); - // TODO - Parse/format description_html references - $exportPages = $exportChapter->pages; usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) { return ($a->priority ?? 0) - ($b->priority ?? 0); }); foreach ($exportPages as $exportPage) { - // + $this->importPage($exportPage, $chapter, $reader); } - // TODO - Pages $this->references->addChapter($chapter, $exportChapter); @@ -129,11 +170,13 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z { $page = $this->pageRepo->getNewDraftPage($parent); - // TODO - Import attachments - // TODO - Add attachment references - // TODO - Import images - // TODO - Add image references - // TODO - Parse/format HTML + foreach ($exportPage->attachments as $exportAttachment) { + $this->importAttachment($exportAttachment, $page, $reader); + } + + foreach ($exportPage->images as $exportImage) { + $this->importImage($exportImage, $page, $reader); + } $this->pageRepo->publishDraft($page, [ 'name' => $exportPage->name, @@ -147,6 +190,40 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z return $page; } + protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment + { + if ($exportAttachment->file) { + $file = $this->zipFileToUploadedFile($exportAttachment->file, $reader); + $attachment = $this->attachmentService->saveNewUpload($file, $page->id); + $attachment->name = $exportAttachment->name; + $attachment->save(); + } else { + $attachment = $this->attachmentService->saveNewFromLink( + $exportAttachment->name, + $exportAttachment->link ?? '', + $page->id, + ); + } + + $this->references->addAttachment($attachment, $exportAttachment->id); + + return $attachment; + } + + protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image + { + $file = $this->zipFileToUploadedFile($exportImage->file, $reader); + $image = $this->imageService->saveNewFromUpload( + $file, + $exportImage->type, + $page->id, + ); + + $this->references->addImage($image, $exportImage->id); + + return $image; + } + protected function exportTagsToInputArray(array $exportTags): array { $tags = []; @@ -235,7 +312,7 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| } if (count($attachments) > 0) { - if (userCan('attachment-create-all')) { + if (!userCan('attachment-create-all')) { $errors[] = 'You are lacking the required permissions to create attachments.'; } } @@ -257,6 +334,8 @@ protected function getZipPath(Import $import): string stream_copy_to_stream($stream, $tempFile); fclose($tempFile); + $this->tempFilesToCleanup[] = $tempFilePath; + return $tempFilePath; } } From c40ab4147e38748a90fcfc71d6632cb6e868ed21 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 13 Nov 2024 11:39:04 +0000 Subject: [PATCH 216/800] Dependencies: Updated composer packages --- composer.json | 2 +- composer.lock | 138 ++++++++++++++++++++++++++------------------------ 2 files changed, 72 insertions(+), 68 deletions(-) diff --git a/composer.json b/composer.json index 5c54774f1e2..b9b5c9a0490 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "guzzlehttp/guzzle": "^7.4", "intervention/image": "^3.5", "knplabs/knp-snappy": "^1.5", - "laravel/framework": "^10.10", + "laravel/framework": "^10.48.23", "laravel/socialite": "^5.10", "laravel/tinker": "^2.8", "league/commonmark": "^2.3", diff --git a/composer.lock b/composer.lock index f2d585f8fb2..2d798fd6bed 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b24a1daf815b6910b51a2acc5e2d38e7", + "content-hash": "4a5a18010b7f4b32b7f0ae2a3e6305bb", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.325.4", + "version": "3.325.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "86372984539f25485718059f997a4ea12c05ba0a" + "reference": "55852104253172e66c3358bf4c35281bbd8622b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/86372984539f25485718059f997a4ea12c05ba0a", - "reference": "86372984539f25485718059f997a4ea12c05ba0a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/55852104253172e66c3358bf4c35281bbd8622b2", + "reference": "55852104253172e66c3358bf4c35281bbd8622b2", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.325.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.325.7" }, - "time": "2024-11-07T19:25:01+00:00" + "time": "2024-11-12T19:27:31+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2085,16 +2085,16 @@ }, { "name": "laravel/framework", - "version": "v10.48.22", + "version": "v10.48.23", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e" + "reference": "625269ca4881d2b50eded2045cb930960a181d98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/c4ea52bb044faef4a103d7dd81746c01b2ec860e", - "reference": "c4ea52bb044faef4a103d7dd81746c01b2ec860e", + "url": "https://api.github.com/repos/laravel/framework/zipball/625269ca4881d2b50eded2045cb930960a181d98", + "reference": "625269ca4881d2b50eded2045cb930960a181d98", "shasum": "" }, "require": { @@ -2288,7 +2288,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-09-12T15:00:09+00:00" + "time": "2024-11-12T15:39:10+00:00" }, { "name": "laravel/prompts", @@ -2350,16 +2350,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c" + "reference": "f865a58ea3a0107c336b7045104c75243fa59d96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c", - "reference": "1dc4a3dbfa2b7628a3114e43e32120cce7cdda9c", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/f865a58ea3a0107c336b7045104c75243fa59d96", + "reference": "f865a58ea3a0107c336b7045104c75243fa59d96", "shasum": "" }, "require": { @@ -2407,7 +2407,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-09-23T13:33:08+00:00" + "time": "2024-11-11T17:06:04+00:00" }, { "name": "laravel/socialite", @@ -3282,16 +3282,16 @@ }, { "name": "monolog/monolog", - "version": "3.7.0", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8" + "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8", - "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67", + "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67", "shasum": "" }, "require": { @@ -3311,12 +3311,14 @@ "guzzlehttp/psr7": "^2.2", "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", - "phpstan/phpstan": "^1.9", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "^10.5.17", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", - "ruflin/elastica": "^7", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", "symfony/mime": "^5.4 || ^6" }, @@ -3367,7 +3369,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.7.0" + "source": "https://github.com/Seldaek/monolog/tree/3.8.0" }, "funding": [ { @@ -3379,7 +3381,7 @@ "type": "tidelift" } ], - "time": "2024-06-28T09:40:51+00:00" + "time": "2024-11-12T13:57:08+00:00" }, { "name": "mtdowling/jmespath.php", @@ -5289,16 +5291,16 @@ }, { "name": "socialiteproviders/manager", - "version": "v4.6.0", + "version": "v4.7.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21" + "reference": "ab0691b82cec77efd90154c78f1854903455c82f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/dea5190981c31b89e52259da9ab1ca4e2b258b21", - "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/ab0691b82cec77efd90154c78f1854903455c82f", + "reference": "ab0691b82cec77efd90154c78f1854903455c82f", "shasum": "" }, "require": { @@ -5359,7 +5361,7 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2024-05-04T07:57:39+00:00" + "time": "2024-11-10T01:56:18+00:00" }, { "name": "socialiteproviders/microsoft-azure", @@ -7990,16 +7992,16 @@ "packages-dev": [ { "name": "fakerphp/faker", - "version": "v1.23.1", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b" + "reference": "a136842a532bac9ecd8a1c723852b09915d7db50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/bfb4fe148adbf78eff521199619b93a52ae3554b", - "reference": "bfb4fe148adbf78eff521199619b93a52ae3554b", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/a136842a532bac9ecd8a1c723852b09915d7db50", + "reference": "a136842a532bac9ecd8a1c723852b09915d7db50", "shasum": "" }, "require": { @@ -8047,9 +8049,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.23.1" + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.0" }, - "time": "2024-01-02T13:46:09+00:00" + "time": "2024-11-07T15:11:20+00:00" }, { "name": "filp/whoops", @@ -8250,16 +8252,16 @@ }, { "name": "larastan/larastan", - "version": "v2.9.10", + "version": "v2.9.11", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "9e7233d88f4f10796fadb8a2d85c5f2b55277c76" + "reference": "54eccd35d1732b9ee4392c25aec606a6a9c521e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/9e7233d88f4f10796fadb8a2d85c5f2b55277c76", - "reference": "9e7233d88f4f10796fadb8a2d85c5f2b55277c76", + "url": "https://api.github.com/repos/larastan/larastan/zipball/54eccd35d1732b9ee4392c25aec606a6a9c521e7", + "reference": "54eccd35d1732b9ee4392c25aec606a6a9c521e7", "shasum": "" }, "require": { @@ -8277,9 +8279,11 @@ }, "require-dev": { "doctrine/coding-standard": "^12.0", + "laravel/framework": "^9.52.16 || ^10.28.0 || ^11.16", + "mockery/mockery": "^1.5.1", "nikic/php-parser": "^4.19.1", "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2", - "orchestra/testbench": "^7.33.0 || ^8.13.0 || ^9.0.3", + "orchestra/testbench-core": "^7.33.0 || ^8.13.0 || ^9.0.9", "phpstan/phpstan-deprecation-rules": "^1.2", "phpunit/phpunit": "^9.6.13 || ^10.5.16" }, @@ -8329,7 +8333,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v2.9.10" + "source": "https://github.com/larastan/larastan/tree/v2.9.11" }, "funding": [ { @@ -8349,7 +8353,7 @@ "type": "patreon" } ], - "time": "2024-10-19T23:04:40+00:00" + "time": "2024-11-11T23:11:00+00:00" }, { "name": "mockery/mockery", @@ -8436,16 +8440,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", "shasum": "" }, "require": { @@ -8484,7 +8488,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" }, "funding": [ { @@ -8492,7 +8496,7 @@ "type": "tidelift" } ], - "time": "2024-06-12T14:39:25+00:00" + "time": "2024-11-08T17:47:46+00:00" }, { "name": "nunomaduro/collision", @@ -8710,16 +8714,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.10.0", + "version": "5.10.1", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "91d980ab76c3f152481e367f62b921adc38af451" + "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/91d980ab76c3f152481e367f62b921adc38af451", - "reference": "91d980ab76c3f152481e367f62b921adc38af451", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/b14fd66496a22d8dd7f7e2791edd9e8674422f17", + "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17", "shasum": "" }, "require": { @@ -8793,20 +8797,20 @@ "type": "other" } ], - "time": "2024-08-29T20:56:34+00:00" + "time": "2024-11-10T04:10:31+00:00" }, { "name": "phpstan/phpstan", - "version": "1.12.8", + "version": "1.12.10", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "f6a60a4d66142b8156c9da923f1972657bc4748c" + "reference": "fc463b5d0fe906dcf19689be692c65c50406a071" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f6a60a4d66142b8156c9da923f1972657bc4748c", - "reference": "f6a60a4d66142b8156c9da923f1972657bc4748c", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fc463b5d0fe906dcf19689be692c65c50406a071", + "reference": "fc463b5d0fe906dcf19689be692c65c50406a071", "shasum": "" }, "require": { @@ -8851,7 +8855,7 @@ "type": "github" } ], - "time": "2024-11-06T19:06:49+00:00" + "time": "2024-11-11T15:37:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10193,16 +10197,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.3", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" + "reference": "70c08f8d20c0eb4fe56f26644dd94dae76a7f450" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/70c08f8d20c0eb4fe56f26644dd94dae76a7f450", + "reference": "70c08f8d20c0eb4fe56f26644dd94dae76a7f450", "shasum": "" }, "require": { @@ -10269,7 +10273,7 @@ "type": "open_collective" } ], - "time": "2024-09-18T10:38:58+00:00" + "time": "2024-11-12T09:53:29+00:00" }, { "name": "ssddanbrown/asserthtml", From 306b8774c25d5c7148441c52125800506f8f2891 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 13 Nov 2024 11:59:03 +0000 Subject: [PATCH 217/800] Updated translations with latest Crowdin changes (#5317) * New translations common.php (Ukrainian) * New translations entities.php (Ukrainian) * New translations errors.php (Ukrainian) * New translations activities.php (Czech) * New translations entities.php (Czech) --- lang/cs/activities.php | 2 +- lang/cs/entities.php | 4 ++-- lang/uk/common.php | 2 +- lang/uk/entities.php | 4 ++-- lang/uk/errors.php | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lang/cs/activities.php b/lang/cs/activities.php index d0bf9cf4577..412bf9d3be3 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -46,7 +46,7 @@ 'bookshelf_create_from_book_notification' => 'Kniha byla úspěšně převedena na polici', 'bookshelf_update' => 'aktualizovat polici', 'bookshelf_update_notification' => 'Police byla úspěšně aktualizována', - 'bookshelf_delete' => 'odstranit knihovnu', + 'bookshelf_delete' => 'odstranil polici', 'bookshelf_delete_notification' => 'Police byla úspěšně odstraněna', // Revisions diff --git a/lang/cs/entities.php b/lang/cs/entities.php index 6122fe6a445..77fb8a3fb95 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -83,8 +83,8 @@ 'search_update' => 'Aktualizovat hledání', // Shelves - 'shelf' => 'Knihovna', - 'shelves' => 'Knihovny', + 'shelf' => 'Police', + 'shelves' => 'Police', 'x_shelves' => '{0}:count polic|{1}:count police|[2,4]:count police|[5,*]:count polic', 'shelves_empty' => 'Nebyly vytvořeny žádné police', 'shelves_create' => 'Vytvořit novou polici', diff --git a/lang/uk/common.php b/lang/uk/common.php index d384e392447..aed4880f124 100644 --- a/lang/uk/common.php +++ b/lang/uk/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Умови використання', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Шукати :appName', ]; diff --git a/lang/uk/entities.php b/lang/uk/entities.php index 664c6ed1725..01549e7f36f 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -224,8 +224,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Очистити вміст)', 'pages_edit_switch_to_markdown_stable' => '(Стабілізувати вміст)', 'pages_edit_switch_to_wysiwyg' => 'Змінити редактор на WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Перейти на новий WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(В альфа-тестуванні)', 'pages_edit_set_changelog' => 'Встановити журнал змін', 'pages_edit_enter_changelog_desc' => 'Введіть короткий опис внесених вами змін', 'pages_edit_enter_changelog' => 'Введіть список змін', diff --git a/lang/uk/errors.php b/lang/uk/errors.php index 602df64f8f5..7698093c1d5 100644 --- a/lang/uk/errors.php +++ b/lang/uk/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Ви не можете видалити єдиного адміністратора', 'users_cannot_delete_guest' => 'Ви не можете видалити гостьового користувача', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Не вдалося створити користувача, оскільки не вдалося надіслати електронний лист із запрошенням', // Roles 'role_cannot_be_edited' => 'Цю роль не можна редагувати', From b7476a9e7fc27c27342a0a155ab256a93f19981e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 14 Nov 2024 15:59:15 +0000 Subject: [PATCH 218/800] ZIP Import: Finished base import process & error handling Added file creation reverting and DB rollback on error. Added error display on failed import. Extracted likely shown import form/error text to translation files. --- app/Exports/Controllers/ImportController.php | 25 +++---- app/Exports/ImportRepo.php | 26 ++++++- .../ZipExports/ZipImportReferences.php | 17 +++++ app/Exports/ZipExports/ZipImportRunner.php | 69 +++++++++++-------- app/Uploads/AttachmentService.php | 2 +- app/Uploads/ImageService.php | 12 +++- lang/en/entities.php | 3 + lang/en/errors.php | 6 ++ resources/views/exports/import-show.blade.php | 49 ++++++++----- .../views/exports/parts/import.blade.php | 4 +- 10 files changed, 145 insertions(+), 68 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 4d2c83090a3..d8dceed2f8e 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -4,7 +4,7 @@ namespace BookStack\Exports\Controllers; -use BookStack\Activity\ActivityType; +use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ImportRepo; use BookStack\Http\Controller; @@ -48,12 +48,9 @@ public function upload(Request $request) try { $import = $this->imports->storeFromUpload($file); } catch (ZipValidationException $exception) { - session()->flash('validation_errors', $exception->errors); - return redirect('/import'); + return redirect('/import')->with('validation_errors', $exception->errors); } - $this->logActivity(ActivityType::IMPORT_CREATE, $import); - return redirect($import->getUrl()); } @@ -80,20 +77,20 @@ public function run(int $id, Request $request) $parent = null; if ($import->type === 'page' || $import->type === 'chapter') { + session()->setPreviousUrl($import->getUrl()); $data = $this->validate($request, [ - 'parent' => ['required', 'string'] + 'parent' => ['required', 'string'], ]); $parent = $data['parent']; } - $entity = $this->imports->runImport($import, $parent); - if ($entity) { - $this->logActivity(ActivityType::IMPORT_RUN, $import); - return redirect($entity->getUrl()); + try { + $entity = $this->imports->runImport($import, $parent); + } catch (ZipImportException $exception) { + return redirect($import->getUrl())->with('import_errors', $exception->errors); } - // TODO - Redirect to result - // TODO - Or redirect back with errors - return 'failed'; + + return redirect($entity->getUrl()); } /** @@ -104,8 +101,6 @@ public function delete(int $id) $import = $this->imports->findVisible($id); $this->imports->deleteImport($import); - $this->logActivity(ActivityType::IMPORT_DELETE, $import); - return redirect('/import'); } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index d169d4845ab..f72386c47bc 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,6 +2,7 @@ namespace BookStack\Exports; +use BookStack\Activity\ActivityType; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; @@ -14,8 +15,10 @@ use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Exports\ZipExports\ZipImportRunner; +use BookStack\Facades\Activity; use BookStack\Uploads\FileStorage; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\File\UploadedFile; class ImportRepo @@ -93,25 +96,42 @@ public function storeFromUpload(UploadedFile $file): Import $import->path = $path; $import->save(); + Activity::add(ActivityType::IMPORT_CREATE, $import); + return $import; } /** - * @throws ZipValidationException|ZipImportException + * @throws ZipImportException */ - public function runImport(Import $import, ?string $parent = null): ?Entity + public function runImport(Import $import, ?string $parent = null): Entity { $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; } - return $this->importer->run($import, $parentModel); + DB::beginTransaction(); + try { + $model = $this->importer->run($import, $parentModel); + } catch (ZipImportException $e) { + DB::rollBack(); + $this->importer->revertStoredFiles(); + throw $e; + } + + DB::commit(); + $this->deleteImport($import); + Activity::add(ActivityType::IMPORT_RUN, $import); + + return $model; } public function deleteImport(Import $import): void { $this->storage->delete($import->path); $import->delete(); + + Activity::add(ActivityType::IMPORT_DELETE, $import); } } diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index 3bce16bbb13..b23d5e72b15 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -139,4 +139,21 @@ public function replaceReferences(): void ]); } } + + + /** + * @return Image[] + */ + public function images(): array + { + return $this->images; + } + + /** + * @return Attachment[] + */ + public function attachments(): array + { + return $this->attachments; + } } diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 9f19f03e2e5..c5b9da31909 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -47,14 +47,17 @@ public function __construct( * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): ?Entity + public function run(Import $import, ?Entity $parent = null): Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); $errors = (new ZipExportValidator($reader))->validate(); if ($errors) { - throw new ZipImportException(["ZIP failed to validate"]); + throw new ZipImportException([ + trans('errors.import_validation_failed'), + ...$errors, + ]); } try { @@ -65,15 +68,14 @@ public function run(Import $import, ?Entity $parent = null): ?Entity // Validate parent type if ($exportModel instanceof ZipExportBook && ($parent !== null)) { - throw new ZipImportException(["Must not have a parent set for a Book import"]); - } else if ($exportModel instanceof ZipExportChapter && (!$parent instanceof Book)) { - throw new ZipImportException(["Parent book required for chapter import"]); + throw new ZipImportException(["Must not have a parent set for a Book import."]); + } else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) { + throw new ZipImportException(["Parent book required for chapter import."]); } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) { - throw new ZipImportException(["Parent book or chapter required for page import"]); + throw new ZipImportException(["Parent book or chapter required for page import."]); } - $this->ensurePermissionsPermitImport($exportModel); - $entity = null; + $this->ensurePermissionsPermitImport($exportModel, $parent); if ($exportModel instanceof ZipExportBook) { $entity = $this->importBook($exportModel, $reader); @@ -81,32 +83,46 @@ public function run(Import $import, ?Entity $parent = null): ?Entity $entity = $this->importChapter($exportModel, $parent, $reader); } else if ($exportModel instanceof ZipExportPage) { $entity = $this->importPage($exportModel, $parent, $reader); + } else { + throw new ZipImportException(['No importable data found in import data.']); } - // TODO - In transaction? - // TODO - Revert uploaded files if goes wrong - // TODO - Attachments - // TODO - Images - // (Both listed/stored in references) - $this->references->replaceReferences(); $reader->close(); $this->cleanup(); - dd('stop'); + return $entity; + } - // TODO - Delete import/zip after import? - // Do this in parent repo? + /** + * Revert any files which have been stored during this import process. + * Considers files only, and avoids the database under the + * assumption that the database may already have been + * reverted as part of a transaction rollback. + */ + public function revertStoredFiles(): void + { + foreach ($this->references->images() as $image) { + $this->imageService->destroyFileAtPath($image->type, $image->path); + } - return $entity; + foreach ($this->references->attachments() as $attachment) { + if (!$attachment->external) { + $this->attachmentService->deleteFileInStorage($attachment); + } + } + + $this->cleanup(); } - protected function cleanup() + protected function cleanup(): void { foreach ($this->tempFilesToCleanup as $file) { unlink($file); } + + $this->tempFilesToCleanup = []; } protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book @@ -256,9 +272,6 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| { $errors = []; - // TODO - Extract messages to language files - // TODO - Ensure these are shown to users on failure - $chapters = []; $pages = []; $images = []; @@ -266,7 +279,7 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| if ($exportModel instanceof ZipExportBook) { if (!userCan('book-create-all')) { - $errors[] = 'You are lacking the required permission to create books.'; + $errors[] = trans('errors.import_perms_books'); } array_push($pages, ...$exportModel->pages); array_push($chapters, ...$exportModel->chapters); @@ -283,7 +296,7 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| if (count($chapters) > 0) { $permission = 'chapter-create' . ($parent ? '' : '-all'); if (!userCan($permission, $parent)) { - $errors[] = 'You are lacking the required permission to create chapters.'; + $errors[] = trans('errors.import_perms_chapters'); } } @@ -295,25 +308,25 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| if (count($pages) > 0) { if ($parent) { if (!userCan('page-create', $parent)) { - $errors[] = 'You are lacking the required permission to create pages.'; + $errors[] = trans('errors.import_perms_pages'); } } else { $hasPermission = userCan('page-create-all') || userCan('page-create-own'); if (!$hasPermission) { - $errors[] = 'You are lacking the required permission to create pages.'; + $errors[] = trans('errors.import_perms_pages'); } } } if (count($images) > 0) { if (!userCan('image-create-all')) { - $errors[] = 'You are lacking the required permissions to create images.'; + $errors[] = trans('errors.import_perms_images'); } } if (count($attachments) > 0) { if (!userCan('attachment-create-all')) { - $errors[] = 'You are lacking the required permissions to create attachments.'; + $errors[] = trans('errors.import_perms_attachments'); } } diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index fa53c4ae499..033f2334104 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -151,7 +151,7 @@ public function deleteFile(Attachment $attachment) * Delete a file from the filesystem it sits on. * Cleans any empty leftover folders. */ - protected function deleteFileInStorage(Attachment $attachment): void + public function deleteFileInStorage(Attachment $attachment): void { $this->storage->delete($attachment->path); } diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index e501cc7b12d..5c455cf8633 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -153,11 +153,19 @@ public function getImageStream(Image $image): mixed */ public function destroy(Image $image): void { - $disk = $this->storage->getDisk($image->type); - $disk->destroyAllMatchingNameFromPath($image->path); + $this->destroyFileAtPath($image->type, $image->path); $image->delete(); } + /** + * Destroy the underlying image file at the given path. + */ + public function destroyFileAtPath(string $type, string $path): void + { + $disk = $this->storage->getDisk($type); + $disk->destroyAllMatchingNameFromPath($path); + } + /** * Delete gallery and drawings that are not within HTML content of pages or page revisions. * Checks based off of only the image name. diff --git a/lang/en/entities.php b/lang/en/entities.php index ae1c1e8d4cc..26a563a7eb5 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -52,6 +52,7 @@ 'import_pending_none' => 'No imports have been started.', 'import_continue' => 'Continue Import', 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', 'import_run' => 'Run Import', 'import_size' => ':size Import ZIP Size', 'import_uploaded_at' => 'Uploaded :relativeTime', @@ -60,6 +61,8 @@ 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/errors.php b/lang/en/errors.php index 3f2f303311e..ced80a32c1f 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -109,6 +109,12 @@ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', // API errors 'api_no_authorization_found' => 'No authorization token found on the request', diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index 40867377fb0..e4f199aa20c 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -7,8 +7,19 @@

                                {{ trans('entities.import_continue') }}

                                {{ trans('entities.import_continue_desc') }}

                                + @if(session()->has('import_errors')) +
                                + +

                                {{ trans('entities.import_errors_desc') }}

                                + @foreach(session()->get('import_errors') ?? [] as $error) +

                                {{ $error }}

                                + @endforeach +
                                +
                                + @endif +
                                - +
                                @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data]) @@ -34,32 +45,36 @@ @if($import->type === 'page' || $import->type === 'chapter')
                                -

                                {{ trans('entities.import_location_desc') }}

                                +

                                {{ trans('entities.import_location_desc') }}

                                + @if($errors->has('parent')) +
                                + @include('form.errors', ['name' => 'parent']) +
                                + @endif @include('entities.selector', [ 'name' => 'parent', 'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book', 'entityPermission' => "{$import->type}-create", 'selectorSize' => 'compact small', ]) - @include('form.errors', ['name' => 'parent']) @endif - -
                                - {{ trans('common.cancel') }} -
                                - - +
                                diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php index fd53095a422..2f7659c469e 100644 --- a/resources/views/exports/parts/import.blade.php +++ b/resources/views/exports/parts/import.blade.php @@ -4,7 +4,7 @@ class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}
                                -
                                {{ $import->getSizeString() }}
                                -
                                @icon('time'){{ $import->created_at->diffForHumans() }}
                                +
                                {{ $import->getSizeString() }}
                                +
                                @icon('time'){{ $import->created_at->diffForHumans() }}
                                \ No newline at end of file From 7681e32dca6cb7d06c2d196bf46239a41a86852c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2024 13:57:41 +0000 Subject: [PATCH 219/800] ZIP Imports: Added high level import run tests --- app/Exports/Controllers/ImportController.php | 4 +- database/factories/Exports/ImportFactory.php | 2 +- tests/Exports/ZipImportRunnerTest.php | 21 +++ tests/Exports/ZipImportTest.php | 133 +++++++++++++++++-- tests/Exports/ZipTestHelper.php | 47 +++++++ 5 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 tests/Exports/ZipImportRunnerTest.php create mode 100644 tests/Exports/ZipTestHelper.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index d8dceed2f8e..a20c341fb02 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -70,9 +70,11 @@ public function show(int $id) ]); } + /** + * Run the import process against an uploaded import ZIP. + */ public function run(int $id, Request $request) { - // TODO - Test access/visibility $import = $this->imports->findVisible($id); $parent = null; diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 74a2bcd65f3..5d0b4f89299 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -21,7 +21,7 @@ class ImportFactory extends Factory public function definition(): array { return [ - 'path' => 'uploads/imports/' . Str::random(10) . '.zip', + 'path' => 'uploads/files/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), 'type' => 'book', 'metadata' => '{"name": "My book"}', diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php new file mode 100644 index 00000000000..7bdd8ecbb65 --- /dev/null +++ b/tests/Exports/ZipImportRunnerTest.php @@ -0,0 +1,21 @@ +runner = app()->make(ZipImportRunner::class); + } + + // TODO - Test full book import + // TODO - Test full chapter import + // TODO - Test full page import +} diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index 2b40100aabe..3644e9bdcb7 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -3,6 +3,7 @@ namespace Tests\Exports; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\Book; use BookStack\Exports\Import; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; @@ -91,7 +92,7 @@ public function test_error_shown_if_missing_data() public function test_error_shown_if_no_importable_key() { $this->asAdmin(); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ 'instance' => [] ])); @@ -103,7 +104,7 @@ public function test_error_shown_if_no_importable_key() public function test_zip_data_validation_messages_shown() { $this->asAdmin(); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ 'book' => [ 'id' => 4, 'pages' => [ @@ -154,7 +155,7 @@ public function test_import_upload_success() ], ]; - $resp = $this->runImportFromFile($this->zipUploadFromData($data)); + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData($data)); $this->assertDatabaseHas('imports', [ 'name' => 'My great book name', @@ -217,7 +218,7 @@ public function test_import_show_page_access_limited() public function test_import_delete() { $this->asAdmin(); - $this->runImportFromFile($this->zipUploadFromData([ + $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ 'book' => [ 'name' => 'My great book name' ], @@ -262,20 +263,126 @@ public function test_import_delete_access_limited() $this->delete("/import/{$adminImport->id}")->assertRedirect('/import'); } - protected function runImportFromFile(UploadedFile $file): TestResponse + public function test_run_simple_success_scenario() { - return $this->call('POST', '/import', [], [], ['file' => $file]); + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'name' => 'My imported book', + 'pages' => [ + [ + 'name' => 'My imported book page', + 'html' => '

                                Hello there from child page!

                                ' + ] + ], + ] + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}"); + $book = Book::query()->where('name', '=', 'My imported book')->latest()->first(); + $resp->assertRedirect($book->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSee('My imported book page'); + $resp->assertSee('Hello there from child page!'); + + $this->assertDatabaseMissing('imports', ['id' => $import->id]); + $this->assertFileDoesNotExist(storage_path($import->path)); + $this->assertActivityExists(ActivityType::IMPORT_RUN, null, $import->logDescriptor()); } - protected function zipUploadFromData(array $data): UploadedFile + public function test_import_run_access_limited() { - $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + $user = $this->users->editor(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); - $zip = new ZipArchive(); - $zip->open($zipFile, ZipArchive::CREATE); - $zip->addFromString('data.json', json_encode($data)); - $zip->close(); + $this->post("/import/{$userImport->id}")->assertRedirect('/'); + $this->post("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->post("/import/{$userImport->id}")->assertRedirect($userImport->getUrl()); // Getting validation response instead of access issue response + $this->post("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->post("/import/{$adminImport->id}")->assertRedirect($adminImport->getUrl()); // Getting validation response instead of access issue response + } + + public function test_run_revalidates_content() + { + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'id' => 'abc', + ] + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}"); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('The name field is required.'); + $resp->assertSeeText('The id must be an integer.'); + } + + public function test_run_checks_permissions_on_import() + { + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['content-import']); + $import = ZipTestHelper::importFromData(['created_by' => $viewer->id], [ + 'book' => ['name' => 'My import book'], + ]); + + $resp = $this->asViewer()->post("/import/{$import->id}"); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('You are lacking the required permissions to create books.'); + } + + public function test_run_requires_parent_for_chapter_and_page_imports() + { + $book = $this->entities->book(); + $pageImport = ZipTestHelper::importFromData([], [ + 'page' => ['name' => 'My page', 'html' => '

                                page test!

                                '], + ]); + $chapterImport = ZipTestHelper::importFromData([], [ + 'chapter' => ['name' => 'My chapter'], + ]); + + $resp = $this->asAdmin()->post("/import/{$pageImport->id}"); + $resp->assertRedirect($pageImport->getUrl()); + $this->followRedirects($resp)->assertSee('The parent field is required.'); + + $resp = $this->asAdmin()->post("/import/{$pageImport->id}", ['parent' => "book:{$book->id}"]); + $resp->assertRedirectContains($book->getUrl()); + + $resp = $this->asAdmin()->post("/import/{$chapterImport->id}"); + $resp->assertRedirect($chapterImport->getUrl()); + $this->followRedirects($resp)->assertSee('The parent field is required.'); + + $resp = $this->asAdmin()->post("/import/{$chapterImport->id}", ['parent' => "book:{$book->id}"]); + $resp->assertRedirectContains($book->getUrl()); + } + + public function test_run_validates_correct_parent_type() + { + $chapter = $this->entities->chapter(); + $import = ZipTestHelper::importFromData([], [ + 'chapter' => ['name' => 'My chapter'], + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}", ['parent' => "chapter:{$chapter->id}"]); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Parent book required for chapter import.'); + } - return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + protected function runImportFromFile(UploadedFile $file): TestResponse + { + return $this->call('POST', '/import', [], [], ['file' => $file]); } } diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php new file mode 100644 index 00000000000..3a9b3435454 --- /dev/null +++ b/tests/Exports/ZipTestHelper.php @@ -0,0 +1,47 @@ +create($importData); + $zip = static::zipUploadFromData($zipData); + rename($zip->getRealPath(), storage_path($import->path)); + + return $import; + } + + public static function deleteZipForImport(Import $import): void + { + $path = storage_path($import->path); + if (file_exists($path)) { + unlink($path); + } + } + + public static function zipUploadFromData(array $data): UploadedFile + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('data.json', json_encode($data)); + $zip->close(); + + return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + } +} From 8645aeaa4a914c5ee7e0d07a9202b8812aefcafe Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2024 16:12:45 +0000 Subject: [PATCH 220/800] ZIP Imports: Started testing core import logic Fixed image size handling, and lack of attachment reference replacements during testing. --- .../ZipExports/ZipImportReferences.php | 4 +- app/Exports/ZipExports/ZipImportRunner.php | 4 + app/Uploads/ImageService.php | 5 +- tests/Exports/ZipImportRunnerTest.php | 152 ++++++++++++++++++ tests/Exports/ZipTestHelper.php | 11 +- 5 files changed, 170 insertions(+), 6 deletions(-) diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index b23d5e72b15..da0581df6f5 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -97,10 +97,12 @@ protected function handleReference(string $type, int $id): ?string } else if ($model instanceof Image) { if ($model->type === 'gallery') { $this->imageResizer->loadGalleryThumbnailsForImage($model, false); - return $model->thumbs['gallery'] ?? $model->url; + return $model->thumbs['display'] ?? $model->url; } return $model->url; + } else if ($model instanceof Attachment) { + return $model->getUrl(false); } return null; diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index c5b9da31909..27d859e5915 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -233,6 +233,10 @@ protected function importImage(ZipExportImage $exportImage, Page $page, ZipExpor $file, $exportImage->type, $page->id, + null, + null, + true, + $exportImage->name, ); $this->references->addImage($image, $exportImage->id); diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 5c455cf8633..038e6aa417c 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -33,9 +33,10 @@ public function saveNewFromUpload( int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, - bool $keepRatio = true + bool $keepRatio = true, + string $imageName = '', ): Image { - $imageName = $uploadedFile->getClientOriginalName(); + $imageName = $imageName ?: $uploadedFile->getClientOriginalName(); $imageData = file_get_contents($uploadedFile->getRealPath()); if ($resizeWidth !== null || $resizeHeight !== null) { diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index 7bdd8ecbb65..f07b3f41b42 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -2,7 +2,10 @@ namespace Tests\Exports; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipImportRunner; +use BookStack\Uploads\Image; use Tests\TestCase; class ZipImportRunnerTest extends TestCase @@ -15,6 +18,155 @@ protected function setUp(): void $this->runner = app()->make(ZipImportRunner::class); } + public function test_book_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'id' => 5, + 'name' => 'Import test', + 'cover' => 'book_cover_image', + 'description_html' => '

                                Link to chapter page

                                ', + 'tags' => [ + ['name' => 'Animal', 'value' => 'Cat'], + ['name' => 'Category', 'value' => 'Test'], + ], + 'chapters' => [ + [ + 'id' => 6, + 'name' => 'Chapter A', + 'description_html' => '

                                Link to book

                                ', + 'priority' => 1, + 'tags' => [ + ['name' => 'Reviewed'], + ['name' => 'Category', 'value' => 'Test Chapter'], + ], + 'pages' => [ + [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => ' +

                                Link to self

                                +

                                Link to cat image

                                +

                                Link to text attachment

                                ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ], + [ + 'name' => 'Cats', + 'link' => 'https://example.com/cats', + ] + ], + 'images' => [ + [ + 'id' => 1, + 'name' => 'Cat', + 'type' => 'gallery', + 'file' => 'cat_image' + ], + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + ], + ], + [ + 'name' => 'Chapter child B', + 'priority' => 5, + ] + ], + 'pages' => [ + [ + 'name' => 'Page C', + 'markdown' => '[Link to text]([[bsexport:attachment:4]]?scale=big)', + 'priority' => 3, + ] + ], + ], + ], [ + 'book_cover_image' => $testImagePath, + 'file_attachment' => $testFilePath, + 'cat_image' => $testImagePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Book $book */ + $book = $this->runner->run($import); + + // Book checks + $this->assertEquals('Import test', $book->name); + $this->assertFileExists(public_path($book->cover->path)); + $this->assertCount(2, $book->tags); + $this->assertEquals('Cat', $book->tags()->first()->value); + $this->assertCount(2, $book->chapters); + $this->assertEquals(1, $book->directPages()->count()); + + // Chapter checks + $chapterA = $book->chapters()->where('name', 'Chapter A')->first(); + $this->assertCount(2, $chapterA->tags); + $firstChapterTag = $chapterA->tags()->first(); + $this->assertEquals('Reviewed', $firstChapterTag->name); + $this->assertEquals('', $firstChapterTag->value); + $this->assertCount(1, $chapterA->pages); + + // Page checks + /** @var Page $pageA */ + $pageA = $chapterA->pages->first(); + $this->assertEquals('Page A', $pageA->name); + $this->assertCount(1, $pageA->tags); + $firstPageTag = $pageA->tags()->first(); + $this->assertEquals('Unreviewed', $firstPageTag->name); + $this->assertCount(2, $pageA->attachments); + $firstAttachment = $pageA->attachments->first(); + $this->assertEquals('Text attachment', $firstAttachment->name); + $this->assertFileEquals($testFilePath, storage_path($firstAttachment->path)); + $this->assertFalse($firstAttachment->external); + $secondAttachment = $pageA->attachments->last(); + $this->assertEquals('Cats', $secondAttachment->name); + $this->assertEquals('https://example.com/cats', $secondAttachment->path); + $this->assertTrue($secondAttachment->external); + $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(2, $pageAImages); + $this->assertEquals('Cat', $pageAImages[0]->name); + $this->assertEquals('gallery', $pageAImages[0]->type); + $this->assertFileEquals($testImagePath, public_path($pageAImages[0]->path)); + $this->assertEquals('Dog Drawing', $pageAImages[1]->name); + $this->assertEquals('drawio', $pageAImages[1]->type); + + // Book order check + $children = $book->getDirectVisibleChildren()->values()->all(); + $this->assertEquals($children[0]->name, 'Chapter A'); + $this->assertEquals($children[1]->name, 'Page C'); + $this->assertEquals($children[2]->name, 'Chapter child B'); + + // Reference checks + $textAttachmentUrl = $firstAttachment->getUrl(); + $this->assertStringContainsString($pageA->getUrl(), $book->description_html); + $this->assertStringContainsString($book->getUrl(), $chapterA->description_html); + $this->assertStringContainsString($pageA->getUrl(), $pageA->html); + $this->assertStringContainsString($pageAImages[0]->getThumb(1680, null, true), $pageA->html); + $this->assertStringContainsString($firstAttachment->getUrl(), $pageA->html); + + // Reference in converted markdown + $pageC = $children[1]; + $this->assertStringContainsString("href=\"{$textAttachmentUrl}?scale=big\"", $pageC->html); + + ZipTestHelper::deleteZipForImport($import); + } + // TODO - Test full book import // TODO - Test full chapter import // TODO - Test full page import diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php index 3a9b3435454..2196f361c17 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -8,7 +8,7 @@ class ZipTestHelper { - public static function importFromData(array $importData, array $zipData): Import + public static function importFromData(array $importData, array $zipData, array $files = []): Import { if (isset($zipData['book'])) { $importData['type'] = 'book'; @@ -19,7 +19,7 @@ public static function importFromData(array $importData, array $zipData): Import } $import = Import::factory()->create($importData); - $zip = static::zipUploadFromData($zipData); + $zip = static::zipUploadFromData($zipData, $files); rename($zip->getRealPath(), storage_path($import->path)); return $import; @@ -33,13 +33,18 @@ public static function deleteZipForImport(Import $import): void } } - public static function zipUploadFromData(array $data): UploadedFile + public static function zipUploadFromData(array $data, array $files = []): UploadedFile { $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); $zip = new ZipArchive(); $zip->open($zipFile, ZipArchive::CREATE); $zip->addFromString('data.json', json_encode($data)); + + foreach ($files as $name => $file) { + $zip->addFile($file, "files/$name"); + } + $zip->close(); return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); From c2c64e207f89567350eab4b40b725e8d042c9654 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2024 19:52:20 +0000 Subject: [PATCH 221/800] ZIP Imports: Covered import runner with further testing --- tests/Exports/ZipImportRunnerTest.php | 194 +++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 3 deletions(-) diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index f07b3f41b42..c833fadda96 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -3,6 +3,7 @@ namespace Tests\Exports; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Uploads\Image; @@ -167,7 +168,194 @@ public function test_book_import() ZipTestHelper::deleteZipForImport($import); } - // TODO - Test full book import - // TODO - Test full chapter import - // TODO - Test full page import + public function test_chapter_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->book(); + + $import = ZipTestHelper::importFromData([], [ + 'chapter' => [ + 'id' => 6, + 'name' => 'Chapter A', + 'description_html' => '

                                Link to page

                                ', + 'priority' => 1, + 'tags' => [ + ['name' => 'Reviewed', 'value' => '2024'], + ], + 'pages' => [ + [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => '

                                Link to chapter

                                +

                                Link to dog drawing

                                +

                                Link to text attachment

                                ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + [ + 'name' => 'Page B', + 'markdown' => '[Link to page A]([[bsexport:page:3]])', + 'priority' => 9, + ], + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Chapter $chapter */ + $chapter = $this->runner->run($import, $parent); + + // Chapter checks + $this->assertEquals('Chapter A', $chapter->name); + $this->assertEquals($parent->id, $chapter->book_id); + $this->assertCount(1, $chapter->tags); + $firstChapterTag = $chapter->tags()->first(); + $this->assertEquals('Reviewed', $firstChapterTag->name); + $this->assertEquals('2024', $firstChapterTag->value); + $this->assertCount(2, $chapter->pages); + + // Page checks + /** @var Page $pageA */ + $pageA = $chapter->pages->first(); + $this->assertEquals('Page A', $pageA->name); + $this->assertCount(1, $pageA->tags); + $this->assertCount(1, $pageA->attachments); + $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageAImages); + + // Reference checks + $attachment = $pageA->attachments->first(); + $this->assertStringContainsString($pageA->getUrl(), $chapter->description_html); + $this->assertStringContainsString($chapter->getUrl(), $pageA->html); + $this->assertStringContainsString($pageAImages[0]->url, $pageA->html); + $this->assertStringContainsString($attachment->getUrl(), $pageA->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_page_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => '

                                Link to self

                                +

                                Link to dog drawing

                                +

                                Link to text attachment

                                ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + // Page checks + $this->assertEquals('Page A', $page->name); + $this->assertCount(1, $page->tags); + $this->assertCount(1, $page->attachments); + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageImages); + $this->assertFileEquals($testImagePath, public_path($pageImages[0]->path)); + + // Reference checks + $this->assertStringContainsString($page->getUrl(), $page->html); + $this->assertStringContainsString($pageImages[0]->url, $page->html); + $this->assertStringContainsString($page->attachments->first()->getUrl(), $page->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_revert_cleans_up_uploaded_files() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

                                Hello

                                ', + 'attachments' => [ + [ + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'name' => 'Dog Image', + 'type' => 'gallery', + 'file' => 'dog_image' + ] + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $attachment = $page->attachments->first(); + $image = Image::query()->where('uploaded_to', '=', $page->id)->where('type', '=', 'gallery')->first(); + + $this->assertFileExists(public_path($image->path)); + $this->assertFileExists(storage_path($attachment->path)); + + $this->runner->revertStoredFiles(); + + $this->assertFileDoesNotExist(public_path($image->path)); + $this->assertFileDoesNotExist(storage_path($attachment->path)); + + ZipTestHelper::deleteZipForImport($import); + } } From e2f6e50df4347579e3b6eb8e7c48bfcb79199a64 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Nov 2024 15:53:21 +0000 Subject: [PATCH 222/800] ZIP Exports: Added ID checks and testing to validator --- .../ZipExports/Models/ZipExportAttachment.php | 2 +- .../ZipExports/Models/ZipExportBook.php | 2 +- .../ZipExports/Models/ZipExportChapter.php | 2 +- .../ZipExports/Models/ZipExportImage.php | 2 +- .../ZipExports/Models/ZipExportPage.php | 2 +- .../ZipExports/ZipFileReferenceRule.php | 1 - app/Exports/ZipExports/ZipUniqueIdRule.php | 26 +++++++ .../ZipExports/ZipValidationHelper.php | 24 ++++++ lang/en/validation.php | 1 + tests/Exports/ZipExportValidatorTests.php | 74 +++++++++++++++++++ 10 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 app/Exports/ZipExports/ZipUniqueIdRule.php create mode 100644 tests/Exports/ZipExportValidatorTests.php diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index c6615e1dc49..4f5b2f23699 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -43,7 +43,7 @@ public static function fromModelArray(array $attachmentArray, ZipExportFiles $fi public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], 'name' => ['required', 'string', 'min:1'], 'link' => ['required_without:file', 'nullable', 'string'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 0dc4e93d43c..47ab8f0a699 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -70,7 +70,7 @@ public static function fromModel(Book $model, ZipExportFiles $files): self public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('book')], 'name' => ['required', 'string', 'min:1'], 'description_html' => ['nullable', 'string'], 'cover' => ['nullable', 'string', $context->fileReferenceRule()], diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 50440d61a5a..5a5fe350f3a 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -59,7 +59,7 @@ public static function fromModelArray(array $chapterArray, ZipExportFiles $files public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')], 'name' => ['required', 'string', 'min:1'], 'description_html' => ['nullable', 'string'], 'priority' => ['nullable', 'int'], diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 691eb918fc6..89083b15be1 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -33,7 +33,7 @@ public function metadataOnly(): void public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('image')], 'name' => ['required', 'string', 'min:1'], 'file' => ['required', 'string', $context->fileReferenceRule()], 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 3a876e7aaff..16e7e925539 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -68,7 +68,7 @@ public static function fromModelArray(array $pageArray, ZipExportFiles $files): public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('page')], 'name' => ['required', 'string', 'min:1'], 'html' => ['nullable', 'string'], 'markdown' => ['nullable', 'string'], diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index bcd3c39acf0..7d6c829cf03 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -4,7 +4,6 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; -use ZipArchive; class ZipFileReferenceRule implements ValidationRule { diff --git a/app/Exports/ZipExports/ZipUniqueIdRule.php b/app/Exports/ZipExports/ZipUniqueIdRule.php new file mode 100644 index 00000000000..ea2b2539296 --- /dev/null +++ b/app/Exports/ZipExports/ZipUniqueIdRule.php @@ -0,0 +1,26 @@ +context->hasIdBeenUsed($this->modelType, $value)) { + $fail('validation.zip_unique')->translate(['attribute' => $attribute]); + } + } +} diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index 55c86b03b5b..7659c228bcd 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -9,6 +9,13 @@ class ZipValidationHelper { protected Factory $validationFactory; + /** + * Local store of validated IDs (in format ":". Example: "book:2") + * which we can use to check uniqueness. + * @var array + */ + protected array $validatedIds = []; + public function __construct( public ZipExportReader $zipReader, ) { @@ -31,6 +38,23 @@ public function fileReferenceRule(): ZipFileReferenceRule return new ZipFileReferenceRule($this); } + public function uniqueIdRule(string $type): ZipUniqueIdRule + { + return new ZipUniqueIdRule($this, $type); + } + + public function hasIdBeenUsed(string $type, int $id): bool + { + $key = $type . ':' . $id; + if (isset($this->validatedIds[$key])) { + return true; + } + + $this->validatedIds[$key] = true; + + return false; + } + /** * Validate an array of relation data arrays that are expected * to be for the given ZipExportModel. diff --git a/lang/en/validation.php b/lang/en/validation.php index bc01ac47b94..fdfc3d9a9b9 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -107,6 +107,7 @@ 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', // Custom validation lines 'custom' => [ diff --git a/tests/Exports/ZipExportValidatorTests.php b/tests/Exports/ZipExportValidatorTests.php new file mode 100644 index 00000000000..4cacea95ec6 --- /dev/null +++ b/tests/Exports/ZipExportValidatorTests.php @@ -0,0 +1,74 @@ +filesToRemove as $file) { + unlink($file); + } + + parent::tearDown(); + } + + protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator + { + $upload = ZipTestHelper::zipUploadFromData($zipData, $files); + $path = $upload->getRealPath(); + $this->filesToRemove[] = $path; + $reader = new ZipExportReader($path); + return new ZipExportValidator($reader); + } + + public function test_ids_have_to_be_unique() + { + $validator = $this->getValidatorForData([ + 'book' => [ + 'id' => 4, + 'name' => 'My book', + 'pages' => [ + [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'attachments' => [ + ['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'], + ['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com'] + ], + 'images' => [ + ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'], + ['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'], + ], + ], + ['id' => 4, 'name' => 'My page', 'markdown' => 'hello'], + ], + 'chapters' => [ + ['id' => 4, 'name' => 'Chapter 1'], + ['id' => 4, 'name' => 'Chapter 2'] + ] + ] + ], ['cat' => $this->files->testFilePath('test-image.png')]); + + $results = $validator->validate(); + $this->assertCount(4, $results); + + $expectedMessage = 'The id must be unique for the object type within the ZIP.'; + $this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']); + $this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']); + $this->assertEquals($expectedMessage, $results['book.pages.1.id']); + $this->assertEquals($expectedMessage, $results['book.chapters.1.id']); + } +} From 59cfc087e12c8752b4a9f1760db71a13ad6c121c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Nov 2024 17:42:49 +0000 Subject: [PATCH 223/800] ZIP Imports: Added image type validation/handling Images were missing their extension after import since it was (potentially) not part of the import data. This adds validation via mime sniffing (to match normal image upload checks) and also uses the same logic to sniff out a correct extension. Added tests to cover. Also fixed some existing tests around zip functionality. --- .../ZipExports/Models/ZipExportImage.php | 3 +- app/Exports/ZipExports/ZipExportReader.php | 12 +++++++ .../ZipExports/ZipFileReferenceRule.php | 12 +++++++ app/Exports/ZipExports/ZipImportRunner.php | 8 ++++- .../ZipExports/ZipValidationHelper.php | 6 ++-- lang/en/validation.php | 1 + tests/Exports/ZipExportTest.php | 4 --- ...orTests.php => ZipExportValidatorTest.php} | 21 ++++++++++- tests/Exports/ZipImportRunnerTest.php | 35 +++++++++++++++++++ 9 files changed, 92 insertions(+), 10 deletions(-) rename tests/Exports/{ZipExportValidatorTests.php => ZipExportValidatorTest.php} (77%) diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 89083b15be1..e0e7d11986d 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -32,10 +32,11 @@ public function metadataOnly(): void public static function validate(ZipValidationHelper $context, array $data): array { + $acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('image')], 'name' => ['required', 'string', 'min:1'], - 'file' => ['required', 'string', $context->fileReferenceRule()], + 'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)], 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], ]; diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index ebc2fbbc9e1..6b88ef61c33 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -7,6 +7,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; +use BookStack\Util\WebSafeMimeSniffer; use ZipArchive; class ZipExportReader @@ -81,6 +82,17 @@ public function streamFile(string $fileName) return $this->zip->getStream("files/{$fileName}"); } + /** + * Sniff the mime type from the file of given name. + */ + public function sniffFileMime(string $fileName): string + { + $stream = $this->streamFile($fileName); + $sniffContent = fread($stream, 2000); + + return (new WebSafeMimeSniffer())->sniff($sniffContent); + } + /** * @throws ZipExportException */ diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index 7d6c829cf03..90e78c060b0 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -9,6 +9,7 @@ class ZipFileReferenceRule implements ValidationRule { public function __construct( protected ZipValidationHelper $context, + protected array $acceptedMimes, ) { } @@ -21,5 +22,16 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if (!$this->context->zipReader->fileExists($value)) { $fail('validation.zip_file')->translate(); } + + if (!empty($this->acceptedMimes)) { + $fileMime = $this->context->zipReader->sniffFileMime($value); + if (!in_array($fileMime, $this->acceptedMimes)) { + $fail('validation.zip_file_mime')->translate([ + 'attribute' => $attribute, + 'validTypes' => implode(',', $this->acceptedMimes), + 'foundType' => $fileMime + ]); + } + } } } diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 27d859e5915..d25a1621f6e 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -228,6 +228,9 @@ protected function importAttachment(ZipExportAttachment $exportAttachment, Page protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image { + $mime = $reader->sniffFileMime($exportImage->file); + $extension = explode('/', $mime)[1]; + $file = $this->zipFileToUploadedFile($exportImage->file, $reader); $image = $this->imageService->saveNewFromUpload( $file, @@ -236,9 +239,12 @@ protected function importImage(ZipExportImage $exportImage, Page $page, ZipExpor null, null, true, - $exportImage->name, + $exportImage->name . '.' . $extension, ); + $image->name = $exportImage->name; + $image->save(); + $this->references->addImage($image, $exportImage->id); return $image; diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index 7659c228bcd..fd9cd784472 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -33,9 +33,9 @@ public function validateData(array $data, array $rules): array return $messages; } - public function fileReferenceRule(): ZipFileReferenceRule + public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule { - return new ZipFileReferenceRule($this); + return new ZipFileReferenceRule($this, $acceptedMimes); } public function uniqueIdRule(string $type): ZipUniqueIdRule @@ -43,7 +43,7 @@ public function uniqueIdRule(string $type): ZipUniqueIdRule return new ZipUniqueIdRule($this, $type); } - public function hasIdBeenUsed(string $type, int $id): bool + public function hasIdBeenUsed(string $type, mixed $id): bool { $key = $type . ':' . $id; if (isset($this->validatedIds[$key])) { diff --git a/lang/en/validation.php b/lang/en/validation.php index fdfc3d9a9b9..d9b982d1e23 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,6 +106,7 @@ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index ac07b33aef5..12531239ff3 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -107,12 +107,10 @@ public function test_page_export_with_tags() [ 'name' => 'Exporty', 'value' => 'Content', - 'order' => 1, ], [ 'name' => 'Another', 'value' => '', - 'order' => 2, ] ], $pageData['tags']); } @@ -162,7 +160,6 @@ public function test_page_export_file_attachments() $attachmentData = $pageData['attachments'][0]; $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']); $this->assertEquals($attachment->id, $attachmentData['id']); - $this->assertEquals(1, $attachmentData['order']); $this->assertArrayNotHasKey('link', $attachmentData); $this->assertNotEmpty($attachmentData['file']); @@ -193,7 +190,6 @@ public function test_page_export_link_attachments() $attachmentData = $pageData['attachments'][0]; $this->assertEquals('My link attachment for export', $attachmentData['name']); $this->assertEquals($attachment->id, $attachmentData['id']); - $this->assertEquals(1, $attachmentData['order']); $this->assertEquals('https://example.com/cats', $attachmentData['link']); $this->assertArrayNotHasKey('file', $attachmentData); } diff --git a/tests/Exports/ZipExportValidatorTests.php b/tests/Exports/ZipExportValidatorTest.php similarity index 77% rename from tests/Exports/ZipExportValidatorTests.php rename to tests/Exports/ZipExportValidatorTest.php index 4cacea95ec6..c453ef294d4 100644 --- a/tests/Exports/ZipExportValidatorTests.php +++ b/tests/Exports/ZipExportValidatorTest.php @@ -11,7 +11,7 @@ use BookStack\Uploads\Image; use Tests\TestCase; -class ZipExportValidatorTests extends TestCase +class ZipExportValidatorTest extends TestCase { protected array $filesToRemove = []; @@ -71,4 +71,23 @@ public function test_ids_have_to_be_unique() $this->assertEquals($expectedMessage, $results['book.pages.1.id']); $this->assertEquals($expectedMessage, $results['book.chapters.1.id']); } + + public function test_image_files_need_to_be_a_valid_detected_image_file() + { + $validator = $this->getValidatorForData([ + 'page' => [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'images' => [ + ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'], + ], + ] + ], ['cat' => $this->files->testFilePath('test-file.txt')]); + + $results = $validator->validate(); + $this->assertCount(1, $results); + + $this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']); + } } diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index c833fadda96..d3af6df76a6 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -358,4 +358,39 @@ public function test_revert_cleans_up_uploaded_files() ZipTestHelper::deleteZipForImport($import); } + + public function test_imported_images_have_their_detected_extension_added() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

                                hello

                                ', + 'images' => [ + [ + 'id' => 2, + 'name' => 'Cat', + 'type' => 'gallery', + 'file' => 'cat_image' + ] + ], + ], + ], [ + 'cat_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + + $this->assertCount(1, $pageImages); + $this->assertStringEndsWith('.png', $pageImages[0]->url); + $this->assertStringEndsWith('.png', $pageImages[0]->path); + + ZipTestHelper::deleteZipForImport($import); + } } From c0dff6d4a6227549e7f756cdd0d7cd6a003b9886 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Nov 2024 21:03:04 +0000 Subject: [PATCH 224/800] ZIP Imports: Added book content ordering to import preview --- app/Exports/ZipExports/Models/ZipExportBook.php | 14 ++++++++++++++ app/Exports/ZipExports/Models/ZipExportChapter.php | 7 ++++++- app/Exports/ZipExports/Models/ZipExportPage.php | 2 +- app/Exports/ZipExports/ZipExportReader.php | 1 - .../views/exports/parts/import-item.blade.php | 14 ++++++++------ 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 47ab8f0a699..4f641d25bd7 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -36,6 +36,20 @@ public function metadataOnly(): void } } + public function children(): array + { + $children = [ + ...$this->pages, + ...$this->chapters, + ]; + + usort($children, function ($a, $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + return $children; + } + public static function fromModel(Book $model, ZipExportFiles $files): self { $instance = new self(); diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 5a5fe350f3a..bf2dc78f8de 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -20,7 +20,7 @@ class ZipExportChapter extends ZipExportModel public function metadataOnly(): void { - $this->description_html = $this->priority = null; + $this->description_html = null; foreach ($this->pages as $page) { $page->metadataOnly(); @@ -30,6 +30,11 @@ public function metadataOnly(): void } } + public function children(): array + { + return $this->pages; + } + public static function fromModel(Chapter $model, ZipExportFiles $files): self { $instance = new self(); diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 16e7e925539..097443df02b 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -23,7 +23,7 @@ class ZipExportPage extends ZipExportModel public function metadataOnly(): void { - $this->html = $this->markdown = $this->priority = null; + $this->html = $this->markdown = null; foreach ($this->attachments as $attachment) { $attachment->metadataOnly(); diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index 6b88ef61c33..c3d5c23cfec 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\ZipExportException; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; -use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Util\WebSafeMimeSniffer; use ZipArchive; diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php index 811a3b31bda..5da4b21405d 100644 --- a/resources/views/exports/parts/import-item.blade.php +++ b/resources/views/exports/parts/import-item.blade.php @@ -16,11 +16,13 @@ @icon('tag'){{ count($model->tags) }} @endif
                                - @foreach($model->chapters ?? [] as $chapter) - @include('exports.parts.import-item', ['type' => 'chapter', 'model' => $chapter]) - @endforeach - @foreach($model->pages ?? [] as $page) - @include('exports.parts.import-item', ['type' => 'page', 'model' => $page]) - @endforeach + @if(method_exists($model, 'children')) + @foreach($model->children() as $child) + @include('exports.parts.import-item', [ + 'type' => ($child instanceof \BookStack\Exports\ZipExports\Models\ZipExportPage) ? 'page' : 'chapter', + 'model' => $child + ]) + @endforeach + @endif
                                \ No newline at end of file From f79c6aef8d1c9aa83e9ce89ec1e5ac9d9e1eb570 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Nov 2024 21:36:42 +0000 Subject: [PATCH 225/800] ZIP Imports: Updated import form to show loading indicator And disable button after submit. Added here because the import could take some time, so it's best to show an indicator to the user to show that something is happening, and help prevent duplicate submission or re-submit attempts. --- resources/js/components/index.js | 1 + resources/js/components/loading-button.ts | 38 +++++++++++++++++++ resources/sass/styles.scss | 4 ++ resources/views/exports/import-show.blade.php | 4 +- 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/loading-button.ts diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 8ad5e14cb2e..12c991a51d8 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -30,6 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; +export {LoadingButton} from './loading-button'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; diff --git a/resources/js/components/loading-button.ts b/resources/js/components/loading-button.ts new file mode 100644 index 00000000000..a793d30a235 --- /dev/null +++ b/resources/js/components/loading-button.ts @@ -0,0 +1,38 @@ +import {Component} from "./component.js"; +import {showLoading} from "../services/dom"; +import {el} from "../wysiwyg/utils/dom"; + +/** + * Loading button. + * Shows a loading indicator and disables the button when the button is clicked, + * or when the form attached to the button is submitted. + */ +export class LoadingButton extends Component { + + protected button!: HTMLButtonElement; + protected loadingEl: HTMLDivElement|null = null; + + setup() { + this.button = this.$el as HTMLButtonElement; + const form = this.button.form; + + const action = () => { + setTimeout(() => this.showLoadingState(), 10) + }; + + this.button.addEventListener('click', action); + if (form) { + form.addEventListener('submit', action); + } + } + + showLoadingState() { + this.button.disabled = true; + + if (!this.loadingEl) { + this.loadingEl = el('div', {class: 'inline block'}) as HTMLDivElement; + showLoading(this.loadingEl); + this.button.after(this.loadingEl); + } + } +} \ No newline at end of file diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 2cf3cbf8221..2106f86e625 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -106,6 +106,10 @@ $loadingSize: 10px; } } +.inline.block .loading-container { + margin: $-xs $-s; +} + .skip-to-content-link { position: fixed; top: -52px; diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index e4f199aa20c..a28b79bb35c 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -59,7 +59,7 @@ ]) @endif -
                                +
                                {{ trans('common.cancel') }}
                                - +
                                From 9ecc91929a60a66ff7e821dfbc9d2b55197988f7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Nov 2024 15:54:15 +0000 Subject: [PATCH 226/800] ZIP Import & Exports: Addressed issues during testing - Handled links to within-zip page images found in chapter/book descriptions; Added test to cover. - Fixed session showing unrelated success on failed import. Tested import file-create undo on failure as part of this testing. --- app/Exports/Controllers/ImportController.php | 2 ++ .../ZipExports/ZipExportReferences.php | 7 ++--- lang/en/errors.php | 1 + tests/Exports/ZipExportTest.php | 26 +++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index a20c341fb02..b938dac8e29 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -89,6 +89,8 @@ public function run(int $id, Request $request) try { $entity = $this->imports->runImport($import, $parent); } catch (ZipImportException $exception) { + session()->flush(); + $this->showErrorNotification(trans('errors.import_zip_failed_notification')); return redirect($import->getUrl())->with('import_errors', $exception->errors); } diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index 0de409fa19a..bf5e02133ef 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -127,11 +127,12 @@ protected function handleModelReference(Model $model, ZipExportModel $exportMode return null; } - // We don't expect images to be part of book/chapter content - if (!($exportModel instanceof ZipExportPage)) { - return null; + // Handle simple links outside of page content + if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) { + return "[[bsexport:image:{$model->id}]]"; } + // Find and include images if in visibility $page = $model->getPage(); if ($page && userCan('view', $page)) { if (!isset($this->images[$model->id])) { diff --git a/lang/en/errors.php b/lang/en/errors.php index ced80a32c1f..9d738379648 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -110,6 +110,7 @@ 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', 'import_perms_pages' => 'You are lacking the required permissions to create pages.', diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 12531239ff3..6e8462f596f 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -274,6 +274,32 @@ public function test_cross_reference_links_are_converted() $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']); } + public function test_book_and_chapter_description_links_to_images_in_pages_are_converted() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + $this->asEditor(); + $this->files->uploadGalleryImageToPage($this, $page); + /** @var Image $image */ + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id)->first(); + + $book->description_html = '

                                Link to image

                                '; + $book->save(); + $chapter->description_html = '

                                Link to image

                                '; + $chapter->save(); + + $zipResp = $this->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $bookData = $zip->data['book']; + $chapterData = $bookData['chapters'][0]; + + $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $bookData['description_html']); + $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']); + } + public function test_cross_reference_links_external_to_export_are_not_converted() { $page = $this->entities->page(); From 95d62e7f573b5bbb04a66fae926c8a33c6ab5c43 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Nov 2024 16:23:59 +0000 Subject: [PATCH 227/800] ZIP Imports/Exports: Fixed some lint and test issues - Updated test handling to create imports folder when required. - Updated some tests to delete created import zip files. --- resources/js/components/index.js | 2 +- resources/js/components/page-comments.js | 1 - tests/Exports/ZipImportTest.php | 8 ++++++++ tests/Exports/ZipTestHelper.php | 9 ++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 12c991a51d8..24e60bd97f2 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -30,7 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; -export {LoadingButton} from './loading-button'; +export {LoadingButton} from './loading-button.ts'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 1d6abfe2044..63900888aca 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -93,7 +93,6 @@ export class PageComments extends Component { updateCount() { const count = this.getCommentCount(); - console.log('update count', count, this.container); this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count}); } diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index 3644e9bdcb7..ad0e6b24137 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -168,6 +168,8 @@ public function test_import_upload_success() $resp->assertRedirect("/import/{$import->id}"); $this->assertFileExists(storage_path($import->path)); $this->assertActivityExists(ActivityType::IMPORT_CREATE); + + ZipTestHelper::deleteZipForImport($import); } public function test_import_show_page() @@ -325,6 +327,8 @@ public function test_run_revalidates_content() $resp = $this->followRedirects($resp); $resp->assertSeeText('The name field is required.'); $resp->assertSeeText('The id must be an integer.'); + + ZipTestHelper::deleteZipForImport($import); } public function test_run_checks_permissions_on_import() @@ -340,6 +344,8 @@ public function test_run_checks_permissions_on_import() $resp = $this->followRedirects($resp); $resp->assertSeeText('You are lacking the required permissions to create books.'); + + ZipTestHelper::deleteZipForImport($import); } public function test_run_requires_parent_for_chapter_and_page_imports() @@ -379,6 +385,8 @@ public function test_run_validates_correct_parent_type() $resp = $this->followRedirects($resp); $resp->assertSee('Parent book required for chapter import.'); + + ZipTestHelper::deleteZipForImport($import); } protected function runImportFromFile(UploadedFile $file): TestResponse diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php index 2196f361c17..d830d8eb6bf 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -20,7 +20,14 @@ public static function importFromData(array $importData, array $zipData, array $ $import = Import::factory()->create($importData); $zip = static::zipUploadFromData($zipData, $files); - rename($zip->getRealPath(), storage_path($import->path)); + $targetPath = storage_path($import->path); + $targetDir = dirname($targetPath); + + if (!file_exists($targetDir)) { + mkdir($targetDir); + } + + rename($zip->getRealPath(), $targetPath); return $import; } From 0a182a45ba944c807c7a5ba6d6ba3a48809e1dc2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 Nov 2024 15:59:39 +0000 Subject: [PATCH 228/800] ZIP Exports: Added detection/handling of images with external storage Added test to cover. --- app/Exports/ZipExports/ZipReferenceParser.php | 23 +++++++++++++-- .../ModelResolvers/ImageModelResolver.php | 29 +++++++++++++++++-- app/Uploads/ImageStorage.php | 16 ++++++++-- tests/Exports/ZipExportTest.php | 24 +++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index 5929383b4dd..a6560e3f289 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -11,6 +11,7 @@ use BookStack\References\ModelResolvers\ImageModelResolver; use BookStack\References\ModelResolvers\PageLinkModelResolver; use BookStack\References\ModelResolvers\PagePermalinkModelResolver; +use BookStack\Uploads\ImageStorage; class ZipReferenceParser { @@ -33,8 +34,7 @@ public function __construct( */ public function parseLinks(string $content, callable $handler): string { - $escapedBase = preg_quote(url('/'), '/'); - $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#()]/"; + $linkRegex = $this->getLinkRegex(); $matches = []; preg_match_all($linkRegex, $content, $matches); @@ -118,4 +118,23 @@ protected function getModelResolvers(): array return $this->modelResolvers; } + + /** + * Build the regex to identify links we should handle in content. + */ + protected function getLinkRegex(): string + { + $urls = [rtrim(url('/'), '/')]; + $imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/'); + if ($urls[0] !== $imageUrl) { + $urls[] = $imageUrl; + } + + + $urlBaseRegex = implode('|', array_map(function ($url) { + return preg_quote($url, '/'); + }, $urls)); + + return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/"; + } } diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php index 331dd593b73..2c6c9fecd74 100644 --- a/app/References/ModelResolvers/ImageModelResolver.php +++ b/app/References/ModelResolvers/ImageModelResolver.php @@ -3,19 +3,22 @@ namespace BookStack\References\ModelResolvers; use BookStack\Uploads\Image; +use BookStack\Uploads\ImageStorage; class ImageModelResolver implements CrossLinkModelResolver { + protected ?string $pattern = null; + public function resolve(string $link): ?Image { - $pattern = '/^' . preg_quote(url('/uploads/images'), '/') . '\/(.+)/'; + $pattern = $this->getUrlPattern(); $matches = []; $match = preg_match($pattern, $link, $matches); if (!$match) { return null; } - $path = $matches[1]; + $path = $matches[2]; // Strip thumbnail element from path if existing $originalPathSplit = array_filter(explode('/', $path), function (string $part) { @@ -30,4 +33,26 @@ public function resolve(string $link): ?Image return Image::query()->where('path', '=', $fullPath)->first(); } + + /** + * Get the regex pattern to identify image URLs. + * Caches the pattern since it requires looking up to settings/config. + */ + protected function getUrlPattern(): string + { + if ($this->pattern) { + return $this->pattern; + } + + $urls = [url('/uploads/images')]; + $baseImageUrl = ImageStorage::getPublicUrl('/uploads/images'); + if ($baseImageUrl !== $urls[0]) { + $urls[] = $baseImageUrl; + } + + $imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls)); + $this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/'; + + return $this->pattern; + } } diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php index dc4abc0f281..ddaa26a9400 100644 --- a/app/Uploads/ImageStorage.php +++ b/app/Uploads/ImageStorage.php @@ -110,10 +110,20 @@ public function urlToPath(string $url): ?string } /** - * Gets a public facing url for an image by checking relevant environment variables. + * Gets a public facing url for an image or location at the given path. + */ + public static function getPublicUrl(string $filePath): string + { + return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/'); + } + + /** + * Get the public base URL used for images. + * Will not include any path element of the image file, just the base part + * from where the path is then expected to start from. * If s3-style store is in use it will default to guessing a public bucket URL. */ - public function getPublicUrl(string $filePath): string + protected static function getPublicBaseUrl(): string { $storageUrl = config('filesystems.url'); @@ -131,6 +141,6 @@ public function getPublicUrl(string $filePath): string $basePath = $storageUrl ?: url('/'); - return rtrim($basePath, '/') . $filePath; + return rtrim($basePath, '/'); } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 6e8462f596f..17891c73d73 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -300,6 +300,30 @@ public function test_book_and_chapter_description_links_to_images_in_pages_are_c $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']); } + public function test_image_links_are_handled_when_using_external_storage_url() + { + $page = $this->entities->page(); + + $this->asEditor(); + $this->files->uploadGalleryImageToPage($this, $page); + /** @var Image $image */ + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id)->first(); + + config()->set('filesystems.url', 'https://i.example.com/content'); + + $storageUrl = 'https://i.example.com/content/' . ltrim($image->path, '/'); + $page->html = '

                                Original URLStorage URL

                                '; + $page->save(); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $ref = '[[bsexport:image:' . $image->id . ']]'; + $this->assertStringContainsString("Original URLStorage URL", $pageData['html']); + } + public function test_cross_reference_links_external_to_export_are_not_converted() { $page = $this->entities->page(); From 17f7afe12d617358ba59489fb544f339839e7370 Mon Sep 17 00:00:00 2001 From: Wes Biggs Date: Tue, 26 Nov 2024 11:21:20 -0600 Subject: [PATCH 229/800] Updates the OIDC userinfo endpoint request to allow for a `Content-Type` response header with optional parameters, like `application/json; charset=utf-8`. This was causing an issue when integrating with [node-oidc-provider](https://github.com/panva/node-oidc-provider). --- app/Access/Oidc/OidcUserinfoResponse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Access/Oidc/OidcUserinfoResponse.php b/app/Access/Oidc/OidcUserinfoResponse.php index 9aded654e31..ffcacb8dee8 100644 --- a/app/Access/Oidc/OidcUserinfoResponse.php +++ b/app/Access/Oidc/OidcUserinfoResponse.php @@ -11,7 +11,7 @@ class OidcUserinfoResponse implements ProvidesClaims public function __construct(ResponseInterface $response, string $issuer, array $keys) { - $contentType = $response->getHeader('Content-Type')[0]; + $contentType = explode(';', $response->getHeader('Content-Type')[0], 2)[0]; if ($contentType === 'application/json') { $this->claims = json_decode($response->getBody()->getContents(), true); } From edb684c72ce0b1f1cf9be90d338ee08e24b4a0cc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 Nov 2024 17:53:20 +0000 Subject: [PATCH 230/800] ZIP Exports: Updated format doc with advisories regarding html/md --- dev/docs/portable-zip-file-format.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 7e5df3f015b..fbb31785824 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -13,7 +13,8 @@ Following the goals & ideals of BookStack, stability is very important. We aim f - Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties. - Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes. -The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage. For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you. +The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage. +For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you. ## Format Outline @@ -57,6 +58,23 @@ Here's an example of each type of such reference that could be used: [[bsexport:book:8]] ``` +## HTML & Markdown Content + +BookStack commonly stores & utilises content in the HTML format. +Properties that expect or provided HTML will either be named `html` or contain `html` in the property name. +While BookStack supports a range of HTML, not all HTML content will be supported by BookStack and be assured to work as desired across all BookStack features. +The HTML supported by BookStack is not yet formally documented, but you can inspect to what the WYSIWYG editor produces as a basis. +Generally, top-level elements should keep to common block formats (p, blockquote, h1, h2 etc...) with no nesting or custom structure apart from common inline elements. +Some areas of BookStack where HTML is used, like book & chapter descriptions, will strictly limit/filter HTML tag & attributes to an allow-list. + +For markdown content, in BookStack we target [the commonmark spec](https://commonmark.org/) with the addition of tables & task-lists. +HTML within markdown is supported but not all HTML is assured to work as advised above. + +### Content Security + +If you're consuming HTML or markdown within an export please consider that the content is not assured to be safe, even if provided directly by a BookStack instance. It's best to treat such content as potentially unsafe. +By default, BookStack performs some basic filtering to remove scripts among other potentially dangerous elements but this is not foolproof. BookStack itself relies on additional security mechanisms such as [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to help prevent a range of exploits. + ## Export Data - `data.json` The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows: @@ -114,9 +132,9 @@ The `pages` are not all pages within the book, just those that are direct childr - `images` - [Image](#image) array, optional, images used in this page. - `tags` - [Tag](#tag) array, optional, tags assigned to this page. -To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. +To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. See the ["HTML & Markdown Content"](#html--markdown-content) section. -The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor and display content. +The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor & display content. #### Image From bdca9fc1ce6f3f792106e86348cfb1479f4dd27c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 27 Nov 2024 16:30:19 +0000 Subject: [PATCH 231/800] ZIP Exports: Changed the instance id mechanism Adds an instance id via app settings. --- app/Exports/ZipExports/ZipExportBuilder.php | 4 +-- ...4_11_27_171039_add_instance_id_setting.php | 30 +++++++++++++++++++ dev/docs/portable-zip-file-format.md | 6 ++-- tests/Exports/ZipExportTest.php | 6 ++-- 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2024_11_27_171039_add_instance_id_setting.php diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php index 42fb03541c0..4c5c638f591 100644 --- a/app/Exports/ZipExports/ZipExportBuilder.php +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -69,8 +69,8 @@ protected function build(): string $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ - 'version' => trim(file_get_contents(base_path('version'))), - 'id_ciphertext' => encrypt('bookstack'), + 'id' => setting('instance-id', ''), + 'version' => trim(file_get_contents(base_path('version'))), ]; $zipFile = tempnam(sys_get_temp_dir(), 'bszip-'); diff --git a/database/migrations/2024_11_27_171039_add_instance_id_setting.php b/database/migrations/2024_11_27_171039_add_instance_id_setting.php new file mode 100644 index 00000000000..ee1e90d0303 --- /dev/null +++ b/database/migrations/2024_11_27_171039_add_instance_id_setting.php @@ -0,0 +1,30 @@ +insert([ + 'setting_key' => 'instance-id', + 'value' => Str::uuid(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'type' => 'string', + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('settings')->where('setting_key', '=', 'instance-id')->delete(); + } +}; diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index fbb31785824..754cb4d3e94 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -93,12 +93,10 @@ The below details the objects & their properties used in Application Data. #### Instance -These details are mainly informational regarding the exporting BookStack instance from where an export was created from. +These details are informational regarding the exporting BookStack instance from where an export was created from. +- `id` - String, required, unique identifier for the BookStack instance. - `version` - String, required, BookStack version of the export source instance. -- `id_ciphertext` - String, required, identifier for the BookStack instance. - -The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This is used as a simple & rough way for a BookStack instance to be able to identify if they were the source (by attempting to decrypt the ciphertext). #### Book diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 17891c73d73..ebe07d052bc 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -54,8 +54,10 @@ public function test_export_metadata() $version = trim(file_get_contents(base_path('version'))); $this->assertEquals($version, $zip->data['instance']['version']); - $instanceId = decrypt($zip->data['instance']['id_ciphertext']); - $this->assertEquals('bookstack', $instanceId); + $zipInstanceId = $zip->data['instance']['id']; + $instanceId = setting('instance-id'); + $this->assertNotEmpty($instanceId); + $this->assertEquals($instanceId, $zipInstanceId); } public function test_page_export() From 68ce34074110375d52f3496c1377515e8634d615 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 28 Nov 2024 16:25:01 +0000 Subject: [PATCH 232/800] Depenencies: Updated PHP packages --- composer.lock | 347 ++++++++++++++++++++++++-------------------------- 1 file changed, 168 insertions(+), 179 deletions(-) diff --git a/composer.lock b/composer.lock index 2d798fd6bed..f744f76208c 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.325.7", + "version": "3.331.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "55852104253172e66c3358bf4c35281bbd8622b2" + "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/55852104253172e66c3358bf4c35281bbd8622b2", - "reference": "55852104253172e66c3358bf4c35281bbd8622b2", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f8b3f63ba7b296afedcb3e6a43ce140831b9400", + "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400", "shasum": "" }, "require": { @@ -100,8 +100,8 @@ "nette/neon": "^2.3", "paragonie/random_compat": ">= 2", "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", - "psr/cache": "^1.0", - "psr/simple-cache": "^1.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0", "yoast/phpunit-polyfills": "^1.0" }, @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.325.7" + "source": "https://github.com/aws/aws-sdk-php/tree/3.331.0" }, - "time": "2024-11-12T19:27:31+00:00" + "time": "2024-11-27T19:12:58+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1267,16 +1267,16 @@ }, { "name": "firebase/php-jwt", - "version": "v6.10.1", + "version": "v6.10.2", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "500501c2ce893c824c801da135d02661199f60c5" + "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5", - "reference": "500501c2ce893c824c801da135d02661199f60c5", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/30c19ed0f3264cb660ea496895cfb6ef7ee3653b", + "reference": "30c19ed0f3264cb660ea496895cfb6ef7ee3653b", "shasum": "" }, "require": { @@ -1324,9 +1324,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.10.1" + "source": "https://github.com/firebase/php-jwt/tree/v6.10.2" }, - "time": "2024-05-18T18:05:11+00:00" + "time": "2024-11-24T11:22:49+00:00" }, { "name": "fruitcake/php-cors", @@ -2085,16 +2085,16 @@ }, { "name": "laravel/framework", - "version": "v10.48.23", + "version": "v10.48.25", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "625269ca4881d2b50eded2045cb930960a181d98" + "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/625269ca4881d2b50eded2045cb930960a181d98", - "reference": "625269ca4881d2b50eded2045cb930960a181d98", + "url": "https://api.github.com/repos/laravel/framework/zipball/f132b23b13909cc22c615c01b0c5640541c3da0c", + "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c", "shasum": "" }, "require": { @@ -2201,7 +2201,7 @@ "nyholm/psr7": "^1.2", "orchestra/testbench-core": "^8.23.4", "pda/pheanstalk": "^4.0", - "phpstan/phpstan": "^1.4.7", + "phpstan/phpstan": "~1.11.11", "phpunit/phpunit": "^10.0.7", "predis/predis": "^2.0.2", "symfony/cache": "^6.2", @@ -2288,7 +2288,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-11-12T15:39:10+00:00" + "time": "2024-11-26T15:32:57+00:00" }, { "name": "laravel/prompts", @@ -2350,16 +2350,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v1.3.6", + "version": "v1.3.7", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "f865a58ea3a0107c336b7045104c75243fa59d96" + "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/f865a58ea3a0107c336b7045104c75243fa59d96", - "reference": "f865a58ea3a0107c336b7045104c75243fa59d96", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", + "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", "shasum": "" }, "require": { @@ -2407,7 +2407,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-11T17:06:04+00:00" + "time": "2024-11-14T18:34:49+00:00" }, { "name": "laravel/socialite", @@ -3764,32 +3764,32 @@ }, { "name": "nunomaduro/termwind", - "version": "v1.16.0", + "version": "v1.17.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257" + "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dcf1ec3dfa36137b7ce41d43866644a7ab8fc257", - "reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301", + "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.1", - "symfony/console": "^6.4.12" + "symfony/console": "^6.4.15" }, "require-dev": { - "illuminate/console": "^10.48.22", - "illuminate/support": "^10.48.22", - "laravel/pint": "^1.18.1", - "pestphp/pest": "^2", + "illuminate/console": "^10.48.24", + "illuminate/support": "^10.48.24", + "laravel/pint": "^1.18.2", + "pestphp/pest": "^2.36.0", "pestphp/pest-plugin-mock": "2.0.0", - "phpstan/phpstan": "^1.12.6", + "phpstan/phpstan": "^1.12.11", "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^6.4.11", + "symfony/var-dumper": "^6.4.15", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3829,7 +3829,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v1.16.0" + "source": "https://github.com/nunomaduro/termwind/tree/v1.17.0" }, "funding": [ { @@ -3845,7 +3845,7 @@ "type": "github" } ], - "time": "2024-10-15T15:27:12+00:00" + "time": "2024-11-21T10:36:35+00:00" }, { "name": "onelogin/php-saml", @@ -4267,16 +4267,16 @@ }, { "name": "predis/predis", - "version": "v2.2.2", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1" + "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1", - "reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1", + "url": "https://api.github.com/repos/predis/predis/zipball/bac46bfdb78cd6e9c7926c697012aae740cb9ec9", + "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9", "shasum": "" }, "require": { @@ -4285,7 +4285,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.3", "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^8.0 || ~9.4.4" + "phpunit/phpunit": "^8.0 || ^9.4" }, "suggest": { "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" @@ -4316,7 +4316,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v2.2.2" + "source": "https://github.com/predis/predis/tree/v2.3.0" }, "funding": [ { @@ -4324,7 +4324,7 @@ "type": "github" } ], - "time": "2023-09-13T16:42:03+00:00" + "time": "2024-11-21T20:00:02+00:00" }, { "name": "psr/cache", @@ -5093,16 +5093,16 @@ }, { "name": "robrichards/xmlseclibs", - "version": "3.1.1", + "version": "3.1.3", "source": { "type": "git", "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df" + "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/f8f19e58f26cdb42c54b214ff8a820760292f8df", - "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07", + "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07", "shasum": "" }, "require": { @@ -5129,9 +5129,9 @@ ], "support": { "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.1" + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3" }, - "time": "2020-09-05T13:00:25+00:00" + "time": "2024-11-20T21:13:56+00:00" }, { "name": "sabberworm/php-css-parser", @@ -5643,16 +5643,16 @@ }, { "name": "symfony/console", - "version": "v6.4.14", + "version": "v6.4.15", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "897c2441ed4eec8a8a2c37b943427d24dba3f26b" + "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/897c2441ed4eec8a8a2c37b943427d24dba3f26b", - "reference": "897c2441ed4eec8a8a2c37b943427d24dba3f26b", + "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", + "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", "shasum": "" }, "require": { @@ -5717,7 +5717,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.14" + "source": "https://github.com/symfony/console/tree/v6.4.15" }, "funding": [ { @@ -5733,7 +5733,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T15:34:40+00:00" + "time": "2024-11-06T14:19:14+00:00" }, { "name": "symfony/css-selector", @@ -5802,16 +5802,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", "shasum": "" }, "require": { @@ -5849,7 +5849,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" }, "funding": [ { @@ -5865,7 +5865,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/error-handler", @@ -6024,16 +6024,16 @@ }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", "shasum": "" }, "require": { @@ -6080,7 +6080,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" }, "funding": [ { @@ -6096,7 +6096,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/finder", @@ -6164,16 +6164,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.14", + "version": "v6.4.16", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ba020a321a95519303a3f09ec2824d34d601c388" + "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ba020a321a95519303a3f09ec2824d34d601c388", - "reference": "ba020a321a95519303a3f09ec2824d34d601c388", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/431771b7a6f662f1575b3cfc8fd7617aa9864d57", + "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57", "shasum": "" }, "require": { @@ -6183,12 +6183,12 @@ "symfony/polyfill-php83": "^1.27" }, "conflict": { - "symfony/cache": "<6.3" + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { "doctrine/dbal": "^2.13.1|^3|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.3|^7.0", + "symfony/cache": "^6.4.12|^7.1.5", "symfony/dependency-injection": "^5.4|^6.0|^7.0", "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", @@ -6221,7 +6221,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.14" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.16" }, "funding": [ { @@ -6237,20 +6237,20 @@ "type": "tidelift" } ], - "time": "2024-11-05T16:39:55+00:00" + "time": "2024-11-13T18:58:10+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.14", + "version": "v6.4.16", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "8278a947d0369754a47b758a9e17b72cab970951" + "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8278a947d0369754a47b758a9e17b72cab970951", - "reference": "8278a947d0369754a47b758a9e17b72cab970951", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8838b5b21d807923b893ccbfc2cbeda0f1bc00f0", + "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0", "shasum": "" }, "require": { @@ -6335,7 +6335,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.14" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.16" }, "funding": [ { @@ -6351,7 +6351,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T09:45:21+00:00" + "time": "2024-11-27T12:49:36+00:00" }, { "name": "symfony/mime", @@ -7076,16 +7076,16 @@ }, { "name": "symfony/process", - "version": "v6.4.14", + "version": "v6.4.15", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "25214adbb0996d18112548de20c281be9f27279f" + "reference": "3cb242f059c14ae08591c5c4087d1fe443564392" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/25214adbb0996d18112548de20c281be9f27279f", - "reference": "25214adbb0996d18112548de20c281be9f27279f", + "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392", + "reference": "3cb242f059c14ae08591c5c4087d1fe443564392", "shasum": "" }, "require": { @@ -7117,7 +7117,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.14" + "source": "https://github.com/symfony/process/tree/v6.4.15" }, "funding": [ { @@ -7133,20 +7133,20 @@ "type": "tidelift" } ], - "time": "2024-11-06T09:25:01+00:00" + "time": "2024-11-06T14:19:14+00:00" }, { "name": "symfony/routing", - "version": "v6.4.13", + "version": "v6.4.16", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "640a74250d13f9c30d5ca045b6aaaabcc8215278" + "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/640a74250d13f9c30d5ca045b6aaaabcc8215278", - "reference": "640a74250d13f9c30d5ca045b6aaaabcc8215278", + "url": "https://api.github.com/repos/symfony/routing/zipball/91e02e606b4b705c2f4fb42f7e7708b7923a3220", + "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220", "shasum": "" }, "require": { @@ -7200,7 +7200,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.13" + "source": "https://github.com/symfony/routing/tree/v6.4.16" }, "funding": [ { @@ -7216,20 +7216,20 @@ "type": "tidelift" } ], - "time": "2024-10-01T08:30:56+00:00" + "time": "2024-11-13T15:31:34+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", "shasum": "" }, "require": { @@ -7283,7 +7283,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" }, "funding": [ { @@ -7299,20 +7299,20 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/string", - "version": "v6.4.13", + "version": "v6.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "38371c60c71c72b3d64d8d76f6b1bb81a2cc3627" + "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/38371c60c71c72b3d64d8d76f6b1bb81a2cc3627", - "reference": "38371c60c71c72b3d64d8d76f6b1bb81a2cc3627", + "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", "shasum": "" }, "require": { @@ -7369,7 +7369,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.13" + "source": "https://github.com/symfony/string/tree/v6.4.15" }, "funding": [ { @@ -7385,7 +7385,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-11-13T13:31:12+00:00" }, { "name": "symfony/translation", @@ -7484,16 +7484,16 @@ }, { "name": "symfony/translation-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a" + "reference": "4667ff3bd513750603a09c8dedbea942487fb07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", - "reference": "b9d2189887bb6b2e0367a9fc7136c5239ab9b05a", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/4667ff3bd513750603a09c8dedbea942487fb07c", + "reference": "4667ff3bd513750603a09c8dedbea942487fb07c", "shasum": "" }, "require": { @@ -7542,7 +7542,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.5.1" }, "funding": [ { @@ -7558,7 +7558,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/uid", @@ -7636,16 +7636,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.14", + "version": "v6.4.15", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "93c09246038178717a9c14b809ea8151ffcf7091" + "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/93c09246038178717a9c14b809ea8151ffcf7091", - "reference": "93c09246038178717a9c14b809ea8151ffcf7091", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80", + "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80", "shasum": "" }, "require": { @@ -7701,7 +7701,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.15" }, "funding": [ { @@ -7717,7 +7717,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T15:34:40+00:00" + "time": "2024-11-08T15:28:48+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7858,16 +7858,16 @@ }, { "name": "voku/portable-ascii", - "version": "2.0.1", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b56450eed252f6801410d810c8e1727224ae0743" + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b56450eed252f6801410d810c8e1727224ae0743", - "reference": "b56450eed252f6801410d810c8e1727224ae0743", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", "shasum": "" }, "require": { @@ -7892,7 +7892,7 @@ "authors": [ { "name": "Lars Moelleken", - "homepage": "http://www.moelleken.org/" + "homepage": "https://www.moelleken.org/" } ], "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", @@ -7904,7 +7904,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.1" + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" }, "funding": [ { @@ -7928,7 +7928,7 @@ "type": "tidelift" } ], - "time": "2022-03-08T17:03:00+00:00" + "time": "2024-11-21T01:49:47+00:00" }, { "name": "webmozart/assert", @@ -7992,16 +7992,16 @@ "packages-dev": [ { "name": "fakerphp/faker", - "version": "v1.24.0", + "version": "v1.24.1", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "a136842a532bac9ecd8a1c723852b09915d7db50" + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/a136842a532bac9ecd8a1c723852b09915d7db50", - "reference": "a136842a532bac9ecd8a1c723852b09915d7db50", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", "shasum": "" }, "require": { @@ -8049,9 +8049,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" }, - "time": "2024-11-07T15:11:20+00:00" + "time": "2024-11-21T13:46:39+00:00" }, { "name": "filp/whoops", @@ -8177,28 +8177,29 @@ }, { "name": "itsgoingd/clockwork", - "version": "v5.2.2", + "version": "v5.3.1", "source": { "type": "git", "url": "https://github.com/itsgoingd/clockwork.git", - "reference": "29bc4cedfbe742b419544c30b7b6e15cd9da08ef" + "reference": "7b0c40418df761f7a78e88762a323386a139d83d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/29bc4cedfbe742b419544c30b7b6e15cd9da08ef", - "reference": "29bc4cedfbe742b419544c30b7b6e15cd9da08ef", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/7b0c40418df761f7a78e88762a323386a139d83d", + "reference": "7b0c40418df761f7a78e88762a323386a139d83d", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=5.6" + "php": ">=7.1" }, "suggest": { "ext-pdo": "Needed in order to use a SQL database for metadata storage", "ext-pdo_mysql": "Needed in order to use MySQL for metadata storage", "ext-pdo_postgres": "Needed in order to use Postgres for metadata storage", "ext-pdo_sqlite": "Needed in order to use a SQLite for metadata storage", - "ext-redis": "Needed in order to use Redis for metadata storage" + "ext-redis": "Needed in order to use Redis for metadata storage", + "php-http/discovery": "Vanilla integration - required for the middleware zero-configuration setup" }, "type": "library", "extra": { @@ -8240,7 +8241,7 @@ ], "support": { "issues": "https://github.com/itsgoingd/clockwork/issues", - "source": "https://github.com/itsgoingd/clockwork/tree/v5.2.2" + "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.1" }, "funding": [ { @@ -8248,20 +8249,20 @@ "type": "github" } ], - "time": "2024-04-14T10:49:22+00:00" + "time": "2024-11-19T17:25:22+00:00" }, { "name": "larastan/larastan", - "version": "v2.9.11", + "version": "v2.9.12", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "54eccd35d1732b9ee4392c25aec606a6a9c521e7" + "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/54eccd35d1732b9ee4392c25aec606a6a9c521e7", - "reference": "54eccd35d1732b9ee4392c25aec606a6a9c521e7", + "url": "https://api.github.com/repos/larastan/larastan/zipball/19012b39fbe4dede43dbe0c126d9681827a5e908", + "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908", "shasum": "" }, "require": { @@ -8275,7 +8276,7 @@ "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16", "php": "^8.0.2", "phpmyadmin/sql-parser": "^5.9.0", - "phpstan/phpstan": "^1.12.5" + "phpstan/phpstan": "^1.12.11" }, "require-dev": { "doctrine/coding-standard": "^12.0", @@ -8292,13 +8293,13 @@ }, "type": "phpstan-extension", "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - }, "phpstan": { "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-master": "2.0-dev" } }, "autoload": { @@ -8320,7 +8321,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel", + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", "keywords": [ "PHPStan", "code analyse", @@ -8333,27 +8334,15 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v2.9.11" + "source": "https://github.com/larastan/larastan/tree/v2.9.12" }, "funding": [ - { - "url": "https://www.paypal.com/paypalme/enunomaduro", - "type": "custom" - }, { "url": "https://github.com/canvural", "type": "github" - }, - { - "url": "https://github.com/nunomaduro", - "type": "github" - }, - { - "url": "https://www.patreon.com/nunomaduro", - "type": "patreon" } ], - "time": "2024-11-11T23:11:00+00:00" + "time": "2024-11-26T23:09:02+00:00" }, { "name": "mockery/mockery", @@ -8801,16 +8790,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.10", + "version": "1.12.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "fc463b5d0fe906dcf19689be692c65c50406a071" + "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fc463b5d0fe906dcf19689be692c65c50406a071", - "reference": "fc463b5d0fe906dcf19689be692c65c50406a071", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", + "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", "shasum": "" }, "require": { @@ -8855,7 +8844,7 @@ "type": "github" } ], - "time": "2024-11-11T15:37:09+00:00" + "time": "2024-11-17T14:08:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10197,16 +10186,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.0", + "version": "3.11.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "70c08f8d20c0eb4fe56f26644dd94dae76a7f450" + "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/70c08f8d20c0eb4fe56f26644dd94dae76a7f450", - "reference": "70c08f8d20c0eb4fe56f26644dd94dae76a7f450", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", + "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", "shasum": "" }, "require": { @@ -10273,7 +10262,7 @@ "type": "open_collective" } ], - "time": "2024-11-12T09:53:29+00:00" + "time": "2024-11-16T12:02:36+00:00" }, { "name": "ssddanbrown/asserthtml", @@ -10333,16 +10322,16 @@ }, { "name": "symfony/dom-crawler", - "version": "v6.4.13", + "version": "v6.4.16", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "ae074dffb018c37a57071990d16e6152728dd972" + "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ae074dffb018c37a57071990d16e6152728dd972", - "reference": "ae074dffb018c37a57071990d16e6152728dd972", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4304e6ad5c894a9c72831ad459f627bfd35d766d", + "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d", "shasum": "" }, "require": { @@ -10380,7 +10369,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.13" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.16" }, "funding": [ { @@ -10396,7 +10385,7 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2024-11-13T15:06:22+00:00" }, { "name": "theseer/tokenizer", From 415cd6a360f9326fd82480f7a7510e0ee247fbcb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 28 Nov 2024 16:30:59 +0000 Subject: [PATCH 233/800] Includes: Workaround for PHP 8.3.14 bug Changed DOMText creation to be done via document so its document reference is correct to avoid a bug in PHP 8.3.14. Ref: https://github.com/php/php-src/issues/16967 Fixes #5341 --- app/Entities/Tools/PageIncludeParser.php | 4 ++-- app/Util/HtmlDocument.php | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php index dad7c29e60e..e0b89f15870 100644 --- a/app/Entities/Tools/PageIncludeParser.php +++ b/app/Entities/Tools/PageIncludeParser.php @@ -104,10 +104,10 @@ protected function splitTextNodesAtTags(DOMNode $textNode): array if ($currentOffset < $tagStartOffset) { $previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset); - $textNode->parentNode->insertBefore(new DOMText($previousText), $textNode); + $textNode->parentNode->insertBefore($this->doc->createTextNode($previousText), $textNode); } - $node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode); + $node = $textNode->parentNode->insertBefore($this->doc->createTextNode($tagOuterContent), $textNode); $includeTags[] = new PageIncludeTag($tagInnerContent, $node); $currentOffset = $tagStartOffset + strlen($tagOuterContent); } diff --git a/app/Util/HtmlDocument.php b/app/Util/HtmlDocument.php index b8c53d43916..7517955b9f6 100644 --- a/app/Util/HtmlDocument.php +++ b/app/Util/HtmlDocument.php @@ -6,6 +6,7 @@ use DOMElement; use DOMNode; use DOMNodeList; +use DOMText; use DOMXPath; /** @@ -81,6 +82,14 @@ public function createElement(string $localName, string $value = ''): DOMElement return $element; } + /** + * Create a new text node within this document. + */ + public function createTextNode(string $text): DOMText + { + return $this->document->createTextNode($text); + } + /** * Get an element within the document of the given ID. */ From bc1f1d92e52ef5b103bdcbd739a001aece605b5d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 28 Nov 2024 16:58:06 +0000 Subject: [PATCH 234/800] OIDC: Added extra userinfo content-type normalisation and test During review of #5337 --- app/Access/Oidc/OidcUserinfoResponse.php | 4 +++- tests/Auth/OidcTest.php | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/Access/Oidc/OidcUserinfoResponse.php b/app/Access/Oidc/OidcUserinfoResponse.php index ffcacb8dee8..33b8ec80665 100644 --- a/app/Access/Oidc/OidcUserinfoResponse.php +++ b/app/Access/Oidc/OidcUserinfoResponse.php @@ -11,7 +11,9 @@ class OidcUserinfoResponse implements ProvidesClaims public function __construct(ResponseInterface $response, string $issuer, array $keys) { - $contentType = explode(';', $response->getHeader('Content-Type')[0], 2)[0]; + $contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? ''; + $contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0])); + if ($contentType === 'application/json') { $this->claims = json_decode($response->getBody()->getContents(), true); } diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index 201f67b5333..205f75a4d62 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -787,6 +787,20 @@ public function test_userinfo_endpoint_fetch_can_parsed_nested_groups() $this->assertTrue($user->hasRole($roleA->id)); } + public function test_userinfo_endpoint_response_with_complex_json_content_type_handled() + { + $userinfoResponseData = [ + 'sub' => OidcJwtHelper::defaultPayload()['sub'], + 'name' => 'Barry', + ]; + $userinfoResponse = new Response(200, ['Content-Type' => 'Application/Json ; charset=utf-8'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/'); + + $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first(); + $this->assertEquals('Barry', $user->name); + } + public function test_userinfo_endpoint_jwks_response_handled() { $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']); From 4630f0728225dbbfca285bd88345a678cf8d02d1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 29 Nov 2024 12:57:53 +0000 Subject: [PATCH 235/800] Code: Set base codemirror line height Prevents difference in line height between light/dark mode. For #5146 --- resources/sass/_codemirror.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/sass/_codemirror.scss b/resources/sass/_codemirror.scss index c79671d7646..5f14cb9dbc9 100644 --- a/resources/sass/_codemirror.scss +++ b/resources/sass/_codemirror.scss @@ -14,6 +14,10 @@ border-radius: 4px; } +.cm-editor .cm-line { + line-height: 1.6; +} + .cm-editor .cm-line, .cm-editor .cm-gutter { font-family: var(--font-code); } From e9f906ce5682d43312055dd3faff77f6e39ac5be Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 29 Nov 2024 13:19:55 +0000 Subject: [PATCH 236/800] Attachments: Fixed full range request handling We were not responsing with a range request, where the requested range was for the full extent of content. This changes things to always provide a range request, even for the full range. Change made since our existing logic could cause problems in chromium browsers. Elseif statement removed as its was likley redundant based upon other existing checks. This also changes responses for requested ranges beyond content, but I think that's technically correct looking at the spec (416 are for when there are no overlapping request/response ranges at all). Updated tests to cover. For #5342 --- app/Http/RangeSupportedStream.php | 2 +- tests/Uploads/AttachmentTest.php | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/Http/RangeSupportedStream.php b/app/Http/RangeSupportedStream.php index 300f4488cf4..fce1e9acce3 100644 --- a/app/Http/RangeSupportedStream.php +++ b/app/Http/RangeSupportedStream.php @@ -92,7 +92,7 @@ protected function parseRequest(Request $request): void if ($start < 0 || $start > $end) { $this->responseStatus = 416; $this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize); - } elseif ($end - $start < $this->fileSize - 1) { + } else { $this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1; $this->responseOffset = $start; $this->responseStatus = 206; diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 2e1a7b3395a..de448d93a4c 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -404,8 +404,8 @@ public function test_file_head_range_edge_cases() $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-2010']); $resp->assertStreamedContent($content); $resp->assertHeader('Content-Length', '2005'); - $resp->assertHeaderMissing('Content-Range'); - $resp->assertStatus(200); + $resp->assertHeader('Content-Range', 'bytes 0-2004/2005'); + $resp->assertStatus(206); // Range start before end $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=50-10']); @@ -413,6 +413,13 @@ public function test_file_head_range_edge_cases() $resp->assertHeader('Content-Length', '2005'); $resp->assertHeader('Content-Range', 'bytes */2005'); $resp->assertStatus(416); + + // Full range request + $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-']); + $resp->assertStreamedContent($content); + $resp->assertHeader('Content-Length', '2005'); + $resp->assertHeader('Content-Range', 'bytes 0-2004/2005'); + $resp->assertStatus(206); } $this->files->deleteAllAttachmentFiles(); From f5ecd51461c31c4e58fbbc5cf5a7dabeabb5bc98 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 29 Nov 2024 13:40:09 +0000 Subject: [PATCH 237/800] Updated translations with latest Crowdin changes (#5331) --- lang/da/activities.php | 28 ++++++++++++++-------------- lang/da/settings.php | 2 +- lang/ka/activities.php | 2 +- lang/nn/activities.php | 6 +++--- lang/nn/auth.php | 2 +- lang/zh_TW/validation.php | 2 +- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lang/da/activities.php b/lang/da/activities.php index 6a99fec6f92..e92d1c165a5 100644 --- a/lang/da/activities.php +++ b/lang/da/activities.php @@ -63,17 +63,17 @@ // Auth 'auth_login' => 'loggede ind', - 'auth_register' => 'registered as new user', + 'auth_register' => 'registreret som ny bruger', 'auth_password_reset_request' => 'requested user password reset', 'auth_password_reset_update' => 'nulstil adgangskode', - 'mfa_setup_method' => 'configured MFA method', + 'mfa_setup_method' => 'konfigureret MFA metode', 'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret', - 'mfa_remove_method' => 'removed MFA method', + 'mfa_remove_method' => 'fjernet MFA metode', 'mfa_remove_method_notification' => 'Multi-faktor metode fjernet', // Settings - 'settings_update' => 'updated settings', - 'settings_update_notification' => 'Settings successfully updated', + 'settings_update' => 'opdaterede indstillinger', + 'settings_update_notification' => 'Indstillinger opdateret', 'maintenance_action_run' => 'ran maintenance action', // Webhooks @@ -87,13 +87,13 @@ // Users 'user_create' => 'opret bruger', 'user_create_notification' => 'Bruger oprettet korrekt', - 'user_update' => 'updated user', + 'user_update' => 'opdateret bruger', 'user_update_notification' => 'Brugeren blev opdateret', - 'user_delete' => 'deleted user', + 'user_delete' => 'slettet bruger', 'user_delete_notification' => 'Brugeren blev fjernet', // API Tokens - 'api_token_create' => 'created API token', + 'api_token_create' => 'oprettet API token', 'api_token_create_notification' => 'API token successfully created', 'api_token_update' => 'updated API token', 'api_token_update_notification' => 'API token successfully updated', @@ -101,12 +101,12 @@ 'api_token_delete_notification' => 'API token successfully deleted', // Roles - 'role_create' => 'created role', - 'role_create_notification' => 'Role successfully created', - 'role_update' => 'updated role', - 'role_update_notification' => 'Role successfully updated', - 'role_delete' => 'deleted role', - 'role_delete_notification' => 'Role successfully deleted', + 'role_create' => 'oprettet rolle', + 'role_create_notification' => 'Rolle oprettet', + 'role_update' => 'opdateret rolle', + 'role_update_notification' => 'Rolle opdateret', + 'role_delete' => 'slettet rolle', + 'role_delete_notification' => 'Rollen blev slettet', // Recycle Bin 'recycle_bin_empty' => 'emptied recycle bin', diff --git a/lang/da/settings.php b/lang/da/settings.php index 5fa2d156f1e..fa802af16f0 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -33,7 +33,7 @@ 'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.', 'app_logo' => 'Applikationslogo', 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', - 'app_icon' => 'Application Icon', + 'app_icon' => 'Program ikon', 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', 'app_homepage' => 'Applikationsforside', 'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.', diff --git a/lang/ka/activities.php b/lang/ka/activities.php index 092398ef0e1..6c7af4429f0 100644 --- a/lang/ka/activities.php +++ b/lang/ka/activities.php @@ -69,7 +69,7 @@ 'mfa_setup_method' => 'configured MFA method', 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', 'mfa_remove_method' => 'removed MFA method', - 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + 'mfa_remove_method_notification' => 'მულტი-ფაქტორული მეთოდი წარმატებით მოიხსნა', // Settings 'settings_update' => 'updated settings', diff --git a/lang/nn/activities.php b/lang/nn/activities.php index 36b7d847b44..73182e0ba84 100644 --- a/lang/nn/activities.php +++ b/lang/nn/activities.php @@ -93,11 +93,11 @@ 'user_delete_notification' => 'Brukaren vart fjerna', // API Tokens - 'api_token_create' => 'created API token', + 'api_token_create' => 'opprett API-nøkkel', 'api_token_create_notification' => 'API-token er oppretta', - 'api_token_update' => 'updated API token', + 'api_token_update' => 'oppdatert api token', 'api_token_update_notification' => 'API-token oppdatert', - 'api_token_delete' => 'deleted API token', + 'api_token_delete' => 'sletta api token', 'api_token_delete_notification' => 'API-token vart sletta', // Roles diff --git a/lang/nn/auth.php b/lang/nn/auth.php index 04546da1dc4..5da686eaa9e 100644 --- a/lang/nn/auth.php +++ b/lang/nn/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Mobilapplikasjon', 'mfa_option_totp_desc' => 'For å bruka fleirfaktorautentisering treng du ein mobilapplikasjon som støttar TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Tryggleikskodar', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Genererer et sett med engangs sikkerhetskoder som du skal logge inn for å bekrefte identiteten din. Sørg for å lagre disse på et sikkert og sikkert sted.', 'mfa_gen_confirm_and_enable' => 'Stadfest og aktiver', 'mfa_gen_backup_codes_title' => 'Konfigurasjon av tryggleikskodar', 'mfa_gen_backup_codes_desc' => 'Lagre lista under med kodar på ein trygg stad. Når du skal ha tilgang til systemet kan du bruka ein av desse som ein faktor under innlogging.', diff --git a/lang/zh_TW/validation.php b/lang/zh_TW/validation.php index 71ba5acadf8..6651f13966c 100644 --- a/lang/zh_TW/validation.php +++ b/lang/zh_TW/validation.php @@ -103,7 +103,7 @@ 'totp' => '提供的代碼無效或已過期。', 'unique' => ':attribute 已經被使用。', 'url' => ':attribute 格式無效。', - 'uploaded' => '無法上傳文件, 服務器可能不接受此大小的文件。', + 'uploaded' => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。', // Custom validation lines 'custom' => [ From 94165cc18f344cee3c99695b4d546b984df21f5f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 29 Nov 2024 13:46:37 +0000 Subject: [PATCH 238/800] Updated translator & dependency attribution before release v24.10.2 --- .github/translators.txt | 2 ++ dev/licensing/php-library-licenses.txt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index 65ea00fe8f7..9699be70f8b 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -453,3 +453,5 @@ Mohammed srhiri (m.sghiri20) :: Arabic YongMin Kim (kym0118) :: Korean Rivo Zängov (Eraser) :: Estonian Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian +ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional +madnjpn (madnjpn.) :: Georgian diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 8507cb4cb61..747ccc986d7 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -415,7 +415,7 @@ predis/predis License: MIT License File: vendor/predis/predis/LICENSE Copyright: Copyright (c) 2009-2020 Daniele Alessandri (original work) -Copyright (c) 2021-2023 Till Krüss (modified work) +Copyright (c) 2021-2024 Till Krüss (modified work) Source: https://github.com/predis/predis.git Link: http://github.com/predis/predis ----------- @@ -514,7 +514,7 @@ Link: https://github.com/ramsey/uuid.git robrichards/xmlseclibs License: BSD-3-Clause License File: vendor/robrichards/xmlseclibs/LICENSE -Copyright: Copyright (c) 2007-2019, Robert Richards <*********@*********.***>. +Copyright: Copyright (c) 2007-2024, Robert Richards <*********@*********.***>. Source: https://github.com/robrichards/xmlseclibs.git Link: https://github.com/robrichards/xmlseclibs ----------- From 227c5e155b60ba9a442d23c64a9846366c3e15ac Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 1 Dec 2024 16:02:21 +0000 Subject: [PATCH 239/800] Dev Docker: Fixed missing gd jpeg handling, forced migrations Migrations run without force could fail startup in certain environment conditions (when testing production env). Also updated paths permission handling to update more needed locations. --- dev/docker/Dockerfile | 7 +++++-- dev/docker/entrypoint.app.sh | 4 ++-- dev/docs/development.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 9f283c75544..edab90ca1c5 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -6,15 +6,18 @@ RUN apt-get update && \ git \ zip \ unzip \ - libpng-dev \ + libfreetype-dev \ + libjpeg62-turbo-dev \ libldap2-dev \ + libpng-dev \ libzip-dev \ wait-for-it && \ rm -rf /var/lib/apt/lists/* # Install PHP extensions RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \ - docker-php-ext-install pdo_mysql gd ldap zip && \ + docker-php-ext-configure gd --with-freetype --with-jpeg && \ + docker-php-ext-install -j$(nproc) pdo_mysql gd ldap zip && \ pecl install xdebug && \ docker-php-ext-enable xdebug diff --git a/dev/docker/entrypoint.app.sh b/dev/docker/entrypoint.app.sh index e91d34a7133..b09edda8863 100755 --- a/dev/docker/entrypoint.app.sh +++ b/dev/docker/entrypoint.app.sh @@ -9,7 +9,7 @@ if [[ -n "$1" ]]; then else composer install wait-for-it db:3306 -t 45 - php artisan migrate --database=mysql - chown -R www-data:www-data storage + php artisan migrate --database=mysql --force + chown -R www-data storage public/uploads bootstrap/cache exec apache2-foreground fi diff --git a/dev/docs/development.md b/dev/docs/development.md index 3c7a6e9d299..0324140f8bd 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -82,7 +82,7 @@ If all the conditions are met, you can proceed with the following steps: 1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`. 2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host. -3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory. +3. **Run `chgrp -R docker storage`**. The development container will chown the `storage`, `public/uploads` and `bootstrap/cache` directories to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory. 4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done. 5. You can now login with `admin@admin.com` and `password` as password on `localhost:8080` (or another port if specified). From 90341e0e00a832e78df8a394487a073a475003ed Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 1 Dec 2024 18:42:54 +0000 Subject: [PATCH 240/800] LDAP: Review and testing of mulitple-display-name attr support Review of #5295 Added test to cover functionality. Moved splitting from config to service. --- app/Access/LdapService.php | 30 ++++++++++++++---------------- app/Config/services.php | 2 +- tests/Auth/LdapTest.php | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index ef6d33f4db1..e5037ad2f95 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -72,25 +72,23 @@ private function getUserWithAttributes(string $userName, array $attributes): ?ar } /** - * Calculate the display name. + * Build the user display name from the (potentially multiple) attributes defined by the configuration. */ - protected function getUserDisplayName(array $displayNameAttr, array $userDetails, string $defaultValue): string + protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string { - $displayName = []; - foreach ($displayNameAttr as $dnAttr) { + $displayNameParts = []; + foreach ($displayNameAttrs as $dnAttr) { $dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null); - if ($dnComponent !== null) { - $displayName[] = $dnComponent; + if ($dnComponent) { + $displayNameParts[] = $dnComponent; } } - if (count($displayName) == 0) { - $displayName = $defaultValue; - } else { - $displayName = implode(' ', $displayName); + if (empty($displayNameParts)) { + return $defaultValue; } - return $displayName; + return implode(' ', $displayNameParts); } /** @@ -103,12 +101,12 @@ public function getUserDetails(string $userName): ?array { $idAttr = $this->config['id_attribute']; $emailAttr = $this->config['email_attribute']; - $displayNameAttr = $this->config['display_name_attribute']; + $displayNameAttrs = explode('|', $this->config['display_name_attribute']); $thumbnailAttr = $this->config['thumbnail_attribute']; - $user = $this->getUserWithAttributes($userName, array_filter(array_merge($displayNameAttr, [ - 'cn', 'dn', $idAttr, $emailAttr, $thumbnailAttr, - ]))); + $user = $this->getUserWithAttributes($userName, array_filter([ + 'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr, + ])); if (is_null($user)) { return null; @@ -117,7 +115,7 @@ public function getUserDetails(string $userName): ?array $userCn = $this->getUserResponseProperty($user, 'cn', null); $formatted = [ 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), - 'name' => $this->getUserDisplayName($displayNameAttr, $user, $userCn), + 'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn), 'dn' => $user['dn'], 'email' => $this->getUserResponseProperty($user, $emailAttr, null), 'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null, diff --git a/app/Config/services.php b/app/Config/services.php index 4e27896870a..d7345823150 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -127,7 +127,7 @@ 'version' => env('LDAP_VERSION', false), 'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), - 'display_name_attribute' => explode('|', env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn')), + 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 27169a2becf..9a00c983a50 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -29,7 +29,7 @@ protected function setUp(): void 'auth.defaults.guard' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'services.ldap.email_attribute' => 'mail', - 'services.ldap.display_name_attribute' => ['cn'], + 'services.ldap.display_name_attribute' => 'cn', 'services.ldap.id_attribute' => 'uid', 'services.ldap.user_to_groups' => false, 'services.ldap.version' => '3', @@ -581,7 +581,7 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() public function test_login_uses_specified_display_name_attribute() { app('config')->set([ - 'services.ldap.display_name_attribute' => ['displayName'], + 'services.ldap.display_name_attribute' => 'displayName', ]); $this->commonLdapMocks(1, 1, 2, 4, 2); @@ -603,10 +603,37 @@ public function test_login_uses_specified_display_name_attribute() $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']); } + public function test_login_uses_multiple_display_properties_if_defined() + { + app('config')->set([ + 'services.ldap.display_name_attribute' => 'firstname|middlename|noname|lastname', + ]); + + $this->commonLdapMocks(1, 1, 1, 2, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), + 'firstname' => ['Barry'], + 'middlename' => ['Elliott'], + 'lastname' => ['Chuckle'], + 'mail' => [$this->mockUser->email], + ]]); + + $this->mockUserLogin(); + + $this->assertDatabaseHas('users', [ + 'email' => $this->mockUser->email, + 'name' => 'Barry Elliott Chuckle', + ]); + } + public function test_login_uses_default_display_name_attribute_if_specified_not_present() { app('config')->set([ - 'services.ldap.display_name_attribute' => ['displayName'], + 'services.ldap.display_name_attribute' => 'displayName', ]); $this->commonLdapMocks(1, 1, 2, 4, 2); From 0f9957bc036e40d068dfb0958560485f7456964a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 2 Dec 2024 11:46:56 +0000 Subject: [PATCH 241/800] MD Exports: Added HTML description conversion Also updated tests to cover checking description use/conversion. Made during review of #5313 --- app/Entities/Tools/ExportFormatter.php | 12 +++++++----- tests/Entity/ExportTest.php | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index e85992a9d70..0af68b8db3c 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -316,8 +316,9 @@ public function chapterToMarkdown(Chapter $chapter): string { $text = '# ' . $chapter->name . "\n\n"; - if (!empty($chapter->description)) { - $text .= $chapter->description . "\n\n"; + $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert(); + if ($description) { + $text .= $description . "\n\n"; } foreach ($chapter->pages as $page) { @@ -334,9 +335,10 @@ public function bookToMarkdown(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; - - if (!empty($book->description)) { - $text .= $book->description . "\n\n"; + + $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert(); + if ($description) { + $text .= $description . "\n\n"; } foreach ($bookTree as $bookChild) { diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 7aafa3b7927..97b1ff1bcfd 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -417,23 +417,35 @@ public function test_page_markdown_export_converts_html_where_no_markdown() public function test_chapter_markdown_export() { $chapter = $this->entities->chapter(); + $chapter->description_html = '

                                My chapter description

                                '; + $chapter->save(); $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); $resp->assertSee('# ' . $chapter->name); $resp->assertSee('# ' . $page->name); + $resp->assertSee('My **chapter** description'); } public function test_book_markdown_export() { $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); + $book->description_html = '

                                My book description

                                '; + $book->save(); + $chapter = $book->chapters()->first(); + $chapter->description_html = '

                                My chapter description

                                '; + $chapter->save(); + $page = $chapter->pages()->first(); $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); $resp->assertSee('# ' . $book->name); $resp->assertSee('# ' . $chapter->name); $resp->assertSee('# ' . $page->name); + $resp->assertSee('My **book** description'); + $resp->assertSee('My **chapter** description'); } public function test_book_markdown_export_concats_immediate_pages_with_newlines() From fec44452cb67819b594bdfca4ca37e4a20c0f42e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 13:47:45 +0000 Subject: [PATCH 242/800] Search API: Updated handling of parent detail, added testing Review of #5280. - Removed additional non-needed loads which could ignore permissions. - Updated new formatter method name to be more specific on use. - Added test case to cover changes. - Updated API examples to align parent id/info in info to be representative. --- app/Api/ApiEntityListFormatter.php | 32 ++---- app/Search/SearchApiController.php | 15 +-- dev/api/responses/search-all.json | 172 ++++++++++++++--------------- tests/Api/SearchApiTest.php | 44 +++++++- 4 files changed, 142 insertions(+), 121 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 7c2d09d4f3a..3c94d96ee60 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -2,6 +2,7 @@ namespace BookStack\Api; +use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; @@ -72,20 +73,20 @@ public function withTags(): self } /** - * Enable the inclusion of related book and chapter titles in the response. + * Include parent book/chapter info in the formatted data. */ - public function withRelatedData(): self + public function withParents(): self { $this->withField('book', function (Entity $entity) { - if (method_exists($entity, 'book')) { - return $entity->book()->select(['id', 'name', 'slug'])->first(); + if ($entity instanceof BookChild && $entity->book) { + return $entity->book->only(['id', 'name', 'slug']); } return null; }); $this->withField('chapter', function (Entity $entity) { - if ($entity instanceof Page && $entity->chapter_id) { - return $entity->chapter()->select(['id', 'name', 'slug'])->first(); + if ($entity instanceof Page && $entity->chapter) { + return $entity->chapter->only(['id', 'name', 'slug']); } return null; }); @@ -99,8 +100,6 @@ public function withRelatedData(): self */ public function format(): array { - $this->loadRelatedData(); - $results = []; foreach ($this->list as $item) { @@ -110,23 +109,6 @@ public function format(): array return $results; } - /** - * Eager load the related book and chapter data when needed. - */ - protected function loadRelatedData(): void - { - $pages = collect($this->list)->filter(fn($item) => $item instanceof Page); - - foreach ($this->list as $entity) { - if (method_exists($entity, 'book')) { - $entity->load('book'); - } - if ($entity instanceof Page && $entity->chapter_id) { - $entity->load('chapter'); - } - } - } - /** * Format a single entity item to a plain array. */ diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 28a3b53e6c3..79cd8cfabd0 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,21 +9,18 @@ class SearchApiController extends ApiController { - protected SearchRunner $searchRunner; - protected SearchResultsFormatter $resultsFormatter; - protected $rules = [ 'all' => [ 'query' => ['required'], - 'page' => ['integer', 'min:1'], + 'page' => ['integer', 'min:1'], 'count' => ['integer', 'min:1', 'max:100'], ], ]; - public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) - { - $this->searchRunner = $searchRunner; - $this->resultsFormatter = $resultsFormatter; + public function __construct( + protected SearchRunner $searchRunner, + protected SearchResultsFormatter $resultsFormatter + ) { } /** @@ -50,7 +47,7 @@ public function all(Request $request) $this->resultsFormatter->format($results['results']->all(), $options); $data = (new ApiEntityListFormatter($results['results']->all())) - ->withType()->withTags()->withRelatedData() + ->withType()->withTags()->withParents() ->withField('preview_html', function (Entity $entity) { return [ 'name' => (string) $entity->getAttribute('preview_name'), diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index f60a12f750d..2ad89641693 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -1,92 +1,92 @@ { - "data": [ + "data": [ + { + "id": 84, + "book_id": 1, + "slug": "a-chapter-for-cats", + "name": "A chapter for cats", + "created_at": "2021-11-14T15:57:35.000000Z", + "updated_at": "2021-11-14T15:57:35.000000Z", + "type": "chapter", + "url": "https://example.com/books/cats/chapter/a-chapter-for-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "preview_html": { + "name": "A chapter for cats", + "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" + }, + "tags": [] + }, + { + "name": "The hows and whys of cats", + "id": 396, + "slug": "the-hows-and-whys-of-cats", + "book_id": 1, + "chapter_id": 75, + "draft": false, + "template": false, + "created_at": "2021-05-15T16:28:10.000000Z", + "updated_at": "2021-11-14T15:56:49.000000Z", + "type": "page", + "url": "https://example.com/books/cats/page/the-hows-and-whys-of-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 75, + "name": "A chapter for cats", + "slug": "a-chapter-for-cats" + }, + "preview_html": { + "name": "The hows and whys of cats", + "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." + }, + "tags": [ { - "id": 84, - "book_id": 1, - "slug": "a-chapter-for-cats", - "name": "A chapter for cats", - "created_at": "2021-11-14T15:57:35.000000Z", - "updated_at": "2021-11-14T15:57:35.000000Z", - "type": "chapter", - "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", - "book": { - "id": 1, - "name": "Cats", - "slug": "cats" - }, - "preview_html": { - "name": "A chapter for cats", - "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" - }, - "tags": [] + "name": "Animal", + "value": "Cat", + "order": 0 }, { - "name": "The hows and whys of cats", - "id": 396, - "slug": "the-hows-and-whys-of-cats", - "book_id": 1, - "chapter_id": 75, - "draft": false, - "template": false, - "created_at": "2021-05-15T16:28:10.000000Z", - "updated_at": "2021-11-14T15:56:49.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", - "book": { - "id": 1, - "name": "Cats", - "slug": "cats" - }, - "chapter": { - "id": 84, - "name": "A chapter for cats", - "slug": "a-chapter-for-cats" - }, - "preview_html": { - "name": "The hows and whys of cats", - "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." - }, - "tags": [ - { - "name": "Animal", - "value": "Cat", - "order": 0 - }, - { - "name": "Category", - "value": "Top Content", - "order": 0 - } - ] - }, - { - "name": "How advanced are cats?", - "id": 362, - "slug": "how-advanced-are-cats", - "book_id": 13, - "chapter_id": 73, - "draft": false, - "template": false, - "created_at": "2020-11-29T21:55:07.000000Z", - "updated_at": "2021-11-14T16:02:39.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/how-advanced-are-cats", - "book": { - "id": 1, - "name": "Cats", - "slug": "cats" - }, - "chapter": { - "id": 84, - "name": "A chapter for cats", - "slug": "a-chapter-for-cats" - }, - "preview_html": { - "name": "How advanced are cats?", - "content": "cats are some of the most advanced animals in the world." - }, - "tags": [] + "name": "Category", + "value": "Top Content", + "order": 0 } - ], - "total": 3 + ] + }, + { + "name": "How advanced are cats?", + "id": 362, + "slug": "how-advanced-are-cats", + "book_id": 13, + "chapter_id": 73, + "draft": false, + "template": false, + "created_at": "2020-11-29T21:55:07.000000Z", + "updated_at": "2021-11-14T16:02:39.000000Z", + "type": "page", + "url": "https://example.com/books/big-cats/page/how-advanced-are-cats", + "book": { + "id": 13, + "name": "Big Cats", + "slug": "big-cats" + }, + "chapter": { + "id": 73, + "name": "A chapter for bigger cats", + "slug": "a-chapter-for-bigger-cats" + }, + "preview_html": { + "name": "How advanced are cats?", + "content": "cats are some of the most advanced animals in the world." + }, + "tags": [] + } + ], + "total": 3 } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 3f2eb395c11..9da7900ca9a 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -13,7 +13,7 @@ class SearchApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/search'; + protected string $baseEndpoint = '/api/search'; public function test_all_endpoint_returns_search_filtered_results_with_query() { @@ -74,4 +74,46 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } + + public function test_all_endpoint_includes_parent_details_where_visible() + { + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $book = $page->book; + + $page->update(['name' => 'name with superextrauniquevalue within']); + $page->indexForSearch(); + + $editor = $this->users->editor(); + $this->actingAsApiEditor(); + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonFragment([ + 'id' => $page->id, + 'type' => 'page', + 'book' => [ + 'id' => $book->id, + 'name' => $book->name, + 'slug' => $book->slug, + ], + 'chapter' => [ + 'id' => $chapter->id, + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], + ]); + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonPath('data.0.book.name', $book->name); + $resp->assertJsonMissingPath('data.0.chapter'); + + $this->permissions->disableEntityInheritedPermissions($book); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonMissingPath('data.0.book.name'); + } } From f3fa63a5ae5d671d10c9313965723683608ddc4e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 16:24:49 +0000 Subject: [PATCH 243/800] Lexical: Merged custom paragraph node, removed old format/indent refs Start of work to merge custom nodes into lexical, removing old unused format/indent core logic while extending common block elements where possible. --- .../js/wysiwyg/lexical/core/LexicalEvents.ts | 3 - .../wysiwyg/lexical/core/LexicalMutations.ts | 9 -- .../wysiwyg/lexical/core/LexicalReconciler.ts | 67 ---------- .../lexical/core/__tests__/utils/index.ts | 8 -- .../lexical/core/nodes/CommonBlockNode.ts | 54 ++++++++ .../lexical/core/nodes/LexicalElementNode.ts | 41 ------ .../core/nodes/LexicalParagraphNode.ts | 69 +++------- .../lexical/core/nodes/LexicalRootNode.ts | 4 - resources/js/wysiwyg/lexical/html/index.ts | 4 - resources/js/wysiwyg/lexical/link/index.ts | 4 - .../lexical/list/LexicalListItemNode.ts | 38 ------ .../js/wysiwyg/lexical/list/formatList.ts | 6 - .../js/wysiwyg/lexical/rich-text/index.ts | 56 +------- .../lexical/selection/range-selection.ts | 8 -- .../js/wysiwyg/nodes/custom-paragraph.ts | 123 ------------------ resources/js/wysiwyg/nodes/index.ts | 9 +- .../wysiwyg/services/drop-paste-handling.ts | 4 +- .../js/wysiwyg/services/keyboard-handling.ts | 4 +- resources/js/wysiwyg/todo.md | 6 +- resources/js/wysiwyg/utils/formats.ts | 13 +- resources/js/wysiwyg/utils/nodes.ts | 4 +- resources/js/wysiwyg/utils/selection.ts | 6 +- 22 files changed, 95 insertions(+), 445 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-paragraph.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts index 5fd671a76c5..c70a906a08e 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -355,7 +355,6 @@ function onSelectionChange( lastNode instanceof ParagraphNode && lastNode.getChildrenSize() === 0 ) { - selection.format = lastNode.getTextFormat(); selection.style = lastNode.getTextStyle(); } else { selection.format = 0; @@ -578,7 +577,6 @@ function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { if ($isRangeSelection(selection)) { const anchorNode = selection.anchor.getNode(); anchorNode.markDirty(); - selection.format = anchorNode.getFormat(); invariant( $isTextNode(anchorNode), 'Anchor node must be a TextNode', @@ -912,7 +910,6 @@ function onCompositionStart( // 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 diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts index 56f364501ee..c24dc9ebb3c 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts @@ -96,15 +96,6 @@ function shouldUpdateTextNodeFromMutation( 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(); } diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index 09d01bffd43..7843027d713 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -17,7 +17,6 @@ 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, @@ -117,51 +116,6 @@ 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, @@ -185,22 +139,14 @@ function $createNode( } 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); $createChildren(children, node, 0, endIndex, dom, null); } - const format = node.__format; - if (format !== 0) { - setElementFormat(dom, format); - } if (!node.isInline()) { reconcileElementTerminatingLineBreak(null, node, dom); } @@ -349,10 +295,8 @@ function reconcileParagraphFormat(element: ElementNode): void { if ( $isParagraphNode(element) && subTreeTextFormat != null && - subTreeTextFormat !== element.__textFormat && !activeEditorStateReadOnly ) { - element.setTextFormat(subTreeTextFormat); element.setTextStyle(subTreeTextStyle); } } @@ -563,17 +507,6 @@ function $reconcileNode( 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()) { diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index f7230595a43..a4d74210e41 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -129,8 +129,6 @@ export class TestElementNode extends ElementNode { serializedNode: SerializedTestElementNode, ): TestInlineElementNode { const node = $createTestInlineElementNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -195,8 +193,6 @@ export class TestInlineElementNode extends ElementNode { serializedNode: SerializedTestInlineElementNode, ): TestInlineElementNode { const node = $createTestInlineElementNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -241,8 +237,6 @@ export class TestShadowRootNode extends ElementNode { serializedNode: SerializedTestShadowRootNode, ): TestShadowRootNode { const node = $createTestShadowRootNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -322,8 +316,6 @@ export class TestExcludeFromCopyElementNode extends ElementNode { serializedNode: SerializedTestExcludeFromCopyElementNode, ): TestExcludeFromCopyElementNode { const node = $createTestExcludeFromCopyElementNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } diff --git a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts new file mode 100644 index 00000000000..37ca1cdef7f --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts @@ -0,0 +1,54 @@ +import {ElementNode} from "./LexicalElementNode"; +import {CommonBlockAlignment, SerializedCommonBlockNode} from "../../../nodes/_common"; + + +export class CommonBlockNode extends ElementNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + 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; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + exportJSON(): SerializedCommonBlockNode { + return { + ...super.exportJSON(), + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + }; + } +} + +export function copyCommonBlockProperties(from: CommonBlockNode, to: CommonBlockNode): void { + to.__id = from.__id; + to.__alignment = from.__alignment; + to.__inset = from.__inset; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 88c6d56780f..002d825d6ea 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -42,8 +42,6 @@ export type SerializedElementNode< { children: Array; direction: 'ltr' | 'rtl' | null; - format: ElementFormatType; - indent: number; }, SerializedLexicalNode >; @@ -74,12 +72,8 @@ export class ElementNode extends LexicalNode { /** @internal */ __size: number; /** @internal */ - __format: number; - /** @internal */ __style: string; /** @internal */ - __indent: number; - /** @internal */ __dir: 'ltr' | 'rtl' | null; constructor(key?: NodeKey) { @@ -87,9 +81,7 @@ export class ElementNode extends LexicalNode { this.__first = null; this.__last = null; this.__size = 0; - this.__format = 0; this.__style = ''; - this.__indent = 0; this.__dir = null; } @@ -98,28 +90,14 @@ export class ElementNode extends LexicalNode { 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(); @@ -301,13 +279,6 @@ export class ElementNode extends LexicalNode { 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 @@ -378,21 +349,11 @@ export class ElementNode extends LexicalNode { 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, @@ -528,8 +489,6 @@ export class ElementNode extends LexicalNode { return { children: [], direction: this.getDirection(), - format: this.getFormatType(), - indent: this.getIndent(), type: 'element', version: 1, }; diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts index 4e69dc21c3c..6517d939eda 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -19,39 +19,36 @@ import type { 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'; +import {$isTextNode} from './LexicalTextNode'; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../../nodes/_common"; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; export type SerializedParagraphNode = Spread< { - textFormat: number; textStyle: string; }, - SerializedElementNode + SerializedCommonBlockNode >; /** @noInheritDoc */ -export class ParagraphNode extends ElementNode { +export class ParagraphNode extends CommonBlockNode { ['constructor']!: KlassConstructor; /** @internal */ - __textFormat: number; __textStyle: string; constructor(key?: NodeKey) { super(key); - this.__textFormat = 0; this.__textStyle = ''; } @@ -59,22 +56,6 @@ export class ParagraphNode extends ElementNode { 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; @@ -92,8 +73,8 @@ export class ParagraphNode extends ElementNode { afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); - this.__textFormat = prevNode.__textFormat; this.__textStyle = prevNode.__textStyle; + copyCommonBlockProperties(prevNode, this); } // View @@ -105,6 +86,9 @@ export class ParagraphNode extends ElementNode { const domClassList = dom.classList; domClassList.add(...classNames); } + + updateElementWithCommonBlockProps(dom, this); + return dom; } updateDOM( @@ -112,7 +96,7 @@ export class ParagraphNode extends ElementNode { dom: HTMLElement, config: EditorConfig, ): boolean { - return false; + return commonPropertiesDifferent(prevNode, this); } static importDOM(): DOMConversionMap | null { @@ -131,16 +115,6 @@ export class ParagraphNode extends ElementNode { if (this.isEmpty()) { element.append(document.createElement('br')); } - - const formatType = this.getFormatType(); - element.style.textAlign = formatType; - - 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 { @@ -150,16 +124,13 @@ export class ParagraphNode extends ElementNode { static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode { const node = $createParagraphNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); - node.setTextFormat(serializedNode.textFormat); + deserializeCommonBlockNode(serializedNode, node); return node; } exportJSON(): SerializedParagraphNode { return { ...super.exportJSON(), - textFormat: this.getTextFormat(), textStyle: this.getTextStyle(), type: 'paragraph', version: 1, @@ -173,11 +144,9 @@ export class ParagraphNode extends ElementNode { 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; @@ -210,13 +179,7 @@ export class ParagraphNode extends ElementNode { 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); - } - } + setCommonBlockPropsFromElement(element, node); return {node}; } diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts index 74c8d5a7f9b..a1c8813c390 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts @@ -99,8 +99,6 @@ export class RootNode extends ElementNode { 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; } @@ -109,8 +107,6 @@ export class RootNode extends ElementNode { return { children: [], direction: this.getDirection(), - format: this.getFormatType(), - indent: this.getIndent(), type: 'root', version: 1, }; diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 2975315cc35..3e962ec72f7 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -327,9 +327,6 @@ function wrapContinuousInlines( 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); @@ -338,7 +335,6 @@ function wrapContinuousInlines( (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) ) { const wrapper = createWrapperFn(); - wrapper.setFormat(textAlign); wrapper.append(...continuousInlines); out.push(wrapper); continuousInlines = []; diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts index fe2b9757048..884fe9153a0 100644 --- a/resources/js/wysiwyg/lexical/link/index.ts +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -162,8 +162,6 @@ export class LinkNode extends ElementNode { target: serializedNode.target, title: serializedNode.title, }); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -402,8 +400,6 @@ export class AutoLinkNode extends LinkNode { target: serializedNode.target, title: serializedNode.title, }); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 5026a01293e..c20329e4be9 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -126,14 +126,12 @@ export class ListItemNode extends ElementNode { 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, }; @@ -172,7 +170,6 @@ export class ListItemNode extends ElementNode { if ($isListItemNode(replaceWithNode)) { return super.replace(replaceWithNode); } - this.setIndent(0); const list = this.getParentOrThrow(); if (!$isListNode(list)) { return replaceWithNode; @@ -351,41 +348,6 @@ export class ListItemNode extends ElementNode { 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); diff --git a/resources/js/wysiwyg/lexical/list/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts index b9ca011696a..aa0d5d61129 100644 --- a/resources/js/wysiwyg/lexical/list/formatList.ts +++ b/resources/js/wysiwyg/lexical/list/formatList.ts @@ -84,10 +84,6 @@ export function insertList(editor: LexicalEditor, listType: ListType): void { 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(); @@ -157,8 +153,6 @@ function $createListOrMerge(node: ElementNode, listType: ListType): ListNode { const previousSibling = node.getPreviousSibling(); const nextSibling = node.getNextSibling(); const listItem = $createListItemNode(); - listItem.setFormat(node.getFormatType()); - listItem.setIndent(node.getIndent()); append(listItem, node.getChildren()); if ( diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts index d937060c658..bc5c3f1d207 100644 --- a/resources/js/wysiwyg/lexical/rich-text/index.ts +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -155,9 +155,6 @@ export class QuoteNode extends ElementNode { if (this.isEmpty()) { element.append(document.createElement('br')); } - - const formatType = this.getFormatType(); - element.style.textAlign = formatType; } return { @@ -167,8 +164,6 @@ export class QuoteNode extends ElementNode { static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { const node = $createQuoteNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); return node; } @@ -315,9 +310,6 @@ export class HeadingNode extends ElementNode { if (this.isEmpty()) { element.append(document.createElement('br')); } - - const formatType = this.getFormatType(); - element.style.textAlign = formatType; } return { @@ -326,10 +318,7 @@ export class HeadingNode extends ElementNode { } static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { - const node = $createHeadingNode(serializedNode.tag); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); - return node; + return $createHeadingNode(serializedNode.tag); } exportJSON(): SerializedHeadingNode { @@ -402,18 +391,12 @@ function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { 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}; } @@ -651,9 +634,6 @@ export function registerRichText(editor: LexicalEditor): () => void { (parentNode): parentNode is ElementNode => $isElementNode(parentNode) && !parentNode.isInline(), ); - if (element !== null) { - element.setFormat(format); - } } return true; }, @@ -691,28 +671,6 @@ export function registerRichText(editor: LexicalEditor): () => void { }, 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) => { @@ -846,19 +804,7 @@ export function registerRichText(editor: LexicalEditor): () => void { 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, diff --git a/resources/js/wysiwyg/lexical/selection/range-selection.ts b/resources/js/wysiwyg/lexical/selection/range-selection.ts index dbadaf346b6..542eae4db9f 100644 --- a/resources/js/wysiwyg/lexical/selection/range-selection.ts +++ b/resources/js/wysiwyg/lexical/selection/range-selection.ts @@ -81,8 +81,6 @@ export function $setBlocksType( 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); } } @@ -136,8 +134,6 @@ export function $wrapNodes( : 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) { @@ -277,8 +273,6 @@ export function $wrapNodesImpl( 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 @@ -299,8 +293,6 @@ export function $wrapNodesImpl( '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); } diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts deleted file mode 100644 index 3adc10d0e9c..00000000000 --- a/resources/js/wysiwyg/nodes/custom-paragraph.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - DOMConversion, - DOMConversionMap, - DOMConversionOutput, - LexicalNode, - ParagraphNode, SerializedParagraphNode, Spread, -} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; -import { - CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - -export type SerializedCustomParagraphNode = Spread - -export class CustomParagraphNode extends ParagraphNode { - __id: string = ''; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-paragraph'; - } - - 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; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - static clone(node: CustomParagraphNode): CustomParagraphNode { - const newNode = new CustomParagraphNode(node.__key); - newNode.__id = node.__id; - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - 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, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomParagraphNode): CustomParagraphNode { - const node = $createCustomParagraphNode(); - deserializeCommonBlockNode(serializedNode, node); - return node; - } - - static importDOM(): DOMConversionMap|null { - return { - p(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - const node = $createCustomParagraphNode(); - if (element.style.textIndent) { - const indent = parseInt(element.style.textIndent, 10) / 20; - if (indent > 0) { - node.setIndent(indent); - } - } - - setCommonBlockPropsFromElement(element, node); - - return {node}; - }, - priority: 1, - }; - }, - }; - } -} - -export function $createCustomParagraphNode(): CustomParagraphNode { - return new CustomParagraphNode(); -} - -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/index.ts b/resources/js/wysiwyg/nodes/index.ts index b5483c5009c..062394a9887 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -7,7 +7,6 @@ import { LexicalNodeReplacement, NodeMutation, ParagraphNode } from "lexical"; -import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; import {DetailsNode, SummaryNode} from "./details"; @@ -45,14 +44,8 @@ export function getNodesForPageEditor(): (KlassConstructor | CodeBlockNode, DiagramNode, MediaNode, // TODO - Alignment - CustomParagraphNode, + ParagraphNode, LinkNode, - { - replace: ParagraphNode, - with: (node: ParagraphNode) => { - return new CustomParagraphNode(); - } - }, { replace: HeadingNode, with: (node: HeadingNode) => { diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index 07e35d4438e..e049d5e7c9e 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -1,4 +1,5 @@ import { + $createParagraphNode, $insertNodes, $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND, LexicalEditor, @@ -8,7 +9,6 @@ import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selec 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"; @@ -67,7 +67,7 @@ function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolea for (const imageFile of images) { const loadingImage = window.baseUrl('/loading.gif'); const loadingNode = $createImageNode(loadingImage); - const imageWrap = $createCustomParagraphNode(); + const imageWrap = $createParagraphNode(); imageWrap.append(loadingNode); $insertNodes([imageWrap]); diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 2c7bfdbbae7..3f0b0c495e0 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -1,5 +1,6 @@ import {EditorUiContext} from "../ui/framework/core"; import { + $createParagraphNode, $getSelection, $isDecoratorNode, COMMAND_PRIORITY_LOW, @@ -13,7 +14,6 @@ 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"; import {$isCustomListItemNode} from "../nodes/custom-list-item"; import {$setInsetForSelection} from "../utils/lists"; @@ -45,7 +45,7 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve if (nearestBlock) { requestAnimationFrame(() => { editor.update(() => { - const newParagraph = $createCustomParagraphNode(); + const newParagraph = $createParagraphNode(); nearestBlock.insertAfter(newParagraph); newParagraph.select(); }); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index a49cccd26dc..817a235a712 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,11 @@ ## In progress -// +Reorg + - Merge custom nodes into original nodes + - Reduce down to use CommonBlockNode where possible + - Remove existing formatType/ElementFormatType references (replaced with alignment). + - Remove existing indent references (replaced with inset). ## Main Todo diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 0ec9220dd2e..3cfc964423f 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -1,5 +1,13 @@ import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; -import {$createTextNode, $getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; +import { + $createParagraphNode, + $createTextNode, + $getSelection, + $insertNodes, + $isParagraphNode, + LexicalEditor, + LexicalNode +} from "lexical"; import { $getBlockElementNodesInSelection, $getNodeFromSelection, @@ -8,7 +16,6 @@ import { 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"; @@ -31,7 +38,7 @@ export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagT export function toggleSelectionAsParagraph(editor: LexicalEditor) { editor.update(() => { - $toggleSelectionBlockNodeType($isCustomParagraphNode, $createCustomParagraphNode); + $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); }); } diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 2dd99d369c7..97634f96b4e 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -1,4 +1,5 @@ import { + $createParagraphNode, $getRoot, $isDecoratorNode, $isElementNode, $isRootNode, @@ -8,7 +9,6 @@ import { LexicalNode } from "lexical"; import {LexicalNodeMatcher} from "../nodes"; -import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; import {NodeHasAlignment, NodeHasInset} from "../nodes/_common"; @@ -17,7 +17,7 @@ import {$findMatchingParent} from "@lexical/utils"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { return nodes.map(node => { if ($isTextNode(node)) { - const paragraph = $createCustomParagraphNode(); + const paragraph = $createParagraphNode(); paragraph.append(node); return paragraph; } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 67c2d91b26c..02838eba034 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -7,17 +7,15 @@ import { $isTextNode, $setSelection, BaseSelection, DecoratorNode, - ElementFormatType, ElementNode, LexicalEditor, LexicalNode, TextFormatType, TextNode } from "lexical"; -import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; -import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {CommonBlockAlignment} from "../nodes/_common"; const lastSelectionByEditor = new WeakMap; @@ -71,7 +69,7 @@ export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creat const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; if (selection && matcher(blockElement)) { - $setBlocksType(selection, $createCustomParagraphNode); + $setBlocksType(selection, $createParagraphNode); } else { $setBlocksType(selection, creator); } From 36a4d791205f824ce6d7d487ab4578ae736c78c0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 17:04:50 +0000 Subject: [PATCH 244/800] Lexical: Extracted & merged heading & quote nodes --- .../unit/LexicalSerialization.test.ts | 3 +- .../lexical/core/__tests__/utils/index.ts | 3 +- .../lexical/core/nodes/CommonBlockNode.ts | 2 +- .../__tests__/unit/LexicalTabNode.test.ts | 3 +- .../html/__tests__/unit/LexicalHtml.test.ts | 3 +- .../lexical/rich-text/LexicalHeadingNode.ts | 202 ++++++++++ .../lexical/rich-text/LexicalQuoteNode.ts | 129 +++++++ .../__tests__/unit/LexicalHeadingNode.test.ts | 6 +- .../__tests__/unit/LexicalQuoteNode.test.ts | 2 +- .../js/wysiwyg/lexical/rich-text/index.ts | 345 +----------------- .../__tests__/unit/LexicalSelection.test.ts | 3 +- .../unit/LexicalSelectionHelpers.test.ts | 2 +- .../unit/LexicalEventHelpers.test.ts | 4 +- resources/js/wysiwyg/nodes/custom-heading.ts | 146 -------- resources/js/wysiwyg/nodes/custom-quote.ts | 115 ------ resources/js/wysiwyg/nodes/index.ts | 21 +- resources/js/wysiwyg/services/shortcuts.ts | 2 +- .../ui/defaults/buttons/block-formats.ts | 8 +- .../wysiwyg/ui/framework/blocks/link-field.ts | 11 +- resources/js/wysiwyg/utils/formats.ts | 11 +- 20 files changed, 370 insertions(+), 651 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts create mode 100644 resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-heading.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-quote.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 5599604c059..81eff674a99 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -8,11 +8,12 @@ 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'; +import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {$createQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; function $createEditorContent() { const root = $getRoot(); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index a4d74210e41..e9d14ef1139 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -10,7 +10,6 @@ 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 { @@ -36,6 +35,8 @@ import { LexicalNodeReplacement, } from '../../LexicalEditor'; import {resetRandomKey} from '../../LexicalUtils'; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; type TestEnv = { diff --git a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts index 37ca1cdef7f..bf4fc08ca60 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts @@ -48,7 +48,7 @@ export class CommonBlockNode extends ElementNode { } export function copyCommonBlockProperties(from: CommonBlockNode, to: CommonBlockNode): void { - to.__id = from.__id; + // to.__id = from.__id; to.__alignment = from.__alignment; to.__inset = from.__inset; } \ No newline at end of file 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 d8525fb369f..9831114340d 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 @@ -11,7 +11,7 @@ import { $insertDataTransferForRichText, } from '@lexical/clipboard'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; +import {registerRichText} from '@lexical/rich-text'; import { $createParagraphNode, $createRangeSelection, @@ -32,6 +32,7 @@ import { initializeUnitTest, invariant, } from '../../../__tests__/utils'; +import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; describe('LexicalTabNode tests', () => { initializeUnitTest((testEnv) => { 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 947e591b4ff..a4e2d231389 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -13,13 +13,14 @@ 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'; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; describe('HTML', () => { type Input = Array<{ diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts new file mode 100644 index 00000000000..0f30263ba0e --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts @@ -0,0 +1,202 @@ +import { + $applyNodeReplacement, + $createParagraphNode, + type DOMConversionMap, + DOMConversionOutput, + type DOMExportOutput, + type EditorConfig, + isHTMLElement, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type ParagraphNode, + type RangeSelection, + type SerializedElementNode, + type Spread +} from "lexical"; +import {addClassNamesToElement} from "@lexical/utils"; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../nodes/_common"; + +export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export type SerializedHeadingNode = Spread< + { + tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + }, + SerializedCommonBlockNode +>; + +/** @noInheritDoc */ +export class HeadingNode extends CommonBlockNode { + /** @internal */ + __tag: HeadingTagType; + + static getType(): string { + return 'heading'; + } + + static clone(node: HeadingNode): HeadingNode { + const clone = new HeadingNode(node.__tag, node.__key); + copyCommonBlockProperties(node, clone); + return clone; + } + + 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); + } + updateElementWithCommonBlockProps(element, this); + return element; + } + + updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { + return commonPropertiesDifferent(prevNode, this); + } + + 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, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element && isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { + const node = $createHeadingNode(serializedNode.tag); + deserializeCommonBlockNode(serializedNode, node); + 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 $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); + setCommonBlockPropsFromElement(element, node); + } + 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; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts new file mode 100644 index 00000000000..53caca80115 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts @@ -0,0 +1,129 @@ +import { + $applyNodeReplacement, + $createParagraphNode, + type DOMConversionMap, + type DOMConversionOutput, + type DOMExportOutput, + type EditorConfig, + ElementNode, + isHTMLElement, + type LexicalEditor, + LexicalNode, + type NodeKey, + type ParagraphNode, + type RangeSelection, + SerializedElementNode +} from "lexical"; +import {addClassNamesToElement} from "@lexical/utils"; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../nodes/_common"; + +export type SerializedQuoteNode = SerializedCommonBlockNode; + +/** @noInheritDoc */ +export class QuoteNode extends CommonBlockNode { + static getType(): string { + return 'quote'; + } + + static clone(node: QuoteNode): QuoteNode { + const clone = new QuoteNode(node.__key); + copyCommonBlockProperties(node, clone); + return clone; + } + + constructor(key?: NodeKey) { + super(key); + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('blockquote'); + addClassNamesToElement(element, config.theme.quote); + updateElementWithCommonBlockProps(element, this); + return element; + } + + updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { + return commonPropertiesDifferent(prevNode, this); + } + + 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')); + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { + const node = $createQuoteNode(); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + + exportJSON(): SerializedQuoteNode { + 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; +} + +function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { + const node = $createQuoteNode(); + setCommonBlockPropsFromElement(element, node); + return {node}; +} \ 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 index a94f9ee0bb8..be4b97ba3f3 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 @@ -6,11 +6,6 @@ * */ -import { - $createHeadingNode, - $isHeadingNode, - HeadingNode, -} from '@lexical/rich-text'; import { $createTextNode, $getRoot, @@ -19,6 +14,7 @@ import { RangeSelection, } from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; +import {$createHeadingNode, $isHeadingNode, HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; 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 66374bf5ff0..cf85045cda1 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 @@ -6,9 +6,9 @@ * */ -import {$createQuoteNode, QuoteNode} from '@lexical/rich-text'; import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; +import {$createQuoteNode, QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts index bc5c3f1d207..c585c028a5a 100644 --- a/resources/js/wysiwyg/lexical/rich-text/index.ts +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -8,42 +8,14 @@ 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, @@ -55,7 +27,6 @@ import { $isElementNode, $isNodeSelection, $isRangeSelection, - $isRootNode, $isTextNode, $normalizeSelection__EXPERIMENTAL, $selectAll, @@ -75,7 +46,6 @@ import { ElementNode, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, - INDENT_CONTENT_COMMAND, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, INSERT_TAB_COMMAND, @@ -88,327 +58,22 @@ import { 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 ->; +import {$insertDataTransferForRichText, copyToClipboard,} from '@lexical/clipboard'; +import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexical/selection'; +import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils'; +import caretFromPoint from 'lexical/shared/caretFromPoint'; +import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment'; 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')); - } - } - - return { - element, - }; - } - - static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { - const node = $createQuoteNode(); - 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')); - } - } - - return { - element, - }; - } - - static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { - return $createHeadingNode(serializedNode.tag); - } - - 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); - } - return {node}; -} - -function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { - const node = $createQuoteNode(); - 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, 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 5f2d9dcc093..466be7498de 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -8,7 +8,7 @@ import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; +import {registerRichText} from '@lexical/rich-text'; import { $addNodeStyle, $getSelectionStyleValueForProperty, @@ -74,6 +74,7 @@ import { } from '../utils'; import {createEmptyHistoryState, registerHistory} from "@lexical/history"; import {mergeRegister} from "@lexical/utils"; +import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; interface ExpectedSelection { anchorPath: number[]; 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 4d88bde0e4c..0523b7f7164 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -7,7 +7,6 @@ */ import {$createLinkNode} from '@lexical/link'; -import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; import { $getSelectionStyleValueForProperty, $patchStyleText, @@ -44,6 +43,7 @@ import { } from 'lexical/__tests__/utils'; import {$setAnchorPoint, $setFocusPoint} from '../utils'; +import {$createHeadingNode, $isHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; Range.prototype.getBoundingClientRect = function (): DOMRect { const rect = { 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 fd7731f9061..d76937ed606 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -7,7 +7,7 @@ */ import {AutoLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; -import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text'; +import {registerRichText} from '@lexical/rich-text'; import { applySelectionInputs, pasteHTML, @@ -15,6 +15,8 @@ import { import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import {$createParagraphNode, $insertNodes, LexicalEditor} from 'lexical'; import {createTestEditor, initializeClipboard} from 'lexical/__tests__/utils'; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; jest.mock('lexical/shared/environment', () => { const originalModule = jest.requireActual('lexical/shared/environment'); diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts deleted file mode 100644 index 5df6245f5a5..00000000000 --- a/resources/js/wysiwyg/nodes/custom-heading.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - DOMConversionMap, - DOMConversionOutput, - LexicalNode, - Spread -} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; -import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text"; -import { - CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - - -export type SerializedCustomHeadingNode = Spread - -export class CustomHeadingNode extends HeadingNode { - __id: string = ''; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-heading'; - } - - 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; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - static clone(node: CustomHeadingNode) { - const newNode = new CustomHeadingNode(node.__tag, node.__key); - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - 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, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode { - const node = $createCustomHeadingNode(serializedNode.tag); - deserializeCommonBlockNode(serializedNode, node); - 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); - setCommonBlockPropsFromElement(element, node); - } - 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-quote.ts b/resources/js/wysiwyg/nodes/custom-quote.ts deleted file mode 100644 index 39ae7bf8af3..00000000000 --- a/resources/js/wysiwyg/nodes/custom-quote.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - DOMConversionMap, - DOMConversionOutput, - LexicalNode, - Spread -} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; -import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text"; -import { - CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - - -export type SerializedCustomQuoteNode = Spread - -export class CustomQuoteNode extends QuoteNode { - __id: string = ''; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-quote'; - } - - 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; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - static clone(node: CustomQuoteNode) { - const newNode = new CustomQuoteNode(node.__key); - newNode.__id = node.__id; - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - 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, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode { - const node = $createCustomQuoteNode(); - deserializeCommonBlockNode(serializedNode, node); - return node; - } - - static importDOM(): DOMConversionMap | null { - return { - blockquote: (node: Node) => ({ - conversion: $convertBlockquoteElement, - priority: 0, - }), - }; - } -} - -function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { - const node = $createCustomQuoteNode(); - setCommonBlockPropsFromElement(element, node); - 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/index.ts b/resources/js/wysiwyg/nodes/index.ts index 062394a9887..7b274eba13c 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,4 +1,3 @@ -import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; import { ElementNode, @@ -21,9 +20,9 @@ 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"; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; /** * Load the nodes for lexical. @@ -31,8 +30,8 @@ import {CustomListNode} from "./custom-list"; export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ CalloutNode, - CustomHeadingNode, - CustomQuoteNode, + HeadingNode, + QuoteNode, CustomListNode, CustomListItemNode, // TODO - Alignment? CustomTableNode, @@ -46,18 +45,6 @@ export function getNodesForPageEditor(): (KlassConstructor | MediaNode, // TODO - Alignment ParagraphNode, LinkNode, - { - replace: HeadingNode, - with: (node: HeadingNode) => { - return new CustomHeadingNode(node.__tag); - } - }, - { - replace: QuoteNode, - with: (node: QuoteNode) => { - return new CustomQuoteNode(); - } - }, { replace: ListNode, with: (node: ListNode) => { diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 05bdb5dccd3..0384a3bf133 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -6,12 +6,12 @@ import { 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"; +import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { toggleSelectionAsHeading(editor, tag); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index f86e33c311e..e0d1e7077fa 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -2,18 +2,14 @@ import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../ import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; -import { - $isHeadingNode, - $isQuoteNode, - HeadingNode, - HeadingTagType -} from "@lexical/rich-text"; import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection"; import { toggleSelectionAsBlockquote, toggleSelectionAsHeading, toggleSelectionAsParagraph } from "../../../utils/formats"; +import {$isHeadingNode, HeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; +import {$isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts index 5a64cdc30fc..f88b22c3f05 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -1,14 +1,13 @@ 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"; +import {$isHeadingNode, HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; export class LinkField extends EditorContainerUiElement { protected input: EditorFormField; - protected headerMap = new Map(); + protected headerMap = new Map(); constructor(input: EditorFormField) { super([input]); @@ -43,7 +42,7 @@ export class LinkField extends EditorContainerUiElement { return container; } - updateFormFromHeader(header: CustomHeadingNode) { + updateFormFromHeader(header: HeadingNode) { this.getHeaderIdAndText(header).then(({id, text}) => { console.log('updating form', id, text); const modal = this.getContext().manager.getActiveModal('link'); @@ -57,7 +56,7 @@ export class LinkField extends EditorContainerUiElement { }); } - getHeaderIdAndText(header: CustomHeadingNode): Promise<{id: string, text: string}> { + getHeaderIdAndText(header: HeadingNode): Promise<{id: string, text: string}> { return new Promise((res) => { this.getContext().editor.update(() => { let id = header.getId(); @@ -75,7 +74,7 @@ export class LinkField extends EditorContainerUiElement { updateDataList(listEl: HTMLElement) { this.getContext().editor.getEditorState().read(() => { - const headers = $getAllNodesOfType($isHeadingNode) as CustomHeadingNode[]; + const headers = $getAllNodesOfType($isHeadingNode) as HeadingNode[]; this.headerMap.clear(); const listEls: HTMLElement[] = []; diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 3cfc964423f..d724730e3a2 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -1,4 +1,3 @@ -import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; import { $createParagraphNode, $createTextNode, @@ -15,23 +14,23 @@ import { $toggleSelectionBlockNodeType, getLastSelection } from "./selection"; -import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading"; -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"; +import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; +import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { - return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; + return $isHeadingNode(node) && node.getTag() === tag; }; export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagType) { editor.update(() => { $toggleSelectionBlockNodeType( (node) => $isHeaderNodeOfTag(node, tag), - () => $createCustomHeadingNode(tag), + () => $createHeadingNode(tag), ) }); } @@ -44,7 +43,7 @@ export function toggleSelectionAsParagraph(editor: LexicalEditor) { export function toggleSelectionAsBlockquote(editor: LexicalEditor) { editor.update(() => { - $toggleSelectionBlockNodeType($isQuoteNode, $createCustomQuoteNode); + $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); }); } From ebd4604f21060cd5414fda5d2d79ed41e79e9b62 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 19:03:52 +0000 Subject: [PATCH 245/800] Lexical: Merged list nodes --- .../lexical/list/LexicalListItemNode.ts | 126 ++++++---------- .../wysiwyg/lexical/list/LexicalListNode.ts | 66 ++++++--- .../unit/LexicalListItemNode.test.ts | 94 ------------ .../js/wysiwyg/nodes/custom-list-item.ts | 120 --------------- resources/js/wysiwyg/nodes/custom-list.ts | 139 ------------------ resources/js/wysiwyg/nodes/index.ts | 18 +-- .../js/wysiwyg/services/keyboard-handling.ts | 4 +- .../ui/framework/helpers/task-list-handler.ts | 4 +- resources/js/wysiwyg/utils/formats.ts | 5 +- resources/js/wysiwyg/utils/lists.ts | 27 ++-- 10 files changed, 111 insertions(+), 492 deletions(-) delete mode 100644 resources/js/wysiwyg/nodes/custom-list-item.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-list.ts diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index c20329e4be9..33b021298a6 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -13,7 +13,6 @@ import type { DOMConversionOutput, DOMExportOutput, EditorConfig, - EditorThemeClasses, LexicalNode, NodeKey, ParagraphNode, @@ -22,10 +21,6 @@ import type { Spread, } from 'lexical'; -import { - addClassNamesToElement, - removeClassNamesFromElement, -} from '@lexical/utils'; import { $applyNodeReplacement, $createParagraphNode, @@ -36,11 +31,11 @@ import { 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 {mergeLists} from './formatList'; import {isNestedListNode} from './utils'; +import {el} from "../../utils/dom"; export type SerializedListItemNode = Spread< { @@ -74,11 +69,17 @@ export class ListItemNode extends ElementNode { createDOM(config: EditorConfig): HTMLElement { const element = document.createElement('li'); const parent = this.getParent(); + if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(element, this, null, parent); + updateListItemChecked(element, this); } + element.value = this.__value; - $setListItemThemeClassNames(element, config.theme, this); + + if ($hasNestedListWithoutLabel(this)) { + element.style.listStyle = 'none'; + } + return element; } @@ -89,11 +90,12 @@ export class ListItemNode extends ElementNode { ): boolean { const parent = this.getParent(); if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(dom, this, prevNode, parent); + updateListItemChecked(dom, this); } + + dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : ''; // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; - $setListItemThemeClassNames(dom, config.theme, this); return false; } @@ -132,6 +134,20 @@ export class ListItemNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const element = this.createDOM(editor._config); + + 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, }; @@ -390,89 +406,33 @@ export class ListItemNode extends ElementNode { } } -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); - } +function $hasNestedListWithoutLabel(node: ListItemNode): boolean { + const children = node.getChildren(); + let hasLabel = false; + let hasNestedList = false; - if (isCheckList) { - classesToAdd.push( - checked ? listTheme.listitemChecked : listTheme.listitemUnchecked, - ); + for (const child of children) { + if ($isListNode(child)) { + hasNestedList = true; + } else if (child.getTextContent().trim().length > 0) { + hasLabel = true; } } - 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); - } + return hasNestedList && !hasLabel; } 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'); + // 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.setAttribute('role', 'checkbox'); - dom.setAttribute('tabIndex', '-1'); - - if ( - !prevListItemNode || - listItemNode.__checked !== prevListItemNode.__checked - ) { - dom.setAttribute( - 'aria-checked', - listItemNode.getChecked() ? 'true' : 'false', - ); - } + dom.removeAttribute('checked'); } } diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index e22fbf7717f..138c895e6b8 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -36,9 +36,11 @@ import { updateChildrenListItemValue, } from './formatList'; import {$getListDepth, $wrapInListItem} from './utils'; +import {extractDirectionFromElement} from "../../nodes/_common"; export type SerializedListNode = Spread< { + id: string; listType: ListType; start: number; tag: ListNodeTagType; @@ -58,15 +60,18 @@ export class ListNode extends ElementNode { __start: number; /** @internal */ __listType: ListType; + /** @internal */ + __id: string = ''; 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); + const newNode = new ListNode(node.__listType, node.__start, node.__key); + newNode.__id = node.__id; + newNode.__dir = node.__dir; + return newNode; } constructor(listType: ListType, start: number, key?: NodeKey) { @@ -81,6 +86,16 @@ export class ListNode extends ElementNode { return this.__tag; } + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + setListType(type: ListType): void { const writable = this.getWritable(); writable.__listType = type; @@ -108,6 +123,14 @@ export class ListNode extends ElementNode { dom.__lexicalListType = this.__listType; $setListThemeClassNames(dom, config.theme, this); + if (this.__id) { + dom.setAttribute('id', this.__id); + } + + if (this.__dir) { + dom.setAttribute('dir', this.__dir); + } + return dom; } @@ -116,7 +139,11 @@ export class ListNode extends ElementNode { dom: HTMLElement, config: EditorConfig, ): boolean { - if (prevNode.__tag !== this.__tag) { + if ( + prevNode.__tag !== this.__tag + || prevNode.__dir !== this.__dir + || prevNode.__id !== this.__id + ) { return true; } @@ -148,8 +175,7 @@ export class ListNode extends ElementNode { static importJSON(serializedNode: SerializedListNode): ListNode { const node = $createListNode(serializedNode.listType, serializedNode.start); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); + node.setId(serializedNode.id); node.setDirection(serializedNode.direction); return node; } @@ -177,6 +203,7 @@ export class ListNode extends ElementNode { tag: this.getTag(), type: 'list', version: 1, + id: this.__id, }; } @@ -277,28 +304,21 @@ function $setListThemeClassNames( } /* - * 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. + * 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. */ function $normalizeChildren(nodes: Array): Array { const normalizedListItems: Array = []; - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; + + for (const node of nodes) { 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; } @@ -334,6 +354,14 @@ function $convertListNode(domNode: HTMLElement): DOMConversionOutput { } } + if (domNode.id && node) { + node.setId(domNode.id); + } + + if (domNode.dir && node) { + node.setDirection(extractDirectionFromElement(domNode)); + } + return { after: $normalizeChildren, node, 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 581db0294f5..523c7eb126e 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -1265,99 +1265,5 @@ describe('LexicalListItemNode tests', () => { 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/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts deleted file mode 100644 index 11887b4364d..00000000000 --- a/resources/js/wysiwyg/nodes/custom-list-item.ts +++ /dev/null @@ -1,120 +0,0 @@ -import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list"; -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, - 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; - - if ($hasNestedListWithoutLabel(this)) { - element.style.listStyle = 'none'; - } - - return element; - } - - updateDOM( - prevNode: ListItemNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { - const parent = this.getParent(); - if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(dom, this); - } - - dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : ''; - // @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', - }; - } -} - -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 deleted file mode 100644 index 4b05fa62e25..00000000000 --- a/resources/js/wysiwyg/nodes/custom-list.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - DOMConversionFn, - DOMConversionMap, EditorConfig, - LexicalNode, - Spread -} from "lexical"; -import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list"; -import {$createCustomListItemNode} from "./custom-list-item"; -import {extractDirectionFromElement} from "./_common"; - - -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, node.__start, node.__key); - newNode.__id = node.__id; - newNode.__dir = node.__dir; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - if (this.__id) { - 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(), - type: 'custom-list', - version: 1, - id: this.__id, - }; - } - - static importJSON(serializedNode: SerializedCustomListNode): CustomListNode { - const node = $createCustomListNode(serializedNode.listType); - node.setId(serializedNode.id); - node.setDirection(serializedNode.direction); - 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); - } - - if (element.dir && baseResult?.node) { - (baseResult.node as CustomListNode).setDirection(extractDirectionFromElement(element)); - } - - if (baseResult) { - baseResult.after = $normalizeChildren; - } - - return baseResult; - }; - - return { - ol: () => ({ - conversion: customConvertFunction, - priority: 0, - }), - ul: () => ({ - conversion: customConvertFunction, - priority: 0, - }), - }; - } -} - -/* - * 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, 1); -} - -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/index.ts b/resources/js/wysiwyg/nodes/index.ts index 7b274eba13c..7e0ce9daf2b 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -17,10 +17,8 @@ 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"; import {CustomTableCellNode} from "./custom-table-cell"; import {CustomTableRowNode} from "./custom-table-row"; -import {CustomListNode} from "./custom-list"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; @@ -32,8 +30,8 @@ export function getNodesForPageEditor(): (KlassConstructor | CalloutNode, HeadingNode, QuoteNode, - CustomListNode, - CustomListItemNode, // TODO - Alignment? + ListNode, + ListItemNode, CustomTableNode, CustomTableRowNode, CustomTableCellNode, @@ -45,18 +43,6 @@ export function getNodesForPageEditor(): (KlassConstructor | MediaNode, // TODO - Alignment ParagraphNode, LinkNode, - { - replace: ListNode, - with: (node: ListNode) => { - return new CustomListNode(node.getListType(), node.getStart()); - } - }, - { - replace: ListItemNode, - with: (node: ListItemNode) => { - return new CustomListItemNode(node.__value, node.__checked); - } - }, { replace: TableNode, with(node: TableNode) { diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 3f0b0c495e0..5f7f41ef02c 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -14,8 +14,8 @@ import {$isImageNode} from "../nodes/image"; import {$isMediaNode} from "../nodes/media"; import {getLastSelection} from "../utils/selection"; import {$getNearestNodeBlockParent} from "../utils/nodes"; -import {$isCustomListItemNode} from "../nodes/custom-list-item"; import {$setInsetForSelection} from "../utils/lists"; +import {$isListItemNode} from "@lexical/list"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -62,7 +62,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); const nodes = selection?.getNodes() || []; - if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) { + if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) { editor.update(() => { $setInsetForSelection(editor, change); }); diff --git a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts index da8c0eae3dd..62a784d83ed 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts @@ -1,5 +1,5 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; -import {$isCustomListItemNode} from "../../../nodes/custom-list-item"; +import {$isListItemNode} from "@lexical/list"; class TaskListHandler { protected editorContainer: HTMLElement; @@ -38,7 +38,7 @@ class TaskListHandler { this.editor.update(() => { const node = $getNearestNodeFromDOMNode(listItem); - if ($isCustomListItemNode(node)) { + if ($isListItemNode(node)) { node.setChecked(!node.getChecked()); } }); diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index d724730e3a2..1be802ebf1c 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -16,8 +16,7 @@ import { } from "./selection"; 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 {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; @@ -51,7 +50,7 @@ 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; + return $isListNode(node) && (node as ListNode).getListType() === type; }); if (listSelected) { diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 30a97cbc1f9..646f341c2ba 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,22 +1,21 @@ -import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item"; -import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list"; import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {nodeHasInset} from "./nodes"; +import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; -export function $nestListItem(node: CustomListItemNode): CustomListItemNode { +export function $nestListItem(node: ListItemNode): ListItemNode { const list = node.getParent(); - if (!$isCustomListNode(list)) { + if (!$isListNode(list)) { return node; } - const listItems = list.getChildren() as CustomListItemNode[]; + const listItems = list.getChildren() as ListItemNode[]; const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); const isFirst = nodeIndex === 0; - const newListItem = $createCustomListItemNode(); - const newList = $createCustomListNode(list.getListType()); + const newListItem = $createListItemNode(); + const newList = $createListNode(list.getListType()); newList.append(newListItem); newListItem.append(...node.getChildren()); @@ -31,11 +30,11 @@ export function $nestListItem(node: CustomListItemNode): CustomListItemNode { return newListItem; } -export function $unnestListItem(node: CustomListItemNode): CustomListItemNode { +export function $unnestListItem(node: ListItemNode): ListItemNode { const list = node.getParent(); const parentListItem = list?.getParent(); const outerList = parentListItem?.getParent(); - if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) { + if (!$isListNode(list) || !$isListNode(outerList) || !$isListItemNode(parentListItem)) { return node; } @@ -51,19 +50,19 @@ export function $unnestListItem(node: CustomListItemNode): CustomListItemNode { return node; } -function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] { +function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] { const nodes = selection?.getNodes() || []; const listItemNodes = []; outer: for (const node of nodes) { - if ($isCustomListItemNode(node)) { + if ($isListItemNode(node)) { listItemNodes.push(node); continue; } const parents = node.getParents(); for (const parent of parents) { - if ($isCustomListItemNode(parent)) { + if ($isListItemNode(parent)) { listItemNodes.push(parent); continue outer; } @@ -75,8 +74,8 @@ function getListItemsForSelection(selection: BaseSelection|null): (CustomListIte return listItemNodes; } -function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] { - const listItemMap: Record = {}; +function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[] { + const listItemMap: Record = {}; for (const item of listItems) { if (item === null) { From 57d8449660e2cf5acd9bfaa6c951b3dfd040bf42 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 20:08:33 +0000 Subject: [PATCH 246/800] Lexical: Merged custom table node code --- .../lexical/table/LexicalTableCellNode.ts | 134 ++++++---- .../wysiwyg/lexical/table/LexicalTableNode.ts | 98 ++++++- .../lexical/table/LexicalTableRowNode.ts | 57 ++-- .../table/LexicalTableSelectionHelpers.ts | 53 ---- .../unit/LexicalTableRowNode.test.ts | 5 +- .../js/wysiwyg/nodes/custom-table-cell.ts | 247 ------------------ .../js/wysiwyg/nodes/custom-table-row.ts | 106 -------- resources/js/wysiwyg/nodes/custom-table.ts | 166 ------------ resources/js/wysiwyg/nodes/index.ts | 33 +-- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 35 ++- .../js/wysiwyg/ui/defaults/forms/tables.ts | 21 +- .../ui/framework/blocks/table-creator.ts | 3 +- .../ui/framework/helpers/table-resizer.ts | 5 +- .../helpers/table-selection-handler.ts | 8 +- .../js/wysiwyg/utils/table-copy-paste.ts | 42 +-- resources/js/wysiwyg/utils/table-map.ts | 28 +- resources/js/wysiwyg/utils/tables.ts | 70 ++--- 17 files changed, 323 insertions(+), 788 deletions(-) delete mode 100644 resources/js/wysiwyg/nodes/custom-table-cell.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-table-row.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-table.ts diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 455d39bf6c9..72676b9bacb 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -28,7 +28,8 @@ import { ElementNode, } from 'lexical'; -import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants'; +import {extractStyleMapFromElement, StyleMap} from "../../utils/dom"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "../../nodes/_common"; export const TableCellHeaderStates = { BOTH: 3, @@ -47,6 +48,8 @@ export type SerializedTableCellNode = Spread< headerState: TableCellHeaderState; width?: number; backgroundColor?: null | string; + styles: Record; + alignment: CommonBlockAlignment; }, SerializedElementNode >; @@ -63,6 +66,10 @@ export class TableCellNode extends ElementNode { __width?: number; /** @internal */ __backgroundColor: null | string; + /** @internal */ + __styles: StyleMap = new Map; + /** @internal */ + __alignment: CommonBlockAlignment = ''; static getType(): string { return 'tablecell'; @@ -77,6 +84,8 @@ export class TableCellNode extends ElementNode { ); cellNode.__rowSpan = node.__rowSpan; cellNode.__backgroundColor = node.__backgroundColor; + cellNode.__styles = new Map(node.__styles); + cellNode.__alignment = node.__alignment; return cellNode; } @@ -94,16 +103,20 @@ export class TableCellNode extends ElementNode { } static importJSON(serializedNode: SerializedTableCellNode): TableCellNode { - const colSpan = serializedNode.colSpan || 1; - const rowSpan = serializedNode.rowSpan || 1; - const cellNode = $createTableCellNode( - serializedNode.headerState, - colSpan, - serializedNode.width || undefined, + const node = $createTableCellNode( + serializedNode.headerState, + serializedNode.colSpan, + serializedNode.width, ); - cellNode.__rowSpan = rowSpan; - cellNode.__backgroundColor = serializedNode.backgroundColor || null; - return cellNode; + + if (serializedNode.rowSpan) { + node.setRowSpan(serializedNode.rowSpan); + } + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + node.setAlignment(serializedNode.alignment); + + return node; } constructor( @@ -144,34 +157,19 @@ export class TableCellNode extends ElementNode { this.hasHeader() && config.theme.tableCellHeader, ); + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + 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, }; @@ -186,6 +184,8 @@ export class TableCellNode extends ElementNode { rowSpan: this.__rowSpan, type: 'tablecell', width: this.getWidth(), + styles: Object.fromEntries(this.__styles), + alignment: this.__alignment, }; } @@ -231,6 +231,38 @@ export class TableCellNode extends ElementNode { return this.getLatest().__width; } + clearWidth(): void { + const self = this.getWritable(); + self.__width = undefined; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + 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; + const self = this.getWritable(); + self.__headerState = state; + } + getBackgroundColor(): null | string { return this.getLatest().__backgroundColor; } @@ -265,7 +297,9 @@ export class TableCellNode extends ElementNode { prevNode.__width !== this.__width || prevNode.__colSpan !== this.__colSpan || prevNode.__rowSpan !== this.__rowSpan || - prevNode.__backgroundColor !== this.__backgroundColor + prevNode.__backgroundColor !== this.__backgroundColor || + prevNode.__styles !== this.__styles || + prevNode.__alignment !== this.__alignment ); } @@ -287,38 +321,42 @@ export class TableCellNode extends ElementNode { } export function $convertTableCellNodeElement( - domNode: Node, + 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, + 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'; + style.fontWeight === '700' || style.fontWeight === 'bold'; const hasLinethroughTextDecoration = textDecoration.includes('line-through'); const hasItalicFontStyle = style.fontStyle === 'italic'; const hasUnderlineTextDecoration = textDecoration.includes('underline'); + + if (domNode instanceof HTMLElement) { + tableCellNode.setStyles(extractStyleMapFromElement(domNode)); + tableCellNode.setAlignment(extractAlignmentFromElement(domNode)); + } + return { after: (childLexicalNodes) => { if (childLexicalNodes.length === 0) { @@ -330,8 +368,8 @@ export function $convertTableCellNodeElement( if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) { const paragraphNode = $createParagraphNode(); if ( - $isLineBreakNode(lexicalNode) && - lexicalNode.getTextContent() === '\n' + $isLineBreakNode(lexicalNode) && + lexicalNode.getTextContent() === '\n' ) { return null; } @@ -360,7 +398,7 @@ export function $convertTableCellNodeElement( } export function $createTableCellNode( - headerState: TableCellHeaderState, + headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, colSpan = 1, width?: number, ): TableCellNode { diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 357ba3e738b..ab163005370 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -7,7 +7,7 @@ */ import type {TableCellNode} from './LexicalTableCellNode'; -import type { +import { DOMConversionMap, DOMConversionOutput, DOMExportOutput, @@ -15,7 +15,7 @@ import type { LexicalEditor, LexicalNode, NodeKey, - SerializedElementNode, + SerializedElementNode, Spread, } from 'lexical'; import {addClassNamesToElement, isHTMLElement} from '@lexical/utils'; @@ -27,19 +27,36 @@ import { import {$isTableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; -import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; import {getTable} from './LexicalTableSelectionHelpers'; - -export type SerializedTableNode = SerializedElementNode; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../nodes/_common"; +import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom"; +import {getTableColumnWidths} from "../../utils/tables"; + +export type SerializedTableNode = Spread<{ + colWidths: string[]; + styles: Record, +}, SerializedCommonBlockNode> /** @noInheritDoc */ -export class TableNode extends ElementNode { +export class TableNode extends CommonBlockNode { + __colWidths: string[] = []; + __styles: StyleMap = new Map; + static getType(): string { return 'table'; } static clone(node: TableNode): TableNode { - return new TableNode(node.__key); + const newNode = new TableNode(node.__key); + copyCommonBlockProperties(node, newNode); + newNode.__colWidths = node.__colWidths; + newNode.__styles = new Map(node.__styles); + return newNode; } static importDOM(): DOMConversionMap | null { @@ -52,18 +69,24 @@ export class TableNode extends ElementNode { } static importJSON(_serializedNode: SerializedTableNode): TableNode { - return $createTableNode(); + const node = $createTableNode(); + deserializeCommonBlockNode(_serializedNode, node); + node.setColWidths(_serializedNode.colWidths); + node.setStyles(new Map(Object.entries(_serializedNode.styles))); + return node; } constructor(key?: NodeKey) { super(key); } - exportJSON(): SerializedElementNode { + exportJSON(): SerializedTableNode { return { ...super.exportJSON(), type: 'table', version: 1, + colWidths: this.__colWidths, + styles: Object.fromEntries(this.__styles), }; } @@ -72,11 +95,33 @@ export class TableNode extends ElementNode { addClassNamesToElement(tableElement, config.theme.table); + updateElementWithCommonBlockProps(tableElement, this); + + 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); + } + tableElement.append(colgroup); + } + + for (const [name, value] of this.__styles.entries()) { + tableElement.style.setProperty(name, value); + } + return tableElement; } - updateDOM(): boolean { - return false; + updateDOM(_prevNode: TableNode): boolean { + return commonPropertiesDifferent(_prevNode, this) + || this.__colWidths.join(':') !== _prevNode.__colWidths.join(':') + || this.__styles.size !== _prevNode.__styles.size + || (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).join(':'))); } exportDOM(editor: LexicalEditor): DOMExportOutput { @@ -115,6 +160,26 @@ export class TableNode extends ElementNode { return true; } + setColWidths(widths: string[]) { + const self = this.getWritable(); + self.__colWidths = widths; + } + + getColWidths(): string[] { + const self = this.getLatest(); + 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); + } + getCordsFromCellNode( tableCellNode: TableCellNode, table: TableDOMTable, @@ -239,8 +304,15 @@ export function $getElementForTableNode( return getTable(tableElement); } -export function $convertTableElement(_domNode: Node): DOMConversionOutput { - return {node: $createTableNode()}; +export function $convertTableElement(element: HTMLElement): DOMConversionOutput { + const node = $createTableNode(); + setCommonBlockPropsFromElement(element, node); + + const colWidths = getTableColumnWidths(element as HTMLTableElement); + node.setColWidths(colWidths); + node.setStyles(extractStyleMapFromElement(element)); + + return {node}; } export function $createTableNode(): TableNode { diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts index eddea69a27e..07db2b65dc0 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts @@ -20,11 +20,12 @@ import { SerializedElementNode, } from 'lexical'; -import {PIXEL_VALUE_REG_EXP} from './constants'; +import {extractStyleMapFromElement, sizeToPixels, StyleMap} from "../../utils/dom"; export type SerializedTableRowNode = Spread< { - height?: number; + styles: Record, + height?: number, }, SerializedElementNode >; @@ -33,13 +34,17 @@ export type SerializedTableRowNode = Spread< export class TableRowNode extends ElementNode { /** @internal */ __height?: number; + /** @internal */ + __styles: StyleMap = new Map(); static getType(): string { return 'tablerow'; } static clone(node: TableRowNode): TableRowNode { - return new TableRowNode(node.__height, node.__key); + const newNode = new TableRowNode(node.__key); + newNode.__styles = new Map(node.__styles); + return newNode; } static importDOM(): DOMConversionMap | null { @@ -52,20 +57,24 @@ export class TableRowNode extends ElementNode { } static importJSON(serializedNode: SerializedTableRowNode): TableRowNode { - return $createTableRowNode(serializedNode.height); + const node = $createTableRowNode(); + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + + return node; } - constructor(height?: number, key?: NodeKey) { + constructor(key?: NodeKey) { super(key); - this.__height = height; } exportJSON(): SerializedTableRowNode { return { ...super.exportJSON(), - ...(this.getHeight() && {height: this.getHeight()}), type: 'tablerow', version: 1, + styles: Object.fromEntries(this.__styles), + height: this.__height || 0, }; } @@ -76,6 +85,10 @@ export class TableRowNode extends ElementNode { element.style.height = `${this.__height}px`; } + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + addClassNamesToElement(element, config.theme.tableRow); return element; @@ -85,6 +98,16 @@ export class TableRowNode extends ElementNode { return true; } + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + setHeight(height: number): number | null | undefined { const self = this.getWritable(); self.__height = height; @@ -96,7 +119,8 @@ export class TableRowNode extends ElementNode { } updateDOM(prevNode: TableRowNode): boolean { - return prevNode.__height !== this.__height; + return prevNode.__height !== this.__height + || prevNode.__styles !== this.__styles; } canBeEmpty(): false { @@ -109,18 +133,21 @@ export class TableRowNode extends ElementNode { } export function $convertTableRowElement(domNode: Node): DOMConversionOutput { - const domNode_ = domNode as HTMLTableCellElement; - let height: number | undefined = undefined; + const rowNode = $createTableRowNode(); + const domNode_ = domNode as HTMLElement; + + const height = sizeToPixels(domNode_.style.height); + rowNode.setHeight(height); - if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) { - height = parseFloat(domNode_.style.height); + if (domNode instanceof HTMLElement) { + rowNode.setStyles(extractStyleMapFromElement(domNode)); } - return {node: $createTableRowNode(height)}; + return {node: rowNode}; } -export function $createTableRowNode(height?: number): TableRowNode { - return $applyNodeReplacement(new TableRowNode(height)); +export function $createTableRowNode(): TableRowNode { + return $applyNodeReplacement(new TableRowNode()); } export function $isTableRowNode( diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 812cccc0d25..6c3317c5dfa 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -438,59 +438,6 @@ export function applyTableHandlers( ), ); - 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, 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 285d587bf5f..5dbf03d9e9c 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts @@ -39,10 +39,9 @@ describe('LexicalTableRowNode tests', () => { ``, ); - const rowHeight = 36; - const rowWithCustomHeightNode = $createTableRowNode(36); + const rowWithCustomHeightNode = $createTableRowNode(); expect(rowWithCustomHeightNode.createDOM(editorConfig).outerHTML).toBe( - ``, + ``, ); }); }); diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts deleted file mode 100644 index 793302cfec4..00000000000 --- a/resources/js/wysiwyg/nodes/custom-table-cell.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { - $createParagraphNode, - $isElementNode, - $isLineBreakNode, - $isTextNode, - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - LexicalEditor, - LexicalNode, - Spread -} from "lexical"; - -import { - $createTableCellNode, - $isTableCellNode, - SerializedTableCellNode, - TableCellHeaderStates, - TableCellNode -} 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; - alignment: CommonBlockAlignment; -}, SerializedTableCellNode> - -export class CustomTableCellNode extends TableCellNode { - __styles: StyleMap = new Map; - __alignment: CommonBlockAlignment = ''; - - 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); - cellNode.__alignment = node.__alignment; - return cellNode; - } - - clearWidth(): void { - const self = this.getWritable(); - self.__width = undefined; - } - - getStyles(): StyleMap { - const self = this.getLatest(); - return new Map(self.__styles); - } - - setStyles(styles: StyleMap): void { - const self = this.getWritable(); - 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; - 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); - } - - if (this.__alignment) { - element.classList.add('align-' + this.__alignment); - } - - return element; - } - - updateDOM(prevNode: CustomTableCellNode): boolean { - return super.updateDOM(prevNode) - || this.__styles !== prevNode.__styles - || this.__alignment !== prevNode.__alignment; - } - - 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 { - element - }; - } - - static importJSON(serializedNode: SerializedCustomTableCellNode): CustomTableCellNode { - const node = $createCustomTableCellNode( - serializedNode.headerState, - serializedNode.colSpan, - serializedNode.width, - ); - - node.setStyles(new Map(Object.entries(serializedNode.styles))); - node.setAlignment(serializedNode.alignment); - - return node; - } - - exportJSON(): SerializedCustomTableCellNode { - return { - ...super.exportJSON(), - type: 'custom-table-cell', - styles: Object.fromEntries(this.__styles), - alignment: this.__alignment, - }; - } -} - -function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput { - const output = $convertTableCellNodeElement(domNode); - - if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { - output.node.setStyles(extractStyleMapFromElement(domNode)); - output.node.setAlignment(extractAlignmentFromElement(domNode)); - } - - 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 = TableCellHeaderStates.NO_STATUS, - 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/custom-table-row.ts b/resources/js/wysiwyg/nodes/custom-table-row.ts deleted file mode 100644 index f4702f36dd5..00000000000 --- a/resources/js/wysiwyg/nodes/custom-table-row.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - DOMConversionMap, - DOMConversionOutput, - EditorConfig, - LexicalNode, - Spread -} from "lexical"; - -import { - SerializedTableRowNode, - TableRowNode -} from "@lexical/table"; -import {NodeKey} from "lexical/LexicalNode"; -import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; - -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(extractStyleMapFromElement(domNode)); - } - - 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/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts deleted file mode 100644 index c25c06c6515..00000000000 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ /dev/null @@ -1,166 +0,0 @@ -import {SerializedTableNode, TableNode} from "@lexical/table"; -import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; - -import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom"; -import {getTableColumnWidths} from "../utils/tables"; -import { - CommonBlockAlignment, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - -export type SerializedCustomTableNode = Spread, -}, SerializedTableNode>, SerializedCommonBlockNode> - -export class CustomTableNode extends TableNode { - __id: string = ''; - __colWidths: string[] = []; - __styles: StyleMap = new Map; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-table'; - } - - 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; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - setColWidths(widths: string[]) { - const self = this.getWritable(); - self.__colWidths = widths; - } - - getColWidths(): string[] { - const self = this.getLatest(); - 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); - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - updateElementWithCommonBlockProps(dom, this); - - 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); - } - - for (const [name, value] of this.__styles.entries()) { - dom.style.setProperty(name, value); - } - - return dom; - } - - updateDOM(): boolean { - return true; - } - - exportJSON(): SerializedCustomTableNode { - return { - ...super.exportJSON(), - type: 'custom-table', - version: 1, - id: this.__id, - colWidths: this.__colWidths, - styles: Object.fromEntries(this.__styles), - alignment: this.__alignment, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomTableNode): CustomTableNode { - const node = $createCustomTableNode(); - deserializeCommonBlockNode(serializedNode, node); - node.setColWidths(serializedNode.colWidths); - node.setStyles(new Map(Object.entries(serializedNode.styles))); - return node; - } - - static importDOM(): DOMConversionMap|null { - return { - table(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - const node = $createCustomTableNode(); - setCommonBlockPropsFromElement(element, node); - - const colWidths = getTableColumnWidths(element as HTMLTableElement); - node.setColWidths(colWidths); - node.setStyles(extractStyleMapFromElement(element)); - - return {node}; - }, - priority: 1, - }; - }, - }; - } -} - -export function $createCustomTableNode(): CustomTableNode { - return new CustomTableNode(); -} - -export function $isCustomTableNode(node: LexicalNode | null | undefined): node is CustomTableNode { - return node instanceof CustomTableNode; -} diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 7e0ce9daf2b..03213e2629a 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -11,14 +11,11 @@ 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"; import {HorizontalRuleNode} from "./horizontal-rule"; import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; import {EditorUiContext} from "../ui/framework/core"; import {MediaNode} from "./media"; -import {CustomTableCellNode} from "./custom-table-cell"; -import {CustomTableRowNode} from "./custom-table-row"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; @@ -32,9 +29,9 @@ export function getNodesForPageEditor(): (KlassConstructor | QuoteNode, ListNode, ListItemNode, - CustomTableNode, - CustomTableRowNode, - CustomTableCellNode, + TableNode, + TableRowNode, + TableCellNode, ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, SummaryNode, @@ -43,30 +40,6 @@ export function getNodesForPageEditor(): (KlassConstructor | MediaNode, // TODO - Alignment ParagraphNode, LinkNode, - { - replace: TableNode, - with(node: TableNode) { - return new CustomTableNode(); - } - }, - { - replace: TableRowNode, - with(node: TableRowNode) { - return new CustomTableRowNode(); - } - }, - { - 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/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index fc4196f0a08..2e4883d88e6 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -9,17 +9,15 @@ 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 {$isCustomTableNode} from "../../../nodes/custom-table"; import { $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, - $insertTableRow__EXPERIMENTAL, - $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, + $insertTableRow__EXPERIMENTAL, $isTableCellNode, + $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"; import {$showCellPropertiesForm, $showRowPropertiesForm, $showTablePropertiesForm} from "../forms/tables"; import { $clearTableFormatting, @@ -27,7 +25,6 @@ import { $getTableRowsFromSelection, $mergeTableCellsInSelection } from "../../../utils/tables"; -import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; import { $copySelectedColumnsToClipboard, $copySelectedRowsToClipboard, @@ -41,7 +38,7 @@ import { } from "../../../utils/table-copy-paste"; const neverActive = (): boolean => false; -const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); +const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isTableCellNode); export const table: EditorBasicButtonDefinition = { label: 'Table', @@ -54,7 +51,7 @@ export const tableProperties: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const table = $getTableFromSelection($getSelection()); - if ($isCustomTableNode(table)) { + if ($isTableNode(table)) { $showTablePropertiesForm(table, context); } }); @@ -68,13 +65,13 @@ export const clearTableFormatting: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { return; } const table = $getParentOfType(cell, $isTableNode); - if ($isCustomTableNode(table)) { + if ($isTableNode(table)) { $clearTableFormatting(table); } }); @@ -88,13 +85,13 @@ export const resizeTableToContents: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { return; } - const table = $getParentOfType(cell, $isCustomTableNode); - if ($isCustomTableNode(table)) { + const table = $getParentOfType(cell, $isTableNode); + if ($isTableNode(table)) { $clearTableSizes(table); } }); @@ -108,7 +105,7 @@ export const deleteTable: EditorButtonDefinition = { icon: deleteIcon, action(context: EditorUiContext) { context.editor.update(() => { - const table = $getNodeFromSelection($getSelection(), $isCustomTableNode); + const table = $getNodeFromSelection($getSelection(), $isTableNode); if (table) { table.remove(); } @@ -169,7 +166,7 @@ export const rowProperties: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const rows = $getTableRowsFromSelection($getSelection()); - if ($isCustomTableRowNode(rows[0])) { + if ($isTableRowNode(rows[0])) { $showRowPropertiesForm(rows[0], context); } }); @@ -350,8 +347,8 @@ export const cellProperties: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if ($isCustomTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if ($isTableCellNode(cell)) { $showCellPropertiesForm(cell, context); } }); @@ -387,7 +384,7 @@ export const splitCell: EditorButtonDefinition = { }, isActive: neverActive, isDisabled(selection) { - const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as TableCellNode|null; + const cell = $getNodeFromSelection(selection, $isTableCellNode) 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 5a41c85b3da..3cfe9592ccb 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -5,7 +5,6 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; import {EditorFormModal} from "../../framework/modals"; import {$getSelection, ElementFormatType} from "lexical"; import { @@ -16,8 +15,8 @@ import { $setTableCellColumnWidth } from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; -import {CustomTableRowNode} from "../../../nodes/custom-table-row"; -import {CustomTableNode} from "../../../nodes/custom-table"; +import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; +import {CommonBlockAlignment} from "../../../nodes/_common"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -62,14 +61,14 @@ const alignmentInput: EditorSelectFormFieldDefinition = { } }; -export function $showCellPropertiesForm(cell: CustomTableCellNode, context: EditorUiContext): EditorFormModal { +export function $showCellPropertiesForm(cell: TableCellNode, context: EditorUiContext): EditorFormModal { const styles = cell.getStyles(); const modalForm = context.manager.createModal('cell_properties'); modalForm.show({ width: $getTableCellColumnWidth(context.editor, cell), height: styles.get('height') || '', type: cell.getTag(), - h_align: cell.getFormatType(), + h_align: cell.getAlignment(), v_align: styles.get('vertical-align') || '', border_width: styles.get('border-width') || '', border_style: styles.get('border-style') || '', @@ -89,7 +88,7 @@ export const cellProperties: EditorFormDefinition = { $setTableCellColumnWidth(cell, width); cell.updateTag(formData.get('type')?.toString() || ''); - cell.setFormat((formData.get('h_align')?.toString() || '') as ElementFormatType); + cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment); const styles = cell.getStyles(); styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); @@ -172,7 +171,7 @@ export const cellProperties: EditorFormDefinition = { ], }; -export function $showRowPropertiesForm(row: CustomTableRowNode, context: EditorUiContext): EditorFormModal { +export function $showRowPropertiesForm(row: TableRowNode, context: EditorUiContext): EditorFormModal { const styles = row.getStyles(); const modalForm = context.manager.createModal('row_properties'); modalForm.show({ @@ -216,7 +215,7 @@ export const rowProperties: EditorFormDefinition = { ], }; -export function $showTablePropertiesForm(table: CustomTableNode, context: EditorUiContext): EditorFormModal { +export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal { const styles = table.getStyles(); const modalForm = context.manager.createModal('table_properties'); modalForm.show({ @@ -229,7 +228,7 @@ export function $showTablePropertiesForm(table: CustomTableNode, context: Editor border_color: styles.get('border-color') || '', background_color: styles.get('background-color') || '', // caption: '', TODO - align: table.getFormatType(), + align: table.getAlignment(), }); return modalForm; } @@ -253,12 +252,12 @@ export const tableProperties: EditorFormDefinition = { styles.set('background-color', formData.get('background_color')?.toString() || ''); table.setStyles(styles); - table.setFormat(formData.get('align') as ElementFormatType); + table.setAlignment(formData.get('align') as CommonBlockAlignment); const cellPadding = (formData.get('cell_padding')?.toString() || ''); if (cellPadding) { const cellPaddingFormatted = formatSizeValue(cellPadding); - $forEachTableCell(table, (cell: CustomTableCellNode) => { + $forEachTableCell(table, (cell: TableCellNode) => { const styles = cell.getStyles(); styles.set('padding', cellPaddingFormatted); cell.setStyles(styles); diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts index 30ff3abc56c..6f026ca1895 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -1,6 +1,5 @@ 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"; @@ -78,7 +77,7 @@ export class EditorTableCreator extends EditorUiElement { const colWidths = Array(columns).fill(targetColWidth + 'px'); this.getContext().editor.update(() => { - const table = $createTableNodeWithDimensions(rows, columns, false) as CustomTableNode; + const table = $createTableNodeWithDimensions(rows, columns, false); table.setColWidths(colWidths); $insertNewBlockNodeAtSelection(table); }); diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index 37f1b6f01ee..4256fdafc65 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -1,7 +1,6 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; -import {CustomTableNode} from "../../../nodes/custom-table"; -import {TableRowNode} from "@lexical/table"; +import {TableNode, TableRowNode} from "@lexical/table"; import {el} from "../../../utils/dom"; import {$getTableColumnWidth, $setTableColumnWidth} from "../../../utils/tables"; @@ -148,7 +147,7 @@ class TableResizer { _this.editor.update(() => { const table = $getNearestNodeFromDOMNode(parentTable); - if (table instanceof CustomTableNode) { + if (table instanceof TableNode) { const originalWidth = $getTableColumnWidth(_this.editor, table, cellIndex); const newWidth = Math.max(originalWidth + change, 10); $setTableColumnWidth(table, cellIndex, newWidth); 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 f631fb804a5..d3d8925505f 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -1,12 +1,12 @@ 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 @@ -25,12 +25,12 @@ class TableSelectionHandler { } protected init() { - this.unregisterMutationListener = this.editor.registerMutationListener(CustomTableNode, (mutations) => { + this.unregisterMutationListener = this.editor.registerMutationListener(TableNode, (mutations) => { for (const [nodeKey, mutation] of mutations) { if (mutation === 'created') { this.editor.getEditorState().read(() => { - const tableNode = $getNodeByKey(nodeKey); - if ($isCustomTableNode(tableNode)) { + const tableNode = $getNodeByKey(nodeKey); + if ($isTableNode(tableNode)) { this.initializeTableNode(tableNode); } }); diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts index 12c19b0fb80..1e024e4c758 100644 --- a/resources/js/wysiwyg/utils/table-copy-paste.ts +++ b/resources/js/wysiwyg/utils/table-copy-paste.ts @@ -1,24 +1,28 @@ import {NodeClipboard} from "./node-clipboard"; -import {CustomTableRowNode} from "../nodes/custom-table-row"; 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 { + $createTableCellNode, + $isTableCellNode, + $isTableSelection, + TableCellNode, + TableNode, + TableRowNode +} from "@lexical/table"; import {$getNodeFromSelection} from "./selection"; -const rowClipboard: NodeClipboard = new NodeClipboard(); +const rowClipboard: NodeClipboard = new NodeClipboard(); export function isRowClipboardEmpty(): boolean { return rowClipboard.size() === 0; } -export function validateRowsToCopy(rows: CustomTableRowNode[]): void { +export function validateRowsToCopy(rows: TableRowNode[]): void { let commonRowSize: number|null = null; for (const row of rows) { - const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + const cells = row.getChildren().filter(n => $isTableCellNode(n)); let rowSize = 0; for (const cell of cells) { rowSize += cell.getColSpan() || 1; @@ -35,10 +39,10 @@ export function validateRowsToCopy(rows: CustomTableRowNode[]): void { } } -export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: CustomTableNode): void { +export function validateRowsToPaste(rows: TableRowNode[], targetTable: TableNode): void { const tableColCount = (new TableMap(targetTable)).columnCount; for (const row of rows) { - const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + const cells = row.getChildren().filter(n => $isTableCellNode(n)); let rowSize = 0; for (const cell of cells) { rowSize += cell.getColSpan() || 1; @@ -49,7 +53,7 @@ export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: Cus } while (rowSize < tableColCount) { - row.append($createCustomTableCellNode()); + row.append($createTableCellNode()); rowSize++; } } @@ -98,11 +102,11 @@ export function $pasteClipboardRowsAfter(editor: LexicalEditor): void { } } -const columnClipboard: NodeClipboard[] = []; +const columnClipboard: NodeClipboard[] = []; -function setColumnClipboard(columns: CustomTableCellNode[][]): void { +function setColumnClipboard(columns: TableCellNode[][]): void { const newClipboards = columns.map(cells => { - const clipboard = new NodeClipboard(); + const clipboard = new NodeClipboard(); clipboard.set(...cells); return clipboard; }); @@ -122,9 +126,9 @@ function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|nul return {from: shape.fromX, to: shape.toX}; } - const cell = $getNodeFromSelection(selection, $isCustomTableCellNode); + const cell = $getNodeFromSelection(selection, $isTableCellNode); const table = $getTableFromSelection(selection); - if (!$isCustomTableCellNode(cell) || !table) { + if (!$isTableCellNode(cell) || !table) { return null; } @@ -137,7 +141,7 @@ function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|nul return {from: range.fromX, to: range.toX}; } -function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTableNode): CustomTableCellNode[][] { +function $getTableColumnCellsFromSelection(range: TableRange, table: TableNode): TableCellNode[][] { const map = new TableMap(table); const columns = []; for (let x = range.from; x <= range.to; x++) { @@ -148,7 +152,7 @@ function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTable return columns; } -function validateColumnsToCopy(columns: CustomTableCellNode[][]): void { +function validateColumnsToCopy(columns: TableCellNode[][]): void { let commonColSize: number|null = null; for (const cells of columns) { @@ -203,7 +207,7 @@ export function $copySelectedColumnsToClipboard(): void { setColumnClipboard(columns); } -function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: CustomTableNode) { +function validateColumnsToPaste(columns: TableCellNode[][], targetTable: TableNode) { const tableRowCount = (new TableMap(targetTable)).rowCount; for (const cells of columns) { let colSize = 0; @@ -216,7 +220,7 @@ function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: C } while (colSize < tableRowCount) { - cells.push($createCustomTableCellNode()); + cells.push($createTableCellNode()); colSize++; } } diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts index 607deffe1ca..dfe21b936f6 100644 --- a/resources/js/wysiwyg/utils/table-map.ts +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -1,6 +1,4 @@ -import {CustomTableNode} from "../nodes/custom-table"; -import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; -import {$isTableRowNode} from "@lexical/table"; +import {$isTableCellNode, $isTableRowNode, TableCellNode, TableNode} from "@lexical/table"; export type CellRange = { fromX: number; @@ -16,15 +14,15 @@ export class TableMap { // 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[] = []; + cells: TableCellNode[] = []; - constructor(table: CustomTableNode) { + constructor(table: TableNode) { this.buildCellMap(table); } - protected buildCellMap(table: CustomTableNode) { - const rowsAndCells: CustomTableCellNode[][] = []; - const setCell = (x: number, y: number, cell: CustomTableCellNode) => { + protected buildCellMap(table: TableNode) { + const rowsAndCells: TableCellNode[][] = []; + const setCell = (x: number, y: number, cell: TableCellNode) => { if (typeof rowsAndCells[y] === 'undefined') { rowsAndCells[y] = []; } @@ -36,7 +34,7 @@ export class TableMap { 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)); + const cellNodes = rowNode.getChildren().filter(c => $isTableCellNode(c)); let targetColIndex: number = 0; for (let cellIndex = 0; cellIndex < cellNodes.length; cellIndex++) { const cellNode = cellNodes[cellIndex]; @@ -60,7 +58,7 @@ export class TableMap { this.columnCount = Math.max(...rowsAndCells.map(r => r.length)); const cells = []; - let lastCell: CustomTableCellNode = rowsAndCells[0][0]; + let lastCell: TableCellNode = 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]) { @@ -75,7 +73,7 @@ export class TableMap { this.cells = cells; } - public getCellAtPosition(x: number, y: number): CustomTableCellNode { + public getCellAtPosition(x: number, y: number): TableCellNode { 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}`); @@ -84,13 +82,13 @@ export class TableMap { return this.cells[position]; } - public getCellsInRange(range: CellRange): CustomTableCellNode[] { + public getCellsInRange(range: CellRange): TableCellNode[] { 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(); + const cells = new Set(); for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) { @@ -101,7 +99,7 @@ export class TableMap { return [...cells.values()]; } - public getCellsInColumn(columnIndex: number): CustomTableCellNode[] { + public getCellsInColumn(columnIndex: number): TableCellNode[] { return this.getCellsInRange({ fromX: columnIndex, toX: columnIndex, @@ -110,7 +108,7 @@ export class TableMap { }); } - public getRangeForCell(cell: CustomTableCellNode): CellRange|null { + public getRangeForCell(cell: TableCellNode): CellRange|null { let range: CellRange|null = null; const cellKey = cell.getKey(); diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index aa8ec89ba77..ed947ddcdcb 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -1,15 +1,19 @@ 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"; +import { + $isTableCellNode, + $isTableNode, + $isTableRowNode, + $isTableSelection, TableCellNode, TableNode, + TableRowNode, + TableSelection, +} from "@lexical/table"; 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; +function $getTableFromCell(cell: TableCellNode): TableNode|null { + return $getParentOfType(cell, $isTableNode) as TableNode|null; } export function getTableColumnWidths(table: HTMLTableElement): string[] { @@ -55,7 +59,7 @@ function extractWidthFromElement(element: HTMLElement): string { return width || ''; } -export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number|string): void { +export function $setTableColumnWidth(node: TableNode, columnIndex: number, width: number|string): void { const rows = node.getChildren() as TableRowNode[]; let maxCols = 0; for (const row of rows) { @@ -78,7 +82,7 @@ export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, node.setColWidths(colWidths); } -export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number { +export function $getTableColumnWidth(editor: LexicalEditor, node: TableNode, columnIndex: number): number { const colWidths = node.getColWidths(); if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) { return Number(colWidths[columnIndex].replace('px', '')); @@ -97,14 +101,14 @@ export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNod return 0; } -function $getCellColumnIndex(node: CustomTableCellNode): number { +function $getCellColumnIndex(node: TableCellNode): number { const row = node.getParent(); if (!$isTableRowNode(row)) { return -1; } let index = 0; - const cells = row.getChildren(); + const cells = row.getChildren(); for (const cell of cells) { let colSpan = cell.getColSpan() || 1; index += colSpan; @@ -116,7 +120,7 @@ function $getCellColumnIndex(node: CustomTableCellNode): number { return index - 1; } -export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: string): void { +export function $setTableCellColumnWidth(cell: TableCellNode, width: string): void { const table = $getTableFromCell(cell) const index = $getCellColumnIndex(cell); @@ -125,7 +129,7 @@ export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: strin } } -export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTableCellNode): string { +export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellNode): string { const table = $getTableFromCell(cell) const index = $getCellColumnIndex(cell); if (!table) { @@ -136,13 +140,13 @@ export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTabl return (widths.length > index) ? widths[index] : ''; } -export function $getTableCellsFromSelection(selection: BaseSelection|null): CustomTableCellNode[] { +export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[] { if ($isTableSelection(selection)) { const nodes = selection.getNodes(); - return nodes.filter(n => $isCustomTableCellNode(n)); + return nodes.filter(n => $isTableCellNode(n)); } - const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode; + const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode; return cell ? [cell] : []; } @@ -193,12 +197,12 @@ export function $mergeTableCellsInSelection(selection: TableSelection): void { firstCell.setRowSpan(newHeight); } -export function $getTableRowsFromSelection(selection: BaseSelection|null): CustomTableRowNode[] { +export function $getTableRowsFromSelection(selection: BaseSelection|null): TableRowNode[] { const cells = $getTableCellsFromSelection(selection); - const rowsByKey: Record = {}; + const rowsByKey: Record = {}; for (const cell of cells) { const row = cell.getParent(); - if ($isCustomTableRowNode(row)) { + if ($isTableRowNode(row)) { rowsByKey[row.getKey()] = row; } } @@ -206,28 +210,28 @@ export function $getTableRowsFromSelection(selection: BaseSelection|null): Custo return Object.values(rowsByKey); } -export function $getTableFromSelection(selection: BaseSelection|null): CustomTableNode|null { +export function $getTableFromSelection(selection: BaseSelection|null): TableNode|null { const cells = $getTableCellsFromSelection(selection); if (cells.length === 0) { return null; } - const table = $getParentOfType(cells[0], $isCustomTableNode); - if ($isCustomTableNode(table)) { + const table = $getParentOfType(cells[0], $isTableNode); + if ($isTableNode(table)) { return table; } return null; } -export function $clearTableSizes(table: CustomTableNode): void { +export function $clearTableSizes(table: TableNode): void { table.setColWidths([]); // TODO - Extra form things once table properties and extra things // are supported for (const row of table.getChildren()) { - if (!$isCustomTableRowNode(row)) { + if (!$isTableRowNode(row)) { continue; } @@ -236,7 +240,7 @@ export function $clearTableSizes(table: CustomTableNode): void { rowStyles.delete('width'); row.setStyles(rowStyles); - const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + const cells = row.getChildren().filter(c => $isTableCellNode(c)); for (const cell of cells) { const cellStyles = cell.getStyles(); cellStyles.delete('height'); @@ -247,23 +251,21 @@ export function $clearTableSizes(table: CustomTableNode): void { } } -export function $clearTableFormatting(table: CustomTableNode): void { +export function $clearTableFormatting(table: TableNode): void { table.setColWidths([]); table.setStyles(new Map); for (const row of table.getChildren()) { - if (!$isCustomTableRowNode(row)) { + if (!$isTableRowNode(row)) { continue; } row.setStyles(new Map); - row.setFormat(''); - const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + const cells = row.getChildren().filter(c => $isTableCellNode(c)); for (const cell of cells) { cell.setStyles(new Map); cell.clearWidth(); - cell.setFormat(''); } } } @@ -272,14 +274,14 @@ export function $clearTableFormatting(table: CustomTableNode): void { * 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 { +export function $forEachTableCell(table: TableNode, callback: (c: TableCellNode) => void|false): void { outer: for (const row of table.getChildren()) { - if (!$isCustomTableRowNode(row)) { + if (!$isTableRowNode(row)) { continue; } const cells = row.getChildren(); for (const cell of cells) { - if (!$isCustomTableCellNode(cell)) { + if (!$isTableCellNode(cell)) { return; } const result = callback(cell); @@ -290,10 +292,10 @@ export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTa } } -export function $getCellPaddingForTable(table: CustomTableNode): string { +export function $getCellPaddingForTable(table: TableNode): string { let padding: string|null = null; - $forEachTableCell(table, (cell: CustomTableCellNode) => { + $forEachTableCell(table, (cell: TableCellNode) => { const cellPadding = cell.getStyles().get('padding') || '' if (padding === null) { padding = cellPadding; From 9fdd100f2d989ddc30d9cbad4dadb1b98096edaf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 4 Dec 2024 18:53:59 +0000 Subject: [PATCH 247/800] Lexical: Reorganised custom node code into lexical codebase Also cleaned up old unused imports. --- resources/js/wysiwyg/index.ts | 2 +- .../js/wysiwyg/lexical/core/LexicalMutations.ts | 1 - .../js/wysiwyg/lexical/core/LexicalReconciler.ts | 12 ++++++------ .../core/__tests__/unit/LexicalEditor.test.ts | 1 - .../lexical/core/nodes/CommonBlockNode.ts | 13 ++++++++++--- .../lexical/core/nodes/LexicalElementNode.ts | 4 ++-- .../lexical/core/nodes/LexicalParagraphNode.ts | 6 +++--- .../nodes/__tests__/unit/LexicalTabNode.test.ts | 10 +--------- .../nodes/__tests__/unit/LexicalTextNode.test.ts | 2 -- .../_common.ts => lexical/core/nodes/common.ts} | 13 +++---------- .../js/wysiwyg/lexical/list/LexicalListNode.ts | 2 +- resources/js/wysiwyg/lexical/readme.md | 2 +- .../rich-text/LexicalCalloutNode.ts} | 4 ++-- .../rich-text/LexicalCodeBlockNode.ts} | 6 +++--- .../rich-text/LexicalDetailsNode.ts} | 4 ++-- .../rich-text/LexicalDiagramNode.ts} | 4 ++-- .../lexical/rich-text/LexicalHeadingNode.ts | 7 +++---- .../rich-text/LexicalHorizontalRuleNode.ts} | 0 .../rich-text/LexicalImageNode.ts} | 4 ++-- .../rich-text/LexicalMediaNode.ts} | 8 ++++---- .../lexical/rich-text/LexicalQuoteNode.ts | 10 ++++------ .../lexical/table/LexicalTableCellNode.ts | 2 +- .../js/wysiwyg/lexical/table/LexicalTableNode.ts | 10 +++++----- .../table/LexicalTableSelectionHelpers.ts | 2 -- .../js/wysiwyg/{nodes/index.ts => nodes.ts} | 16 ++++++++-------- .../js/wysiwyg/services/drop-paste-handling.ts | 2 +- .../js/wysiwyg/services/keyboard-handling.ts | 4 ++-- resources/js/wysiwyg/ui/decorators/code-block.ts | 4 ++-- resources/js/wysiwyg/ui/decorators/diagram.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/alignments.ts | 4 ++-- .../wysiwyg/ui/defaults/buttons/block-formats.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 15 +++++++-------- .../js/wysiwyg/ui/defaults/forms/objects.ts | 9 ++++----- resources/js/wysiwyg/ui/defaults/forms/tables.ts | 4 ++-- .../wysiwyg/ui/framework/helpers/node-resizer.ts | 6 +++--- resources/js/wysiwyg/ui/framework/manager.ts | 2 +- resources/js/wysiwyg/utils/diagrams.ts | 4 ++-- resources/js/wysiwyg/utils/formats.ts | 4 ++-- resources/js/wysiwyg/utils/images.ts | 2 +- resources/js/wysiwyg/utils/nodes.ts | 2 +- resources/js/wysiwyg/utils/selection.ts | 2 +- 41 files changed, 97 insertions(+), 116 deletions(-) rename resources/js/wysiwyg/{nodes/_common.ts => lexical/core/nodes/common.ts} (89%) rename resources/js/wysiwyg/{nodes/callout.ts => lexical/rich-text/LexicalCalloutNode.ts} (98%) rename resources/js/wysiwyg/{nodes/code-block.ts => lexical/rich-text/LexicalCodeBlockNode.ts} (97%) rename resources/js/wysiwyg/{nodes/details.ts => lexical/rich-text/LexicalDetailsNode.ts} (97%) rename resources/js/wysiwyg/{nodes/diagram.ts => lexical/rich-text/LexicalDiagramNode.ts} (97%) rename resources/js/wysiwyg/{nodes/horizontal-rule.ts => lexical/rich-text/LexicalHorizontalRuleNode.ts} (100%) rename resources/js/wysiwyg/{nodes/image.ts => lexical/rich-text/LexicalImageNode.ts} (98%) rename resources/js/wysiwyg/{nodes/media.ts => lexical/rich-text/LexicalMediaNode.ts} (97%) rename resources/js/wysiwyg/{nodes/index.ts => nodes.ts} (78%) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index c4403773bf2..9066b402f33 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,4 +1,4 @@ -import {$getSelection, createEditor, CreateEditorArgs, isCurrentlyReadOnlyMode, LexicalEditor} from 'lexical'; +import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts index c24dc9ebb3c..80645205679 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts @@ -16,7 +16,6 @@ import { $getSelection, $isDecoratorNode, $isElementNode, - $isRangeSelection, $isTextNode, $setSelection, } from '.'; diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index 7843027d713..fccf1ae23a8 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -29,12 +29,12 @@ import { 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 { 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 f3c6f710509..28a203100c4 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -47,7 +47,6 @@ import { import invariant from 'lexical/shared/invariant'; import { - $createTestDecoratorNode, $createTestElementNode, $createTestInlineElementNode, createTestEditor, diff --git a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts index bf4fc08ca60..572c9448b0c 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts @@ -1,8 +1,15 @@ -import {ElementNode} from "./LexicalElementNode"; -import {CommonBlockAlignment, SerializedCommonBlockNode} from "../../../nodes/_common"; +import {ElementNode, type SerializedElementNode} from "./LexicalElementNode"; +import {CommonBlockAlignment, CommonBlockInterface} from "./common"; +import {Spread} from "lexical"; -export class CommonBlockNode extends ElementNode { +export type SerializedCommonBlockNode = Spread<{ + id: string; + alignment: CommonBlockAlignment; + inset: number; +}, SerializedElementNode> + +export class CommonBlockNode extends ElementNode implements CommonBlockInterface { __id: string = ''; __alignment: CommonBlockAlignment = ''; __inset: number = 0; diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 002d825d6ea..9624af67e7e 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -19,8 +19,8 @@ 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 { diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts index 6517d939eda..f6f57c91c75 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -29,10 +29,10 @@ import { import {$isTextNode} from './LexicalTextNode'; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../../nodes/_common"; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +} from "./common"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type SerializedParagraphNode = Spread< { 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 9831114340d..d1ba5359752 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 @@ -10,21 +10,14 @@ import { $insertDataTransferForPlainText, $insertDataTransferForRichText, } from '@lexical/clipboard'; -import {$createListItemNode, $createListNode} from '@lexical/list'; -import {registerRichText} from '@lexical/rich-text'; import { $createParagraphNode, - $createRangeSelection, $createTabNode, - $createTextNode, $getRoot, $getSelection, $insertNodes, - $isElementNode, $isRangeSelection, - $isTextNode, - $setSelection, - KEY_TAB_COMMAND, + } from 'lexical'; import { @@ -32,7 +25,6 @@ import { initializeUnitTest, invariant, } from '../../../__tests__/utils'; -import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; describe('LexicalTabNode tests', () => { initializeUnitTest((testEnv) => { 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 b1ea099ac1e..c54760ff242 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 @@ -41,9 +41,7 @@ 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: '', diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/lexical/core/nodes/common.ts similarity index 89% rename from resources/js/wysiwyg/nodes/_common.ts rename to resources/js/wysiwyg/lexical/core/nodes/common.ts index 71849bb4589..eac9c829595 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/common.ts @@ -1,18 +1,11 @@ -import {LexicalNode, Spread} from "lexical"; -import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; -import {el, sizeToPixels} from "../utils/dom"; +import {sizeToPixels} from "../../../utils/dom"; +import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; 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; - inset: number; -}, SerializedElementNode> - export interface NodeHasAlignment { readonly __alignment: CommonBlockAlignment; setAlignment(alignment: CommonBlockAlignment): void; @@ -37,7 +30,7 @@ export interface NodeHasDirection { getDirection(): EditorNodeDirection; } -interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset, NodeHasDirection {} +export interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset, NodeHasDirection {} export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment { const textAlignStyle: string = element.style.textAlign || ''; diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index 138c895e6b8..6edf0d64a2e 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -36,7 +36,7 @@ import { updateChildrenListItemValue, } from './formatList'; import {$getListDepth, $wrapInListItem} from './utils'; -import {extractDirectionFromElement} from "../../nodes/_common"; +import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedListNode = Spread< { diff --git a/resources/js/wysiwyg/lexical/readme.md b/resources/js/wysiwyg/lexical/readme.md index 31db8fab1ca..24440ec8077 100644 --- a/resources/js/wysiwyg/lexical/readme.md +++ b/resources/js/wysiwyg/lexical/readme.md @@ -9,4 +9,4 @@ Only components used, or intended to be used, were copied in at this point. 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 +Files may have since been added or modified with changes 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/nodes/callout.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalCalloutNode.ts similarity index 98% rename from resources/js/wysiwyg/nodes/callout.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalCalloutNode.ts index cfe32ec854c..6f97ba751ff 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalCalloutNode.ts @@ -11,10 +11,10 @@ import type {EditorConfig} from "lexical/LexicalEditor"; import type {RangeSelection} from "lexical/LexicalSelection"; import { CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "./_common"; +} from "lexical/nodes/common"; +import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success'; diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/code-block.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts index 76c17197111..cbe69184887 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts @@ -8,9 +8,9 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {CodeEditor} from "../../components"; -import {el} from "../utils/dom"; +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/details.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/details.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index de87696f345..178b0d9531d 100644 --- a/resources/js/wysiwyg/nodes/details.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -8,8 +8,8 @@ import { EditorConfig, } from 'lexical'; -import {el} from "../utils/dom"; -import {extractDirectionFromElement} from "./_common"; +import {el} from "../../utils/dom"; +import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedDetailsNode = Spread<{ id: string; diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/diagram.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts index bd37b200c80..e69f97848ac 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts @@ -8,8 +8,8 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {el} from "../utils/dom"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; +import {el} from "../../utils/dom"; export type SerializedDiagramNode = Spread<{ id: string; diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts index 0f30263ba0e..30563c09d5a 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts @@ -11,16 +11,15 @@ import { type NodeKey, type ParagraphNode, type RangeSelection, - type SerializedElementNode, type Spread } from "lexical"; import {addClassNamesToElement} from "@lexical/utils"; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../nodes/_common"; +} from "lexical/nodes/common"; export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; diff --git a/resources/js/wysiwyg/nodes/horizontal-rule.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalHorizontalRuleNode.ts similarity index 100% rename from resources/js/wysiwyg/nodes/horizontal-rule.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalHorizontalRuleNode.ts diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts similarity index 98% rename from resources/js/wysiwyg/nodes/image.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts index b6d362b62c9..9f42ad73204 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts @@ -6,8 +6,8 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; -import {$selectSingleNode} from "../utils/selection"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "lexical/nodes/common"; +import {$selectSingleNode} from "../../utils/selection"; import {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; export interface ImageNodeOptions { diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/media.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts index 64fe8f77b4d..a675665ac14 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts @@ -8,14 +8,14 @@ import { } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el, setOrRemoveAttribute, sizeToPixels} from "../utils/dom"; +import {el, setOrRemoveAttribute, sizeToPixels} from "../../utils/dom"; import { CommonBlockAlignment, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "./_common"; -import {$selectSingleNode} from "../utils/selection"; +} from "lexical/nodes/common"; +import {$selectSingleNode} from "../../utils/selection"; +import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts index 53caca80115..f0d97fe9806 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts @@ -5,22 +5,20 @@ import { type DOMConversionOutput, type DOMExportOutput, type EditorConfig, - ElementNode, isHTMLElement, type LexicalEditor, LexicalNode, type NodeKey, type ParagraphNode, - type RangeSelection, - SerializedElementNode + type RangeSelection } from "lexical"; import {addClassNamesToElement} from "@lexical/utils"; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../nodes/_common"; +} from "lexical/nodes/common"; export type SerializedQuoteNode = SerializedCommonBlockNode; diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 72676b9bacb..1fc6b42bbeb 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -29,7 +29,7 @@ import { } from 'lexical'; import {extractStyleMapFromElement, StyleMap} from "../../utils/dom"; -import {CommonBlockAlignment, extractAlignmentFromElement} from "../../nodes/_common"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "lexical/nodes/common"; export const TableCellHeaderStates = { BOTH: 3, diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index ab163005370..9443747a6f7 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -15,25 +15,25 @@ import { LexicalEditor, LexicalNode, NodeKey, - SerializedElementNode, Spread, + Spread, } from 'lexical'; import {addClassNamesToElement, isHTMLElement} from '@lexical/utils'; import { $applyNodeReplacement, $getNearestNodeFromDOMNode, - ElementNode, + } from 'lexical'; import {$isTableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; import {getTable} from './LexicalTableSelectionHelpers'; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../nodes/_common"; +} from "lexical/nodes/common"; import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom"; import {getTableColumnWidths} from "../../utils/tables"; diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 6c3317c5dfa..e098a21e498 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -16,7 +16,6 @@ import type { } from './LexicalTableSelection'; import type { BaseSelection, - ElementFormatType, LexicalCommand, LexicalEditor, LexicalNode, @@ -50,7 +49,6 @@ import { DELETE_LINE_COMMAND, DELETE_WORD_COMMAND, FOCUS_COMMAND, - FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes.ts similarity index 78% rename from resources/js/wysiwyg/nodes/index.ts rename to resources/js/wysiwyg/nodes.ts index 03213e2629a..eb836bdce02 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -1,4 +1,4 @@ -import {CalloutNode} from './callout'; +import {CalloutNode} from '@lexical/rich-text/LexicalCalloutNode'; import { ElementNode, KlassConstructor, @@ -7,15 +7,15 @@ import { ParagraphNode } from "lexical"; import {LinkNode} from "@lexical/link"; -import {ImageNode} from "./image"; -import {DetailsNode, SummaryNode} from "./details"; +import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; -import {HorizontalRuleNode} from "./horizontal-rule"; -import {CodeBlockNode} from "./code-block"; -import {DiagramNode} from "./diagram"; -import {EditorUiContext} from "../ui/framework/core"; -import {MediaNode} from "./media"; +import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; +import {CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; +import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; +import {EditorUiContext} from "./ui/framework/core"; +import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index e049d5e7c9e..2ee831d74fc 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -8,7 +8,7 @@ import { import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; import {Clipboard} from "../../services/clipboard"; -import {$createImageNode} from "../nodes/image"; +import {$createImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$createLinkNode} from "@lexical/link"; import {EditorImageData, uploadImageFile} from "../utils/images"; import {EditorUiContext} from "../ui/framework/core"; diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 5f7f41ef02c..6a1345fac6d 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -10,8 +10,8 @@ import { LexicalEditor, LexicalNode } from "lexical"; -import {$isImageNode} from "../nodes/image"; -import {$isMediaNode} from "../nodes/media"; +import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; import {$getNearestNodeBlockParent} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index 37d3df588c3..daae32e1982 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 {$isDecoratorNode, BaseSelection} from "lexical"; +import {$openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; +import {BaseSelection} from "lexical"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 44d332939e8..d53bcb482ec 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,7 +1,7 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {BaseSelection} from "lexical"; -import {DiagramNode} from "../../nodes/diagram"; +import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; import {$openDrawingEditorForNode} from "../../utils/diagrams"; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index f0f46ddc674..98edf44b3b9 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -9,9 +9,9 @@ import ltrIcon from "@icons/editor/direction-ltr.svg"; import rtlIcon from "@icons/editor/direction-rtl.svg"; import { $getBlockElementNodesInSelection, - $selectionContainsAlignment, $selectionContainsDirection, $selectSingleNode, $toggleSelection, getLastSelection + $selectionContainsAlignment, $selectionContainsDirection, $selectSingleNode, getLastSelection } from "../../../utils/selection"; -import {CommonBlockAlignment} from "../../../nodes/_common"; +import {CommonBlockAlignment} from "lexical/nodes/common"; import {nodeHasAlignment} from "../../../utils/nodes"; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index e0d1e7077fa..b36fd1c4f0c 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -1,4 +1,4 @@ -import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; +import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index fd95f9f35d2..f9c029ff14c 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -2,27 +2,26 @@ import {EditorButtonDefinition} from "../../framework/buttons"; import linkIcon from "@icons/editor/link.svg"; import {EditorUiContext} from "../../framework/core"; import { - $createTextNode, $getRoot, $getSelection, $insertNodes, BaseSelection, - ElementNode, isCurrentlyReadOnlyMode + ElementNode } from "lexical"; 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 {$isImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode"; import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; -import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; +import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; import codeBlockIcon from "@icons/editor/code-block.svg"; -import {$isCodeBlockNode} from "../../../nodes/code-block"; +import {$isCodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; -import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; +import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; 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 {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import { $getNodeFromSelection, $insertNewBlockNodeAtSelection, diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 228566d442e..f00a08bb5f5 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -5,11 +5,10 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {$createNodeSelection, $createTextNode, $getSelection, $insertNodes, $setSelection} from "lexical"; -import {$isImageNode, ImageNode} from "../../../nodes/image"; -import {$createLinkNode, $isLinkNode, LinkNode} from "@lexical/link"; -import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; -import {$insertNodeToNearestRoot} from "@lexical/utils"; +import {$createNodeSelection, $getSelection, $insertNodes, $setSelection} from "lexical"; +import {$isImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {LinkNode} from "@lexical/link"; +import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; import {EditorFormModal} from "../../framework/modals"; import {EditorActionField} from "../../framework/blocks/action-field"; diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 3cfe9592ccb..63fa24c800f 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -6,7 +6,7 @@ import { } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; import {EditorFormModal} from "../../framework/modals"; -import {$getSelection, ElementFormatType} from "lexical"; +import {$getSelection} from "lexical"; import { $forEachTableCell, $getCellPaddingForTable, $getTableCellColumnWidth, @@ -16,7 +16,7 @@ import { } from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; -import {CommonBlockAlignment} from "../../../nodes/_common"; +import {CommonBlockAlignment} from "lexical/nodes/common"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', diff --git a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts index 2e4f2939ca2..fa8ff48be56 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts @@ -1,10 +1,10 @@ import {BaseSelection, LexicalNode,} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {el} from "../../../utils/dom"; -import {$isImageNode} from "../../../nodes/image"; +import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {EditorUiContext} from "../core"; -import {NodeHasSize} from "../../../nodes/_common"; -import {$isMediaNode} from "../../../nodes/media"; +import {NodeHasSize} from "lexical/nodes/common"; +import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode { return $isImageNode(node) || $isMediaNode(node); diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 7c0975da7e7..185cd5dccd0 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,7 +1,7 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; -import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; +import {BaseSelection, LexicalEditor} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts index fb5543005c2..ffd8e603b99 100644 --- a/resources/js/wysiwyg/utils/diagrams.ts +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -1,8 +1,8 @@ -import {$getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; +import {$insertNodes, LexicalEditor, LexicalNode} from "lexical"; import {HttpError} from "../../services/http"; import {EditorUiContext} from "../ui/framework/core"; import * as DrawIO from "../../services/drawio"; -import {$createDiagramNode, DiagramNode} from "../nodes/diagram"; +import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import {ImageManager} from "../../components"; import {EditorImageData} from "./images"; import {$getNodeFromSelection, getLastSelection} from "./selection"; diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 1be802ebf1c..a5f06f147d2 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -14,8 +14,8 @@ import { $toggleSelectionBlockNodeType, getLastSelection } from "./selection"; -import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; -import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; +import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts index 2c13427d967..85bae18e5e5 100644 --- a/resources/js/wysiwyg/utils/images.ts +++ b/resources/js/wysiwyg/utils/images.ts @@ -1,5 +1,5 @@ import {ImageManager} from "../../components"; -import {$createImageNode} from "../nodes/image"; +import {$createImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$createLinkNode, LinkNode} from "@lexical/link"; export type EditorImageData = { diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 97634f96b4e..b5cc789550c 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -11,7 +11,7 @@ import { import {LexicalNodeMatcher} from "../nodes"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; -import {NodeHasAlignment, NodeHasInset} from "../nodes/_common"; +import {NodeHasAlignment, NodeHasInset} from "lexical/nodes/common"; import {$findMatchingParent} from "@lexical/utils"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 02838eba034..28e729e92ae 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -16,7 +16,7 @@ import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; -import {CommonBlockAlignment} from "../nodes/_common"; +import {CommonBlockAlignment} from "lexical/nodes/common"; const lastSelectionByEditor = new WeakMap; From d00cf6e1bac6165354f3b8a3a440f948941fe011 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 4 Dec 2024 20:03:05 +0000 Subject: [PATCH 248/800] Lexical: Updated tests for node changes --- .../__tests__/unit/HTMLCopyAndPaste.test.ts | 4 +- .../core/__tests__/unit/LexicalEditor.test.ts | 18 +- .../__tests__/unit/LexicalEditorState.test.ts | 12 +- .../unit/LexicalSerialization.test.ts | 4 +- .../__tests__/unit/LexicalElementNode.test.ts | 2 - .../unit/LexicalParagraphNode.test.ts | 21 +- .../__tests__/unit/LexicalRootNode.test.ts | 2 - .../unit/LexicalHeadlessEditor.test.ts | 2 +- .../html/__tests__/unit/LexicalHtml.test.ts | 4 +- .../unit/LexicalListItemNode.test.ts | 554 ++++++++---------- .../__tests__/unit/LexicalListNode.test.ts | 19 - .../__tests__/unit/LexicalSelection.test.ts | 6 +- .../__tests__/unit/LexicalTableNode.test.ts | 5 +- .../unit/LexicalTableSelection.test.ts | 8 +- .../unit/LexicalEventHelpers.test.ts | 12 +- .../unit/LexicalUtilsSplitNode.test.ts | 4 +- ...exlcaiUtilsInsertNodeToNearestRoot.test.ts | 4 +- 17 files changed, 301 insertions(+), 380 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 534663a54b4..cdad252c92d 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -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

                                `, }, { - 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 28a203100c4..5d763291972 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -974,7 +974,7 @@ describe('LexicalEditor tests', () => { 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}}'; + '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"type":"root","version":1}}'; init(); const contentEditable = editor.getRootElement(); editor.setEditable(editable); @@ -1047,8 +1047,6 @@ describe('LexicalEditor tests', () => { __cachedText: null, __dir: null, __first: paragraphKey, - __format: 0, - __indent: 0, __key: 'root', __last: paragraphKey, __next: null, @@ -1059,10 +1057,11 @@ describe('LexicalEditor tests', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ + "__alignment": "", __dir: null, __first: textKey, - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: paragraphKey, __last: textKey, __next: null, @@ -1070,7 +1069,6 @@ describe('LexicalEditor tests', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); @@ -1129,8 +1127,6 @@ describe('LexicalEditor tests', () => { __cachedText: null, __dir: null, __first: paragraphKey, - __format: 0, - __indent: 0, __key: 'root', __last: paragraphKey, __next: null, @@ -1141,10 +1137,11 @@ describe('LexicalEditor tests', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ + "__alignment": "", __dir: null, __first: textKey, - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: paragraphKey, __last: textKey, __next: null, @@ -1152,7 +1149,6 @@ describe('LexicalEditor tests', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); 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 38ecf03bce2..97b63450323 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts @@ -54,8 +54,6 @@ describe('LexicalEditorState tests', () => { __cachedText: 'foo', __dir: null, __first: '1', - __format: 0, - __indent: 0, __key: 'root', __last: '1', __next: null, @@ -66,10 +64,11 @@ describe('LexicalEditorState tests', () => { __type: 'root', }); expect(paragraph).toEqual({ + "__alignment": "", __dir: null, __first: '2', - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: '1', __last: '2', __next: null, @@ -77,7 +76,6 @@ describe('LexicalEditorState tests', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); @@ -113,7 +111,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":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"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,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"root","version":1}}`, ); }); @@ -140,8 +138,6 @@ describe('LexicalEditorState tests', () => { __cachedText: '', __dir: null, __first: null, - __format: 0, - __indent: 0, __key: 'root', __last: null, __next: null, 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 81eff674a99..e08547c13b6 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -107,7 +107,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":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}}`; + const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"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,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"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,"type":"paragraph","version":1,"id":"","alignment":"","inset":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,"type":"paragraph","version":1,"id":"","alignment":"","inset":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,"type":"paragraph","version":1,"id":"","alignment":"","inset":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,"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,"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,"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,"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,"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,"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,"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,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"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,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`; expect(stringifiedEditorState).toBe(expectedStringifiedEditorState); @@ -116,7 +116,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":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}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"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,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"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,"type":"paragraph","version":1,"id":"","alignment":"","inset":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,"type":"paragraph","version":1,"id":"","alignment":"","inset":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,"type":"paragraph","version":1,"id":"","alignment":"","inset":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,"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,"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,"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,"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,"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,"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,"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,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"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,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`, ); }); }); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts index fb5c98f8a6c..6e3a3861ae5 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts @@ -84,8 +84,6 @@ describe('LexicalElementNode tests', () => { expect(node.exportJSON()).toStrictEqual({ children: [], direction: null, - format: '', - indent: 0, type: 'test_block', version: 1, }); 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 index 1f7c4cfc3a7..7bf485ca1d4 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts @@ -48,11 +48,11 @@ describe('LexicalParagraphNode tests', () => { // logic is in place in the corresponding importJSON method // to accomodate these changes. expect(node.exportJSON()).toStrictEqual({ + alignment: '', children: [], direction: null, - format: '', - indent: 0, - textFormat: 0, + id: '', + inset: 0, textStyle: '', type: 'paragraph', version: 1, @@ -127,6 +127,21 @@ describe('LexicalParagraphNode tests', () => { }); }); + test('id is supported', async () => { + const {editor} = testEnv; + let paragraphNode: ParagraphNode; + + await editor.update(() => { + paragraphNode = new ParagraphNode(); + paragraphNode.setId('testid') + $getRoot().append(paragraphNode); + }); + + expect(testEnv.innerHTML).toBe( + '


                                ', + ); + }); + test('$createParagraphNode()', async () => { const {editor} = testEnv; 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 index 123cb3375d6..7ef370f4ba7 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts @@ -77,8 +77,6 @@ describe('LexicalRootNode tests', () => { expect(node.exportJSON()).toStrictEqual({ children: [], direction: null, - format: '', - indent: 0, type: 'root', version: 1, }); 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 c4dedd47d13..122516d45b6 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 a4e2d231389..e5064121ab5 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -176,7 +176,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

                                Hello world!

                                ', + '

                                Hello world!

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

                                Hello world!

                                ', + '

                                Hello world!

                                ', ); }); }); 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 523c7eb126e..567714bcd9f 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` -
                              • +
                              • `, ); @@ -90,7 +90,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                              • +
                              • `, ); const newListItemNode = new ListItemNode(); @@ -106,7 +106,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                              • +
                              • `, ); }); @@ -125,7 +125,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                              • +
                              • `, ); const nestedListNode = new ListNode('bullet', 1); @@ -142,7 +142,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
                              • +
                              • `, ); }); @@ -486,53 +486,43 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A -
                                  • -
                                  -
                                • -
                                • - x -
                                • -
                                • - B -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A +
                                  • +
                                  +
                                • +
                                • + x +
                                • +
                                • + B +
                                • +
                                `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A -
                                  • -
                                  -
                                • -
                                • - B -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A +
                                  • +
                                  +
                                • +
                                • + B +
                                • +
                                `, ); }); @@ -566,53 +556,43 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • - A -
                                • -
                                • - x -
                                • -
                                • -
                                    -
                                  • - B -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • + A +
                                • +
                                • + x +
                                • +
                                • +
                                    +
                                  • + B +
                                  • +
                                  +
                                • +
                                `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • - A -
                                • -
                                • -
                                    -
                                  • - B -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • + A +
                                • +
                                • +
                                    +
                                  • + B +
                                  • +
                                  +
                                • +
                                `, ); }); @@ -650,57 +630,47 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A -
                                  • -
                                  -
                                • -
                                • - x -
                                • -
                                • -
                                    -
                                  • - B -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A +
                                  • +
                                  +
                                • +
                                • + x +
                                • +
                                • +
                                    +
                                  • + B +
                                  • +
                                  +
                                • +
                                `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A -
                                  • -
                                  • - B -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A +
                                  • +
                                  • + B +
                                  • +
                                  +
                                • +
                                `, ); }); @@ -746,71 +716,61 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A1 -
                                  • -
                                  • -
                                      -
                                    • - A2 -
                                    • -
                                    -
                                  • -
                                  -
                                • -
                                • - x -
                                • -
                                • -
                                    -
                                  • - B -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A1 +
                                  • +
                                  • +
                                      +
                                    • + A2 +
                                    • +
                                    +
                                  • +
                                  +
                                • +
                                • + x +
                                • +
                                • +
                                    +
                                  • + B +
                                  • +
                                  +
                                • +
                                `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A1 -
                                  • -
                                  • -
                                      -
                                    • - A2 -
                                    • -
                                    -
                                  • -
                                  • - B -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A1 +
                                  • +
                                  • +
                                      +
                                    • + A2 +
                                    • +
                                    +
                                  • +
                                  • + B +
                                  • +
                                  +
                                • +
                                `, ); }); @@ -856,71 +816,61 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A -
                                  • -
                                  -
                                • -
                                • - x -
                                • -
                                • -
                                    -
                                  • -
                                      -
                                    • - B1 -
                                    • -
                                    -
                                  • -
                                  • - B2 -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A +
                                  • +
                                  +
                                • +
                                • + x +
                                • +
                                • +
                                    +
                                  • +
                                      +
                                    • + B1 +
                                    • +
                                    +
                                  • +
                                  • + B2 +
                                  • +
                                  +
                                • +
                                `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A -
                                  • -
                                  • -
                                      -
                                    • - B1 -
                                    • -
                                    -
                                  • -
                                  • - B2 -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A +
                                  • +
                                  • +
                                      +
                                    • + B1 +
                                    • +
                                    +
                                  • +
                                  • + B2 +
                                  • +
                                  +
                                • +
                                `, ); }); @@ -974,81 +924,71 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A1 -
                                  • -
                                  • -
                                      -
                                    • - A2 -
                                    • -
                                    -
                                  • -
                                  -
                                • -
                                • - x -
                                • -
                                • -
                                    -
                                  • -
                                      -
                                    • - B1 -
                                    • -
                                    -
                                  • -
                                  • - B2 -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A1 +
                                  • +
                                  • +
                                      +
                                    • + A2 +
                                    • +
                                    +
                                  • +
                                  +
                                • +
                                • + x +
                                • +
                                • +
                                    +
                                  • +
                                      +
                                    • + B1 +
                                    • +
                                    +
                                  • +
                                  • + B2 +
                                  • +
                                  +
                                • +
                                `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
                                -
                                  -
                                • -
                                    -
                                  • - A1 -
                                  • -
                                  • -
                                      -
                                    • - A2 -
                                    • -
                                    • - B1 -
                                    • -
                                    -
                                  • -
                                  • - B2 -
                                  • -
                                  -
                                • -
                                -
                                +
                                  +
                                • +
                                    +
                                  • + A1 +
                                  • +
                                  • +
                                      +
                                    • + A2 +
                                    • +
                                    • + B1 +
                                    • +
                                    +
                                  • +
                                  • + B2 +
                                  • +
                                  +
                                • +
                                `, ); }); 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 497e096b1c2..8c7729dbff1 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts @@ -294,24 +294,5 @@ describe('LexicalListNode tests', () => { 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/selection/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts index 466be7498de..cc09d1735cc 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -2605,7 +2605,7 @@ describe('LexicalSelection tests', () => { 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}}', + '{"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,"type":"heading","version":1,"id":"","alignment":"","inset":0,"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,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"}],"direction":null,"type":"root","version":1}}', ); }); }); @@ -2695,7 +2695,7 @@ describe('LexicalSelection tests', () => { }); }); expect(element.innerHTML).toStrictEqual( - `

                                1

                                1.1

                                `, + `

                                1

                                • 1.1

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

                                1.1

                                `, + `
                                • 1.1

                                `, ); }); }); 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 6848e55325d..2879decdad7 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -113,9 +113,8 @@ describe('LexicalTableNode tests', () => { $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

                                `, + `

                                Hello there

                                General Kenobi!

                                Lexical is nice


                                `, ); }); @@ -136,7 +135,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 d5b85ccaaa0..1548216cf1a 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts @@ -101,8 +101,6 @@ describe('table selection', () => { __cachedText: null, __dir: null, __first: paragraphKey, - __format: 0, - __indent: 0, __key: 'root', __last: paragraphKey, __next: null, @@ -113,10 +111,11 @@ describe('table selection', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ + __alignment: "", __dir: null, __first: textKey, - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: paragraphKey, __last: textKey, __next: null, @@ -124,7 +123,6 @@ describe('table selection', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); 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 d76937ed606..cae4f1aae68 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -176,7 +176,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                • Other side
                                • I must have called
                                ', + '
                                • Other side
                                • I must have called
                                ', inputs: [ pasteHTML( `
                                • Other side
                                • I must have called
                                `, @@ -186,7 +186,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
                                `, @@ -266,7 +266,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                • Hello
                                • from the other
                                • side
                                ', + '
                                • Hello
                                • from the other
                                • side
                                ', inputs: [ pasteHTML( `
                                • Hello
                                • from the other
                                • side
                                `, @@ -276,7 +276,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                • Hello
                                • from the other
                                • side
                                ', + '
                                • Hello
                                • from the other
                                • side
                                ', inputs: [ pasteHTML( `
                                • Hello
                                • from the other
                                • side
                                `, @@ -611,7 +611,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                1. 1
                                  2

                                2. 3
                                ', + '
                                1. 1
                                  2

                                2. 3
                                ', inputs: [ pasteHTML('
                                1. 1
                                  2
                                2. 3
                                '), ], @@ -647,7 +647,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
                                1. 1

                                2. 3
                                ', + '
                                1. 1

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

                                2. 3
                                ')], name: 'only br in a li', }, 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 a70200d6349..54cd8b54f1e 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts @@ -82,10 +82,10 @@ describe('LexicalUtils#splitNode', () => { expectedHtml: '
                                  ' + '
                                • Before
                                • ' + - '
                                  • Hello
                                • ' + + '
                                  • Hello
                                • ' + '
                                ' + '
                                  ' + - '
                                  • world
                                • ' + + '
                                  • 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 fb04e628413..8c31496de5a 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts @@ -56,11 +56,11 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { expectedHtml: '
                                  ' + '
                                • Before
                                • ' + - '
                                  • Hello
                                • ' + + '
                                  • Hello
                                • ' + '
                                ' + '' + '
                                  ' + - '
                                  • world
                                • ' + + '
                                  • world
                                • ' + '
                                • After
                                • ' + '
                                ', initialHtml: From 55d074f1a5922fa966a428d9f03e0a1e405e33aa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Dec 2024 11:32:15 +0000 Subject: [PATCH 249/800] Attachment API: Fixed error when name not provided in update Fixes #5353 --- app/Uploads/AttachmentService.php | 8 +++++--- tests/Api/AttachmentsApiTest.php | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 033f2334104..dabd537292f 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -116,16 +116,18 @@ public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId */ public function updateFile(Attachment $attachment, array $requestData): Attachment { - $attachment->name = $requestData['name']; - $link = trim($requestData['link'] ?? ''); + if (isset($requestData['name'])) { + $attachment->name = $requestData['name']; + } + $link = trim($requestData['link'] ?? ''); if (!empty($link)) { if (!$attachment->external) { $this->deleteFileInStorage($attachment); $attachment->external = true; $attachment->extension = ''; } - $attachment->path = $requestData['link']; + $attachment->path = $link; } $attachment->save(); diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php index b03f280ac67..b2346587924 100644 --- a/tests/Api/AttachmentsApiTest.php +++ b/tests/Api/AttachmentsApiTest.php @@ -12,7 +12,7 @@ class AttachmentsApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/attachments'; + protected string $baseEndpoint = '/api/attachments'; public function test_index_endpoint_returns_expected_book() { @@ -302,6 +302,23 @@ public function test_update_link_attachment_to_file() } public function test_update_file_attachment_to_link() + { + $this->actingAsApiAdmin(); + $page = $this->entities->page(); + $attachment = $this->createAttachmentForPage($page); + + $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", [ + 'link' => 'https://example.com/donkey', + ]); + + $resp->assertStatus(200); + $this->assertDatabaseHas('attachments', [ + 'id' => $attachment->id, + 'path' => 'https://example.com/donkey', + ]); + } + + public function test_update_does_not_require_name() { $this->actingAsApiAdmin(); $page = $this->entities->page(); From 617b2edea0bfe8ebb5556aca6b0b6aa371232cca Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Dec 2024 13:07:39 +0000 Subject: [PATCH 250/800] JS: Updated packages, fixed lint issue Left eslint as old due to eslint-config-airbnb-base not yet being comptible. Some SASS deprecations to solve. --- dev/docs/development.md | 2 +- package-lock.json | 812 +++++++++++++++++++++++-------- package.json | 40 +- resources/js/components/index.ts | 2 +- 4 files changed, 642 insertions(+), 214 deletions(-) diff --git a/dev/docs/development.md b/dev/docs/development.md index 0324140f8bd..ea3e692a198 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -3,7 +3,7 @@ All development on BookStack is currently done on the `development` branch. When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements: -* [Node.js](https://nodejs.org/en/) v18.0+ +* [Node.js](https://nodejs.org/en/) v20.0+ ## Building CSS & JavaScript Assets diff --git a/package-lock.json b/package-lock.json index 68ec85fff8b..1912106c2b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,46 +4,47 @@ "requires": true, "packages": { "": { + "name": "bookstack", "dependencies": { - "@codemirror/commands": "^6.3.2", - "@codemirror/lang-css": "^6.2.1", - "@codemirror/lang-html": "^6.4.7", - "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.2.3", + "@codemirror/lang-markdown": "^6.3.1", "@codemirror/lang-php": "^6.0.1", - "@codemirror/lang-xml": "^6.0.2", - "@codemirror/language": "^6.9.3", - "@codemirror/legacy-modes": "^6.3.3", - "@codemirror/state": "^6.3.3", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.10.6", + "@codemirror/legacy-modes": "^6.4.2", + "@codemirror/state": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.22.2", - "@lezer/highlight": "^1.2.0", + "@codemirror/view": "^6.35.2", + "@lezer/highlight": "^1.2.1", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^29.5.13", + "@types/jest": "^29.5.14", "codemirror": "^6.0.1", "idb-keyval": "^6.2.1", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.5.1", - "sortablejs": "^1.15.1" + "snabbdom": "^3.6.2", + "sortablejs": "^1.15.6" }, "devDependencies": { - "@lezer/generator": "^1.5.1", + "@lezer/generator": "^1.7.2", "chokidar-cli": "^3.0", - "esbuild": "^0.23.0", - "eslint": "^8.55.0", + "esbuild": "^0.24.0", + "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.29.0", + "eslint-plugin-import": "^2.31.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", + "sass": "^1.82.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "5.6.*" + "typescript": "5.7.*" } }, "node_modules/@ampproject/remapping": { @@ -627,9 +628,10 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.2.tgz", - "integrity": "sha512-Fq7eWOl1Rcbrfn6jD8FPCj9Auaxdm5nIK5RYOeW7ughnd/rY5AmPg6b+CfsG39ZHdwiwe8lde3q8uR7CF5S0yQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -638,9 +640,10 @@ } }, "node_modules/@codemirror/lang-css": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", - "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -689,9 +692,10 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz", - "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.1.tgz", + "integrity": "sha512-y3sSPuQjBKZQbQwe3ZJKrSW6Silyl9PnrU/Mf0m2OQgIlPoSYTtOvEL7xs94SVMkb8f4x+SQFnzXPdX4Wk2lsg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", @@ -728,9 +732,10 @@ } }, "node_modules/@codemirror/language": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", - "integrity": "sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==", + "version": "6.10.6", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.6.tgz", + "integrity": "sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -741,9 +746,10 @@ } }, "node_modules/@codemirror/legacy-modes": { - "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==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.2.tgz", + "integrity": "sha512-HsvWu08gOIIk303eZQCal4H4t65O/qp1V4ul4zVa3MHK5FJ0gz3qz3O55FIkm+aQUcshUOjBx38t2hPiJwW5/g==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" } @@ -769,9 +775,13 @@ } }, "node_modules/@codemirror/state": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", - "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } }, "node_modules/@codemirror/theme-one-dark": { "version": "6.1.2", @@ -785,9 +795,10 @@ } }, "node_modules/@codemirror/view": { - "version": "6.34.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.34.1.tgz", - "integrity": "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==", + "version": "6.35.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.35.2.tgz", + "integrity": "sha512-u04R04XFCYCNaHoNRr37WUUAfnxKPwPdqV+370NiO6i85qB1J/qCD/WbbMJsyJfRWhXIJXAe2BG/oTzAggqv4A==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -817,13 +828,14 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", + "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -833,13 +845,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", + "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -849,13 +862,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", + "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -865,13 +879,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", + "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -881,13 +896,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", + "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -897,13 +913,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", + "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -913,13 +930,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", + "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -929,13 +947,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", + "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -945,13 +964,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", + "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -961,13 +981,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", + "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -977,13 +998,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", + "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -993,13 +1015,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", + "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1009,13 +1032,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", + "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1025,13 +1049,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", + "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1041,13 +1066,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", + "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1057,13 +1083,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", + "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1073,13 +1100,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", + "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1089,13 +1117,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", + "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1105,13 +1134,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", + "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1121,13 +1151,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", + "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1137,13 +1168,14 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", + "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -1153,13 +1185,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", + "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1169,13 +1202,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", + "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1185,13 +1219,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", + "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1216,10 +1251,11 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1229,6 +1265,7 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1252,6 +1289,7 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1262,6 +1300,7 @@ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1289,7 +1328,8 @@ "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 + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -1738,10 +1778,11 @@ } }, "node_modules/@lezer/generator": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.1.tgz", - "integrity": "sha512-MgPJN9Si+ccxzXl3OAmCeZuUKw4XiPl4y664FX/hnnyG9CTqUPq65N3/VGPA2jD23D7QgMTtNqflta+cPN+5mQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.2.tgz", + "integrity": "sha512-CwgULPOPPmH54tv4gki18bElLCdJ1+FBC+nGVSVD08vFWDsMjS7KEjNTph9JOypDnet90ujN3LzQiW3CyVODNQ==", "dev": true, + "license": "MIT", "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" @@ -1825,11 +1866,18 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.0.tgz", + "integrity": "sha512-0YSzy7M9mBiK+h1m33rD8vZOfaO8leG6CY3+Q+1Lig86snkc8OAHQVAdndmnXMWJlVIH6S7fSZVVcjLcq6OH1A==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -1843,6 +1891,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -1852,6 +1901,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -1860,6 +1910,316 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2009,9 +2369,10 @@ } }, "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==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -2067,10 +2428,11 @@ "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 + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==", + "dev": true, + "license": "ISC" }, "node_modules/abab": { "version": "2.0.6", @@ -2080,10 +2442,11 @@ "dev": true }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -2106,6 +2469,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2139,6 +2503,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2857,10 +3222,11 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3063,6 +3429,20 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3094,6 +3474,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -3307,11 +3688,12 @@ } }, "node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", + "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -3319,30 +3701,30 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "@esbuild/aix-ppc64": "0.24.0", + "@esbuild/android-arm": "0.24.0", + "@esbuild/android-arm64": "0.24.0", + "@esbuild/android-x64": "0.24.0", + "@esbuild/darwin-arm64": "0.24.0", + "@esbuild/darwin-x64": "0.24.0", + "@esbuild/freebsd-arm64": "0.24.0", + "@esbuild/freebsd-x64": "0.24.0", + "@esbuild/linux-arm": "0.24.0", + "@esbuild/linux-arm64": "0.24.0", + "@esbuild/linux-ia32": "0.24.0", + "@esbuild/linux-loong64": "0.24.0", + "@esbuild/linux-mips64el": "0.24.0", + "@esbuild/linux-ppc64": "0.24.0", + "@esbuild/linux-riscv64": "0.24.0", + "@esbuild/linux-s390x": "0.24.0", + "@esbuild/linux-x64": "0.24.0", + "@esbuild/netbsd-x64": "0.24.0", + "@esbuild/openbsd-arm64": "0.24.0", + "@esbuild/openbsd-x64": "0.24.0", + "@esbuild/sunos-x64": "0.24.0", + "@esbuild/win32-arm64": "0.24.0", + "@esbuild/win32-ia32": "0.24.0", + "@esbuild/win32-x64": "0.24.0" } }, "node_modules/escalade": { @@ -3391,7 +3773,9 @@ "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3508,10 +3892,11 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", - "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -3521,7 +3906,7 @@ "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.9.0", + "eslint-module-utils": "^2.12.0", "hasown": "^2.0.2", "is-core-module": "^2.15.1", "is-glob": "^4.0.3", @@ -3530,13 +3915,14 @@ "object.groupby": "^1.0.3", "object.values": "^1.2.0", "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import/node_modules/debug": { @@ -3565,6 +3951,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -3605,6 +3992,7 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -3647,6 +4035,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -3723,7 +4112,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", @@ -3742,6 +4132,7 @@ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -3760,6 +4151,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -3829,6 +4221,7 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -3839,10 +4232,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true, + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.3", @@ -4036,6 +4430,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -4083,7 +4478,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/has-bigints": { "version": "1.0.2", @@ -4247,21 +4643,24 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "dev": true, + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -4530,6 +4929,7 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5434,6 +5834,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5502,7 +5903,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-better-errors": { "version": "1.0.2", @@ -5520,7 +5922,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5545,6 +5948,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -5888,6 +6292,14 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5997,10 +6409,11 @@ "dev": true }, "node_modules/npm-run-all/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, + "license": "MIT", "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -6304,6 +6717,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -6614,7 +7028,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/react-is": { "version": "18.3.1", @@ -6729,6 +7144,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -6747,6 +7163,7 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -6758,6 +7175,7 @@ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -6787,6 +7205,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -6833,13 +7252,14 @@ "dev": true }, "node_modules/sass": { - "version": "1.79.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.3.tgz", - "integrity": "sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.82.0.tgz", + "integrity": "sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", - "immutable": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -6847,6 +7267,9 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/sass/node_modules/chokidar": { @@ -7013,9 +7436,10 @@ } }, "node_modules/sortablejs": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz", - "integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", @@ -7311,7 +7735,8 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", @@ -7528,6 +7953,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -7609,10 +8035,11 @@ } }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7690,6 +8117,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 42531194ec3..08af25d140f 100644 --- a/package.json +++ b/package.json @@ -19,45 +19,45 @@ "test": "jest" }, "devDependencies": { - "@lezer/generator": "^1.5.1", + "@lezer/generator": "^1.7.2", "chokidar-cli": "^3.0", - "esbuild": "^0.23.0", - "eslint": "^8.55.0", + "esbuild": "^0.24.0", + "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.29.0", + "eslint-plugin-import": "^2.31.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", + "sass": "^1.82.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "5.6.*" + "typescript": "5.7.*" }, "dependencies": { - "@codemirror/commands": "^6.3.2", - "@codemirror/lang-css": "^6.2.1", - "@codemirror/lang-html": "^6.4.7", - "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.2.3", + "@codemirror/lang-markdown": "^6.3.1", "@codemirror/lang-php": "^6.0.1", - "@codemirror/lang-xml": "^6.0.2", - "@codemirror/language": "^6.9.3", - "@codemirror/legacy-modes": "^6.3.3", - "@codemirror/state": "^6.3.3", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.10.6", + "@codemirror/legacy-modes": "^6.4.2", + "@codemirror/state": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.22.2", - "@lezer/highlight": "^1.2.0", + "@codemirror/view": "^6.35.2", + "@lezer/highlight": "^1.2.1", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^29.5.13", + "@types/jest": "^29.5.14", "codemirror": "^6.0.1", "idb-keyval": "^6.2.1", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.5.1", - "sortablejs": "^1.15.1" + "snabbdom": "^3.6.2", + "sortablejs": "^1.15.6" }, "eslintConfig": { "root": true, diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 24e60bd97f2..12c991a51d8 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -30,7 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; -export {LoadingButton} from './loading-button.ts'; +export {LoadingButton} from './loading-button'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; From 8ec26e80834841dd66ae6123dad8855d8509120f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Dec 2024 13:25:35 +0000 Subject: [PATCH 251/800] SASS: Updated to use modules and address deprecations Changes the name of our spacing variables due to the prefixing -/_ meaning private in the use of new "use" rather than include. All now modular too, so all variables/mixins are accessed via their package. Also renamed variables file to vars for simpler/cleaner access/writing. eg. '$-m' is now 'vars.$m' --- resources/sass/_blocks.scss | 97 ++++----- resources/sass/_buttons.scss | 33 ++-- resources/sass/_codemirror.scss | 11 +- resources/sass/_colors.scss | 8 +- resources/sass/_components.scss | 187 +++++++++--------- resources/sass/_content.scss | 65 +++--- resources/sass/_editor.scss | 27 +-- resources/sass/_forms.scss | 123 ++++++------ resources/sass/_header.scss | 97 ++++----- resources/sass/_html.scss | 7 +- resources/sass/_layout.scss | 87 ++++---- resources/sass/_lists.scss | 173 ++++++++-------- resources/sass/_pages.scss | 65 +++--- resources/sass/_spacing.scss | 6 +- resources/sass/_tables.scss | 21 +- resources/sass/_text.scss | 67 ++++--- resources/sass/_tinymce.scss | 19 +- .../sass/{_variables.scss => _vars.scss} | 36 ++-- resources/sass/export-styles.scss | 13 +- resources/sass/styles.scss | 102 +++++----- 20 files changed, 649 insertions(+), 595 deletions(-) rename resources/sass/{_variables.scss => _vars.scss} (85%) diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 9e96e920b8d..8c248caee14 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -1,14 +1,17 @@ +@use "mixins"; +@use "vars"; + /** * Card-style blocks */ .card { - @include lightDark(background-color, #FFF, #222); - box-shadow: $bs-card; + @include mixins.lightDark(background-color, #FFF, #222); + box-shadow: vars.$bs-card; border-radius: 3px; break-inside: avoid; .body, p.empty-text { - padding-block: $-m; + padding-block: vars.$m; } a, p { word-wrap: break-word; @@ -17,9 +20,9 @@ } .card-title { - padding: $-m $-m $-xs; + padding: vars.$m vars.$m vars.$xs; margin: 0; - font-size: $fs-m; + font-size: vars.$fs-m; color: #222; fill: #222; font-weight: 400; @@ -29,21 +32,21 @@ } .card-footer-link, button.card-footer-link { display: block; - padding: $-s $-m; + padding: vars.$s vars.$m; line-height: 1; border-top: 1px solid; width: 100%; text-align: left; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); border-radius: 0 0 3px 3px; font-size: 0.9em; - margin-top: $-xs; + margin-top: vars.$xs; &:hover { text-decoration: none; - @include lightDark(background-color, #f2f2f2, #2d2d2d); + @include mixins.lightDark(background-color, #f2f2f2, #2d2d2d); } &:focus { - @include lightDark(background-color, #eee, #222); + @include mixins.lightDark(background-color, #eee, #222); outline: 1px dotted #666; outline-offset: -2px; } @@ -51,17 +54,17 @@ .card.border-card { border: 1px solid; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); } .card.drag-card { border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(border-color, #ddd, #000); + @include mixins.lightDark(background-color, #fff, #333); border-radius: 4px; display: flex; - padding: 0 0 0 ($-s + 28px); - margin: $-s 0; + padding: 0 0 0 (vars.$s + 28px); + margin: vars.$s 0; position: relative; .drag-card-action { cursor: pointer; @@ -73,30 +76,30 @@ justify-content: center; width: 28px; flex-grow: 0; - padding: 0 $-xs; + padding: 0 vars.$xs; &:hover { - @include lightDark(background-color, #eee, #2d2d2d); + @include mixins.lightDark(background-color, #eee, #2d2d2d); } .svg-icon { margin-inline-end: 0px; } } .outline input { - margin: $-s 0; + margin: vars.$s 0; width: 100%; } .outline { position: relative; } .handle { - @include lightDark(background-color, #eee, #2d2d2d); + @include mixins.lightDark(background-color, #eee, #2d2d2d); left: 0; position: absolute; top: 0; bottom: 0; } > div { - padding: 0 $-s; + padding: 0 vars.$s; max-width: 80%; flex: 1; } @@ -106,17 +109,17 @@ display: flex; flex-direction: column; border: 1px solid #ddd; - @include lightDark(border-color, #ddd, #000); - margin-bottom: $-l; + @include mixins.lightDark(border-color, #ddd, #000); + margin-bottom: vars.$l; border-radius: 4px; overflow: hidden; min-width: 100px; - color: $text-dark; + color: vars.$text-dark; transition: border-color ease-in-out 120ms, box-shadow ease-in-out 120ms; &:hover { - color: $text-dark; + color: vars.$text-dark; text-decoration: none; - @include lightDark(box-shadow, $bs-card, $bs-card-dark); + @include mixins.lightDark(box-shadow, vars.$bs-card, vars.$bs-card-dark); } h2 { width: 100%; @@ -134,7 +137,7 @@ border-bottom-width: 2px; } .grid-card-content, .grid-card-footer { - padding: $-l; + padding: vars.$l; } .grid-card-content + .grid-card-footer { padding-top: 0; @@ -149,10 +152,10 @@ } .content-wrap.card { - padding: $-m $-xxl; + padding: vars.$m vars.$xxl; margin-inline-start: auto; margin-inline-end: auto; - margin-bottom: $-l; + margin-bottom: vars.$l; overflow: initial; min-height: 60vh; border-radius: 8px; @@ -163,26 +166,26 @@ width: 100%; } } -@include smaller-than($xxl) { +@include mixins.smaller-than(vars.$bp-xxl) { .content-wrap.card { - padding: $-m $-xl; + padding: vars.$m vars.$xl; } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .content-wrap.card { - padding: $-m $-l; + padding: vars.$m vars.$l; } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .content-wrap.card { - padding: $-m $-m; + padding: vars.$m vars.$m; } } .sub-card { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); border: 1.5px solid; - @include lightDark(border-color, #E2E2E2, #444); + @include mixins.lightDark(border-color, #E2E2E2, #444); border-radius: 4px; } @@ -194,7 +197,7 @@ } .fade-in-when-active { - @include lightDark(opacity, 0.6, 0.7); + @include mixins.lightDark(opacity, 0.6, 0.7); transition: opacity ease-in-out 120ms; &:hover, &:focus-within { opacity: 1 !important; @@ -209,29 +212,29 @@ */ .tag-item { display: inline-flex; - margin-bottom: $-xs; - margin-inline-end: $-xs; + margin-bottom: vars.$xs; + margin-inline-end: vars.$xs; border-radius: 4px; border: 1px solid; overflow: hidden; font-size: 0.85em; - @include lightDark(border-color, #CCC, #666); + @include mixins.lightDark(border-color, #CCC, #666); a, span, a:hover, a:active { padding: 4px 8px; - @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8)); + @include mixins.lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8)); transition: background-color ease-in-out 80ms; text-decoration: none; } a:hover { - @include lightDark(background-color, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3)); + @include mixins.lightDark(background-color, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3)); } svg { - @include lightDark(fill, rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5)); + @include mixins.lightDark(fill, rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5)); } .tag-value { border-inline-start: 1px solid; - @include lightDark(border-color, #DDD, #666); - @include lightDark(background-color, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.2)) + @include mixins.lightDark(border-color, #DDD, #666); + @include mixins.lightDark(background-color, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.2)) } } @@ -253,7 +256,7 @@ .api-method { font-size: 0.75rem; background-color: #888; - padding: $-xs; + padding: vars.$xs; line-height: 1.3; opacity: 0.7; vertical-align: top; @@ -271,7 +274,7 @@ .sticky-sidebar { position: sticky; - top: $-m; - max-height: calc(100vh - #{$-m}); + top: vars.$m; + max-height: calc(100vh - #{vars.$m}); overflow-y: auto; } diff --git a/resources/sass/_buttons.scss b/resources/sass/_buttons.scss index e629e772662..29deda3a08a 100644 --- a/resources/sass/_buttons.scss +++ b/resources/sass/_buttons.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + button { background-color: transparent; border: 0; @@ -8,9 +11,9 @@ button { text-decoration: none; font-size: 0.85rem; line-height: 1.4em; - padding: $-xs*1.3 $-m; - margin-top: $-xs; - margin-bottom: $-xs; + padding: vars.$xs*1.3 vars.$m; + margin-top: vars.$xs; + margin-bottom: vars.$xs; display: inline-block; font-weight: 400; outline: 0; @@ -30,12 +33,12 @@ button { color: #FFFFFF; } &:hover { - @include lightDark(box-shadow, $bs-light, $bs-dark); + @include mixins.lightDark(box-shadow, vars.$bs-light, vars.$bs-dark); filter: brightness(110%); } &:focus { outline: 1px dotted currentColor; - outline-offset: -$-xs; + outline-offset: -(vars.$xs); box-shadow: none; filter: brightness(90%); } @@ -46,16 +49,16 @@ button { .button.outline { background-color: transparent; - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(color, #666, #AAA); fill: currentColor; border: 1px solid; - @include lightDark(border-color, #CCC, #666); + @include mixins.lightDark(border-color, #CCC, #666); &:hover, &:focus, &:active { - @include lightDark(color, #444, #BBB); + @include mixins.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); + @include mixins.lightDark(background-color, #f8f8f8, #444); filter: none; } &:active { @@ -67,12 +70,12 @@ button { } .button + .button { - margin-inline-start: $-s; + margin-inline-start: vars.$s; } .button.small { font-size: 0.75rem; - padding: $-xs*1.2 $-s; + padding: vars.$xs*1.2 vars.$s; } .text-button { @@ -119,22 +122,22 @@ button { .icon-button:hover { background-color: rgba(0, 0, 0, 0.05); border-radius: 4px; - @include lightDark(border-color, #DDD, #444); + @include mixins.lightDark(border-color, #DDD, #444); cursor: pointer; } .button.svg { display: flex; align-items: center; - padding: $-s $-m; - padding-bottom: ($-s - 2px); + padding: vars.$s vars.$m; + padding-bottom: (vars.$s - 2px); width: 100%; svg { display: inline-block; width: 24px; height: 24px; bottom: auto; - margin-inline-end: $-m; + margin-inline-end: vars.$m; } } diff --git a/resources/sass/_codemirror.scss b/resources/sass/_codemirror.scss index 5f14cb9dbc9..a516b4426a3 100644 --- a/resources/sass/_codemirror.scss +++ b/resources/sass/_codemirror.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Custom CodeMirror BookStack overrides */ @@ -6,7 +9,7 @@ font-size: 12px; border: 1px solid #ddd; line-height: 1.4; - margin-bottom: $-l; + margin-bottom: vars.$l; } .page-content .cm-editor, @@ -42,9 +45,9 @@ background-color: #EEE; border: 1px solid #DDD; border-start-end-radius: 4px; - @include lightDark(background-color, #eee, #333); - @include lightDark(border-color, #ddd, #444); - @include lightDark(color, #444, #888); + @include mixins.lightDark(background-color, #eee, #333); + @include mixins.lightDark(border-color, #ddd, #444); + @include mixins.lightDark(color, #444, #888); line-height: 0; cursor: pointer; z-index: 5; diff --git a/resources/sass/_colors.scss b/resources/sass/_colors.scss index c77c1d8b388..bf7a7a0fc03 100644 --- a/resources/sass/_colors.scss +++ b/resources/sass/_colors.scss @@ -1,3 +1,5 @@ +@use "mixins"; + /** * Background colors */ @@ -7,7 +9,7 @@ } .primary-background-light { background-color: var(--color-primary-light); - @include whenDark { + @include mixins.whenDark { background: #000; .text-link { color: #AAA !important; @@ -50,12 +52,12 @@ } .text-muted { - @include lightDark(color, #575757, #888888, true); + @include mixins.lightDark(color, #575757, #888888, true); fill: currentColor !important; } .text-dark { - @include lightDark(color, #222, #ccc, true); + @include mixins.lightDark(color, #222, #ccc, true); fill: currentColor !important; } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 18d1bc18f0c..888b325275e 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1,16 +1,21 @@ +@use "sass:math"; + +@use "mixins"; +@use "vars"; + // System wide notifications .notification { position: fixed; top: 0; right: 0; - margin: $-xl; - padding: $-m $-l; + margin: vars.$xl; + padding: vars.$m vars.$l; background-color: #FFF; - @include lightDark(background-color, #fff, #444); + @include mixins.lightDark(background-color, #fff, #444); border-radius: 4px; border-inline-start: 6px solid currentColor; - box-shadow: $bs-large; + box-shadow: vars.$bs-large; z-index: 999999; cursor: pointer; max-width: 360px; @@ -28,20 +33,20 @@ svg { width: 2.8rem; height: 2.8rem; - padding-inline-end: $-s; + padding-inline-end: vars.$s; fill: currentColor; } .dismiss { margin-top: -8px; svg { height: 1.0rem; - @include lightDark(color, #444, #888); + @include mixins.lightDark(color, #444, #888); } } span { vertical-align: middle; line-height: 1.3; - @include whenDark { + @include mixins.whenDark { color: #BBB; } } @@ -78,12 +83,12 @@ transform: rotate(90deg); } svg[data-icon="caret-right"] + * { - margin-inline-start: $-xxs; + margin-inline-start: vars.$xxs; } } [overlay], .popup-background { - @include lightDark(background-color, rgba(0, 0, 0, 0.333), rgba(0, 0, 0, 0.6)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.333), rgba(0, 0, 0, 0.6)); position: fixed; z-index: 95536; width: 100%; @@ -104,7 +109,7 @@ } .popup-body { - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(background-color, #fff, #333); max-height: 90%; max-width: 1200px; width: 90%; @@ -144,7 +149,7 @@ border-radius: 0; box-shadow: none; color: #FFF; - padding: $-xs $-m; + padding: vars.$xs vars.$m; cursor: pointer; } @@ -161,7 +166,7 @@ background-color: var(--color-primary-light); min-height: 41px; button { - padding: 10px $-m; + padding: 10px vars.$m; } } @@ -183,7 +188,7 @@ .popup-title { color: #FFF; margin-inline-end: auto; - padding: 8px $-m; + padding: 8px vars.$m; } &.flex-container-row { display: flex !important; @@ -226,7 +231,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .dropzone-landing-area { background-color: var(--color-primary-light); - padding: $-m $-l; + padding: vars.$m vars.$l; width: 100%; border: 1px dashed var(--color-primary); color: var(--color-primary); @@ -275,8 +280,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: flex; margin: 1rem; flex-direction: row; - @include lightDark(background, #FFF, #444); - box-shadow: $bs-large; + @include mixins.lightDark(background, #FFF, #444); + box-shadow: vars.$bs-large; border-radius: 4px; overflow: hidden; padding-bottom: 3px; @@ -354,7 +359,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { position: sticky; top: 0; z-index: 5; - @include lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85)); + @include mixins.lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85)); } .image-manager-filter-bar-bg { position: absolute; @@ -367,16 +372,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .image-manager-filters { - box-shadow: $bs-med; + box-shadow: vars.$bs-med; border-radius: 4px; overflow: hidden; border-bottom: 0 !important; - @include whenDark { + @include mixins.whenDark { border: 1px solid #000 !important; } button { line-height: 0; - @include lightDark(background-color, #FFF, #333); + @include mixins.lightDark(background-color, #FFF, #333); } svg { margin: 0; @@ -404,7 +409,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { padding: 0; cursor: pointer; aspect-ratio: 1; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); transition: all linear 80ms; overflow: hidden; &.selected { @@ -441,7 +446,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { &:focus .image-meta { opacity: 1; } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { .image-meta { display: none; } @@ -450,7 +455,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .image-manager .load-more { text-align: center; - padding: $-s $-m; + padding: vars.$s vars.$m; clear: both; .loading-container { margin: 0; @@ -467,7 +472,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .image-manager-warning { - @include lightDark(background, #FFF, #333); + @include mixins.lightDark(background, #FFF, #333); color: var(--color-warning); font-weight: bold; border-inline: 3px solid var(--color-warning); @@ -479,16 +484,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { overflow-y: auto; overflow-x: hidden; border-inline-start: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); .inner { min-height: auto; - padding: $-m; + padding: vars.$m; } .image-manager-viewer img { max-width: 100%; max-height: 180px; display: block; - margin: 0 auto $-m auto; + margin: 0 auto vars.$m auto; box-shadow: 0 1px 21px 1px rgba(76, 76, 76, 0.3); } .image-manager-viewer { @@ -501,7 +506,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .image-manager-sidebar { border-inline-start: 0; } @@ -522,7 +527,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .tab-container.bordered [role="tablist"] button[role="tab"] { border-inline-end: 1px solid #DDD; - @include lightDark(border-inline-end-color, #DDD, #000); + @include mixins.lightDark(border-inline-end-color, #DDD, #000); &:last-child { border-inline-end: none; } @@ -534,14 +539,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { justify-items: start; text-align: start; border-bottom: 1px solid #DDD; - @include lightDark(border-color, #ddd, #444); - margin-bottom: $-m; + @include mixins.lightDark(border-color, #ddd, #444); + margin-bottom: vars.$m; } .tab-container [role="tablist"] button[role="tab"] { display: inline-block; - padding: $-s; - @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); + padding: vars.$s; + @include mixins.lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; @@ -551,8 +556,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { outline: 0 !important; } &:hover, &:focus { - @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); - @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); + @include mixins.lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); + @include mixins.lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); } &:focus { outline: 1px dotted var(--color-primary); @@ -562,7 +567,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .tab-container [role="tablist"].controls-card { margin-bottom: 0; border-bottom: 0; - padding: 0 $-xs; + padding: 0 vars.$xs; } .image-picker .none { @@ -583,16 +588,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .code-editor .lang-options button { display: block; - padding: $-xs $-m; + padding: vars.$xs vars.$m; border-bottom: 1px solid; - @include lightDark(color, #333, #AAA); - @include lightDark(border-bottom-color, #EEE, #000); + @include mixins.lightDark(color, #333, #AAA); + @include mixins.lightDark(border-bottom-color, #EEE, #000); cursor: pointer; width: 100%; text-align: left; font-family: var(--font-code); font-size: 0.7rem; - padding-left: 24px + $-xs; + padding-left: 24px + vars.$xs; &:hover, &.active { background-color: var(--color-primary-light); color: var(--color-primary); @@ -633,7 +638,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { background-color: var(--color-primary-light); width: 100%; color: var(--color-primary); - padding: $-xxs $-s; + padding: vars.$xxs vars.$s; margin-bottom: 0; } @@ -648,7 +653,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border-radius: 0; border: 0; border-bottom: 1px solid #DDD; - padding: $-xs $-s; + padding: vars.$xs vars.$s; height: auto; } @@ -667,7 +672,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { height: 80vh; } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .code-editor .lang-options { display: none; } @@ -680,21 +685,21 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .comments-container { - padding-inline: $-xl; - @include smaller-than($m) { - padding-inline: $-xs; + padding-inline: vars.$xl; + @include mixins.smaller-than(vars.$bp-m) { + padding-inline: vars.$xs; } } .comment-box { border-radius: 4px; border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #ddd, #000); + @include mixins.lightDark(background-color, #FFF, #222); .content { font-size: 0.666em; - padding: $-xs $-s; + padding: vars.$xs vars.$s; p, ul, ol { - font-size: $fs-m; + font-size: vars.$fs-m; margin: .5em 0; } } @@ -708,7 +713,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .actions button:focus { outline: 1px dotted var(--color-primary); } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { .actions { opacity: 1; } @@ -717,8 +722,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .comment-box .header { border-bottom: 1px solid #DDD; - padding: $-xs $-s; - @include lightDark(border-color, #DDD, #000); + padding: vars.$xs vars.$s; + @include mixins.lightDark(border-color, #DDD, #000); a { color: inherit; } @@ -735,10 +740,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .comment-thread-indicator { border-inline-start: 3px dotted #DDD; - @include lightDark(border-color, #DDD, #444); - margin-inline-start: $-xs; - width: $-l; - height: calc(100% - $-m); + @include mixins.lightDark(border-color, #DDD, #444); + margin-inline-start: vars.$xs; + width: vars.$l; + height: calc(100% - vars.$m); } .comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator { @@ -748,7 +753,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .comment-reply { display: none; margin: 0 !important; - margin-bottom: -$-xxs !important; + margin-bottom: -(vars.$xxs) !important; } .comment-branch .comment-branch .comment-branch .comment-branch .comment-reply { @@ -760,17 +765,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { font-size: 0.8rem; } .header { - padding: $-xs; + padding: vars.$xs; } .right-meta { display: none; } .content { - padding: $-xs $-s; + padding: vars.$xs vars.$s; } } .comment-container-compact .comment-thread-indicator { - width: $-m; + width: vars.$m; } #tag-manager .drag-card { @@ -792,15 +797,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: flex; flex-direction: column; border-inline-start: 1px solid; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); } .template-item-actions button { cursor: pointer; flex: 1; - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); border: 0; border-top: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); } .template-item-actions button svg { margin: 0; @@ -818,7 +823,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border: 1px solid transparent; border-radius: 4px; line-height: normal; - padding: $-xs; + padding: vars.$xs; &:hover { border-color: #DDD; } @@ -828,7 +833,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .dropdown-search-toggle-select { display: flex; - gap: $-s; + gap: vars.$s; line-height: normal; .svg-icon { height: 26px; @@ -862,7 +867,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .dropdown-search-dropdown { - box-shadow: $bs-med; + box-shadow: vars.$bs-med; overflow: hidden; min-height: 100px; width: 240px; @@ -871,16 +876,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { z-index: 80; right: 0; top: 0; - margin-top: $-m; - @include rtl { + margin-top: vars.$m; + @include mixins.rtl { right: auto; - left: -$-m; + left: -(vars.$m); } .dropdown-search-search .svg-icon { position: absolute; - left: $-s; - @include rtl { - right: $-s; + left: vars.$s; + @include mixins.rtl { + right: vars.$s; left: auto; } top: 11px; @@ -893,14 +898,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { text-align: start; } .dropdown-search-item { - padding: $-s $-m; + padding: vars.$s vars.$m; &:hover,&:focus { background-color: #F2F2F2; text-decoration: none; } } input, input:focus { - padding-inline-start: $-xl; + padding-inline-start: vars.$xl; border-radius: 0; border: 0; border-bottom: 1px solid #DDD; @@ -910,9 +915,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { .dropdown-search-dropdown { - inset-inline: $-m auto; + inset-inline: vars.$m auto; } .dropdown-search-dropdown .dropdown-search-list { max-height: 240px; @@ -924,13 +929,13 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .item-list-row { border: 1.5px solid; - @include lightDark(border-color, #E2E2E2, #444); + @include mixins.lightDark(border-color, #E2E2E2, #444); border-bottom-width: 0; label { padding-bottom: 0; } &:hover { - @include lightDark(background-color, #F6F6F6, #333); + @include mixins.lightDark(background-color, #F6F6F6, #333); } } .item-list-row:first-child { @@ -980,7 +985,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .shortcut-hint { position: fixed; - padding: $-xxs $-xxs; + padding: vars.$xxs vars.$xxs; font-size: .85rem; font-weight: 700; line-height: 1; @@ -996,8 +1001,8 @@ $btt-size: 40px; .back-to-top { background-color: var(--color-primary); position: fixed; - bottom: $-m; - right: $-l; + bottom: vars.$m; + right: vars.$l; padding: 5px 7px; cursor: pointer; color: #FFF; @@ -1036,17 +1041,17 @@ $btt-size: 40px; max-height: 280px; overflow-y: scroll; border: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); border-radius: 3px; min-height: 20px; - @include lightDark(background-color, #EEE, #000); + @include mixins.lightDark(background-color, #EEE, #000); } .scroll-box-item { border-bottom: 1px solid; border-top: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); margin-top: -1px; - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); display: flex; align-items: flex-start; padding: 1px; @@ -1055,7 +1060,7 @@ $btt-size: 40px; } &:hover { cursor: pointer; - @include lightDark(background-color, #f8f8f8, #333); + @include mixins.lightDark(background-color, #f8f8f8, #333); } .handle { color: #AAA; @@ -1068,13 +1073,13 @@ $btt-size: 40px; margin: 0; } > * { - padding: $-xs $-m; + padding: vars.$xs vars.$m; } .handle + * { padding-left: 0; } &:hover .handle { - @include lightDark(color, #444, #FFF); + @include mixins.lightDark(color, #444, #FFF); } &:hover button { opacity: 1; @@ -1087,8 +1092,8 @@ $btt-size: 40px; input.scroll-box-search, .scroll-box-header-item { font-size: 0.8rem; border: 1px solid; - @include lightDark(border-color, #DDD, #000); - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #DDD, #000); + @include mixins.lightDark(background-color, #FFF, #222); margin-bottom: -1px; border-radius: 3px 3px 0 0; width: 100%; diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 7d130bb0c24..b0176d64ef1 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Page Content * Styles specific to blocks used within page content. @@ -13,14 +16,14 @@ } img.align-left, table.align-left, iframe.align-left, video.align-left { float: left !important; - margin: $-xs $-m $-m 0; + margin: vars.$xs vars.$m vars.$m 0; } .align-right { text-align: right !important; } img.align-right, table.align-right, iframe.align-right, video.align-right { float: right !important; - margin: $-xs 0 $-xs $-s; + margin: vars.$xs 0 vars.$xs vars.$s; } .align-center { text-align: center; @@ -40,7 +43,7 @@ } hr { clear: both; - margin: $-m 0; + margin: vars.$m 0; } table { hyphens: auto; @@ -63,23 +66,23 @@ details { border: 1px solid; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); margin-bottom: 1em; - padding: $-s; + padding: vars.$s; } details > summary { - margin-top: -$-s; - margin-left: -$-s; - margin-right: -$-s; - margin-bottom: -$-s; + margin-top: -(vars.$s); + margin-left: -(vars.$s); + margin-right: -(vars.$s); + margin-bottom: -(vars.$s); font-weight: bold; - @include lightDark(background-color, #EEE, #333); - padding: $-xs $-s; + @include mixins.lightDark(background-color, #EEE, #333); + padding: vars.$xs vars.$s; } details[open] > summary { - margin-bottom: $-s; + margin-bottom: vars.$s; border-bottom: 1px solid; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); } details > summary + * { margin-top: .2em; @@ -138,10 +141,10 @@ body .page-content img, border-inline-start: 3px solid #BBB; border-inline-end: none; background-color: #EEE; - padding: $-s; - padding-left: $-xl; - padding-inline-start: $-xl; - padding-inline-end: $-s; + padding: vars.$s; + padding-left: vars.$xl; + padding-inline-start: vars.$xl; + padding-inline-end: vars.$s; display: block; position: relative; overflow: auto; @@ -151,8 +154,8 @@ body .page-content img, content: ''; width: 1.2em; height: 1.2em; - left: $-xs + 2px; - inset-inline-start: $-xs + 2px; + left: vars.$xs + 2px; + inset-inline-start: vars.$xs + 2px; inset-inline-end: unset; top: 50%; margin-top: -9px; @@ -162,30 +165,30 @@ body .page-content img, opacity: 0.8; } &.success { - @include lightDark(border-color, $positive, $positive-dark); - @include lightDark(background-color, #eafdeb, #122913); - @include lightDark(color, #063409, $positive-dark); + @include mixins.lightDark(border-color, vars.$positive, vars.$positive-dark); + @include mixins.lightDark(background-color, #eafdeb, #122913); + @include mixins.lightDark(color, #063409, vars.$positive-dark); } &.success:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMzNzZjMzkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0yIDE1bC01LTUgMS40MS0xLjQxTDEwIDE0LjE3bDcuNTktNy41OUwxOSA4bC05IDl6Ii8+PC9zdmc+"); } &.danger { - @include lightDark(border-color, $negative, $negative-dark); - @include lightDark(background-color, #fcdbdb, #250505); - @include lightDark(color, #4d0706, $negative-dark); + @include mixins.lightDark(border-color, vars.$negative, vars.$negative-dark); + @include mixins.lightDark(background-color, #fcdbdb, #250505); + @include mixins.lightDark(color, #4d0706, vars.$negative-dark); } &.danger:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiOTE4MTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0xNS43MyAzSDguMjdMMyA4LjI3djcuNDZMOC4yNyAyMWg3LjQ2TDIxIDE1LjczVjguMjdMMTUuNzMgM3pNMTIgMTcuM2MtLjcyIDAtMS4zLS41OC0xLjMtMS4zIDAtLjcyLjU4LTEuMyAxLjMtMS4zLjcyIDAgMS4zLjU4IDEuMyAxLjMgMCAuNzItLjU4IDEuMy0xLjMgMS4zem0xLTQuM2gtMlY3aDJ2NnoiLz4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg=="); } &.info { - @include lightDark(border-color, $info, $info-dark); - @include lightDark(background-color, #d3efff, #001825); - @include lightDark(color, #01466c, $info-dark); + @include mixins.lightDark(border-color, vars.$info, vars.$info-dark); + @include mixins.lightDark(background-color, #d3efff, #001825); + @include mixins.lightDark(color, #01466c, vars.$info-dark); } &.warning { - @include lightDark(border-color, $warning, $warning-dark); - @include lightDark(background-color, #fee3d3, #30170a); - @include lightDark(color, #6a2802, $warning-dark); + @include mixins.lightDark(border-color, vars.$warning, vars.$warning-dark); + @include mixins.lightDark(background-color, #fee3d3, #30170a); + @include mixins.lightDark(color, #6a2802, vars.$warning-dark); } &.warning:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiNjUzMWMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEgMjFoMjJMMTIgMiAxIDIxem0xMi0zaC0ydi0yaDJ2MnptMC00aC0ydi00aDJ2NHoiLz48L3N2Zz4="); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index b33cb4d0558..e273f1942d5 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + // Common variables :root { --editor-color-primary: #206ea7; @@ -262,11 +265,11 @@ body.editor-is-fullscreen { color: #FFF; } .editor-modal-title { - padding: 8px $-m; + padding: 8px vars.$m; } .editor-modal-close { color: #FFF; - padding: 8px $-m; + padding: 8px vars.$m; align-items: center; justify-content: center; cursor: pointer; @@ -281,7 +284,7 @@ body.editor-is-fullscreen { } } .editor-modal-body { - padding: $-m; + padding: vars.$m; } // Specific UI elements @@ -493,21 +496,21 @@ textarea.editor-form-field-input { .editor-form-actions { display: flex; justify-content: end; - gap: $-s; - margin-top: $-m; + gap: vars.$s; + margin-top: vars.$m; } .editor-form-actions > button { display: block; font-size: 0.85rem; line-height: 1.4em; - padding: $-xs*1.3 $-m; + padding: vars.$xs*1.3 vars.$m; font-weight: 400; border-radius: 4px; cursor: pointer; box-shadow: none; &:focus { outline: 1px dotted currentColor; - outline-offset: -$-xs; + outline-offset: -(vars.$xs); box-shadow: none; filter: brightness(90%); } @@ -517,20 +520,20 @@ textarea.editor-form-field-input { color: #FFF; border: 1px solid var(--color-primary); &:hover { - @include lightDark(box-shadow, $bs-light, $bs-dark); + @include mixins.lightDark(box-shadow, vars.$bs-light, vars.$bs-dark); filter: brightness(110%); } } .editor-form-action-secondary { border: 1px solid; - @include lightDark(border-color, #CCC, #666); - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(border-color, #CCC, #666); + @include mixins.lightDark(color, #666, #AAA); &:hover, &:focus, &:active { - @include lightDark(color, #444, #BBB); + @include mixins.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); + @include mixins.lightDark(background-color, #f8f8f8, #444); filter: none; } &:active { diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 1c679aaa0dd..b66688f8d20 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -1,13 +1,18 @@ +@use "sass:math"; + +@use "mixins"; +@use "vars"; + .input-base { border-radius: 3px; border: 1px solid #D4D4D4; - @include lightDark(background-color, #fff, #333); - @include lightDark(border-color, #d4d4d4, #111); - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(background-color, #fff, #333); + @include mixins.lightDark(border-color, #d4d4d4, #111); + @include mixins.lightDark(color, #666, #AAA); display: inline-block; - font-size: $fs-m; - padding: $-xs*1.8; + font-size: vars.$fs-m; + padding: vars.$xs*1.8; height: 40px; width: 250px; max-width: 100%; @@ -49,7 +54,7 @@ #markdown-editor-input { font-style: normal; font-weight: 400; - padding: $-xs $-m; + padding: vars.$xs vars.$m; color: #444; border-radius: 0; max-height: 100%; @@ -72,7 +77,7 @@ .markdown-editor-wrap { border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); position: relative; flex: 1; min-width: 0; @@ -92,11 +97,11 @@ .markdown-panel-divider { width: 2px; - @include lightDark(background-color, #ddd, #000); + @include mixins.lightDark(background-color, #ddd, #000); cursor: col-resize; } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { #markdown-editor { flex-direction: column; } @@ -109,7 +114,7 @@ } .editor-toolbar-label { float: none !important; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); display: block; } .markdown-editor-wrap:not(.active) .editor-toolbar + div, @@ -150,10 +155,10 @@ html.markdown-editor-display.dark-mode { font-size: 11px; line-height: 1.6; border-bottom: 1px solid #CCC; - @include lightDark(background-color, #FFF, #333); - @include lightDark(border-color, #CCC, #000); + @include mixins.lightDark(background-color, #FFF, #333); + @include mixins.lightDark(border-color, #CCC, #000); flex: none; - @include whenDark { + @include mixins.whenDark { button { color: #AAA; } @@ -161,12 +166,12 @@ html.markdown-editor-display.dark-mode { } .editor-toolbar .buttons { - font-size: $fs-m; + font-size: vars.$fs-m; .dropdown-menu { padding: 0; } .toggle-switch { - margin: $-s 0; + margin: vars.$s 0; } } @@ -175,18 +180,18 @@ html.markdown-editor-display.dark-mode { width: 2rem; text-align: center; border-left: 1px solid; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); svg { margin-inline-end: 0; } &:hover { - @include lightDark(background-color, #DDD, #222); + @include mixins.lightDark(background-color, #DDD, #222); } } label { - @include lightDark(color, #666, #ddd); + @include mixins.lightDark(color, #666, #ddd); display: block; line-height: 1.4em; font-size: 0.94em; @@ -202,12 +207,12 @@ label.radio, label.checkbox { font-weight: 400; user-select: none; input[type="radio"], input[type="checkbox"] { - margin-inline-end: $-xs; + margin-inline-end: vars.$xs; } } label.inline.checkbox { - margin-inline-end: $-m; + margin-inline-end: vars.$m; } label + p.small { @@ -218,7 +223,7 @@ table.form-table { max-width: 100%; td { overflow: hidden; - padding: math.div($-xxs, 2) 0; + padding: math.div(vars.$xxs, 2) 0; } } @@ -236,7 +241,7 @@ select { background-position: calc(100% - 20px) 64%; background-repeat: no-repeat; - @include rtl { + @include mixins.rtl { background-position: 20px 70%; } } @@ -257,9 +262,9 @@ input[type=color] { .toggle-switch { user-select: none; display: inline-grid; - grid-template-columns: (16px + $-s) 1fr; + grid-template-columns: (16px + vars.$s) 1fr; align-items: center; - margin: $-m 0; + margin: vars.$m 0; .custom-checkbox { width: 16px; height: 16px; @@ -302,7 +307,7 @@ input[type=color] { } .toggle-switch-list { .toggle-switch { - margin: $-xs 0; + margin: vars.$xs 0; } &.compact .toggle-switch { margin: 1px 0; @@ -310,18 +315,18 @@ input[type=color] { } .form-group { - margin-bottom: $-s; + margin-bottom: vars.$s; } .setting-list > div { border-bottom: 1px solid #DDD; - padding: $-xl 0; + padding: vars.$xl 0; &:last-child { border-bottom: none; } } .setting-list-label { - @include lightDark(color, #222, #DDD); + @include mixins.lightDark(color, #222, #DDD); color: #222; font-size: 1rem; } @@ -329,7 +334,7 @@ input[type=color] { margin-bottom: 0; } .setting-list-label + .grid { - margin-top: $-m; + margin-top: vars.$m; } .setting-list .grid, .stretch-inputs { @@ -349,19 +354,19 @@ input[type=color] { .form-group { div.text-pos, div.text-neg, p.text-post, p.text-neg { - padding: $-xs 0; + padding: vars.$xs 0; } } .form-group.collapsible { - padding: 0 $-m; + padding: 0 vars.$m; border: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); border-radius: 4px; .collapse-title { - margin-inline-start: -$-m; - margin-inline-end: -$-m; - padding: $-s $-m; + margin-inline-start: -(vars.$m); + margin-inline-end: -(vars.$m); + padding: vars.$s vars.$m; display: block; width: calc(100% + 32px); text-align: start; @@ -377,13 +382,13 @@ input[type=color] { .collapse-title label:before { display: inline-block; content: '▸'; - margin-inline-end: $-m; + margin-inline-end: vars.$m; transition: all ease-in-out 400ms; transform: rotate(0); } .collapse-content { display: none; - padding-bottom: $-m; + padding-bottom: vars.$m; } &.open .collapse-title label:before { transform: rotate(90deg); @@ -407,7 +412,7 @@ input[type=color] { .title-input input[type="text"] { display: block; width: 100%; - padding: $-s; + padding: vars.$s; margin-top: 0; font-size: 2em; height: auto; @@ -416,15 +421,15 @@ input[type=color] { .description-input textarea { display: block; width: 100%; - padding: $-s; - font-size: $fs-m; + padding: vars.$s; + font-size: vars.$fs-m; color: #666; height: auto; } .description-input > .tox-tinymce { border: 1px solid #DDD !important; - @include lightDark(border-color, #DDD !important, #000 !important); + @include mixins.lightDark(border-color, #DDD !important, #000 !important); border-radius: 3px; .tox-toolbar__primary { justify-content: end; @@ -437,7 +442,7 @@ input[type=color] { button[tabindex="-1"] { background-color: transparent; border: none; - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(color, #666, #AAA); padding: 0; cursor: pointer; position: absolute; @@ -446,8 +451,8 @@ input[type=color] { } input { display: block; - padding: $-xs * 1.5; - padding-inline-start: $-l + 4px; + padding: vars.$xs * 1.5; + padding-inline-start: vars.$l + 4px; width: 300px; max-width: 100%; height: auto; @@ -466,10 +471,10 @@ input[type=color] { height: 38px; z-index: -1; &.floating { - box-shadow: $bs-med; + box-shadow: vars.$bs-med; border-radius: 4px; overflow: hidden; - @include whenDark { + @include mixins.whenDark { border: 1px solid #000; } } @@ -477,7 +482,7 @@ input[type=color] { height: 100%; border-radius: 0; border: 1px solid #ddd; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); margin-inline-start: -1px; &:last-child { border-inline-end: 0; @@ -486,7 +491,7 @@ input[type=color] { input { border: 0; flex: 5; - padding: $-xs $-s; + padding: vars.$xs vars.$s; &:focus, &:active { outline: 1px dotted var(--color-primary); outline-offset: -2px; @@ -498,8 +503,8 @@ input[type=color] { width: 48px; border-inline-start: 1px solid #DDD; background-color: #FFF; - @include lightDark(background-color, #FFF, #333); - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(background-color, #FFF, #333); + @include mixins.lightDark(color, #444, #AAA); } button:focus { outline: 1px dotted var(--color-primary); @@ -508,7 +513,7 @@ input[type=color] { svg { margin: 0; } - @include smaller-than($s) { + @include mixins.smaller-than(vars.$bp-s) { width: 180px; } } @@ -548,30 +553,30 @@ input[type=color] { .custom-simple-file-input { max-width: 100%; border: 1px solid; - @include lightDark(border-color, #DDD, #666); + @include mixins.lightDark(border-color, #DDD, #666); border-radius: 4px; - padding: $-s $-m; + padding: vars.$s vars.$m; } .custom-simple-file-input::file-selector-button { background-color: transparent; text-decoration: none; font-size: 0.8rem; line-height: 1.4em; - padding: $-xs $-s; + padding: vars.$xs vars.$s; border: 1px solid; font-weight: 400; outline: 0; border-radius: 4px; cursor: pointer; - margin-right: $-m; - @include lightDark(color, #666, #AAA); - @include lightDark(border-color, #CCC, #666); + margin-right: vars.$m; + @include mixins.lightDark(color, #666, #AAA); + @include mixins.lightDark(border-color, #CCC, #666); &:hover, &:focus, &:active { - @include lightDark(color, #444, #BBB); + @include mixins.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); + @include mixins.lightDark(background-color, #f8f8f8, #444); filter: none; } &:active { diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index d72b6672948..3ec7275a480 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Includes the main navigation header and the faded toolbar. */ @@ -6,7 +9,7 @@ header.grid { grid-template-columns: minmax(max-content, 2fr) 1fr minmax(max-content, 2fr); } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { header.grid { grid-template-columns: 1fr; grid-row-gap: 0; @@ -20,8 +23,8 @@ header { top: 0; color: rgb(250, 250, 250); border-bottom: 1px solid #DDD; - box-shadow: $bs-card; - @include lightDark(border-bottom-color, #DDD, #000); + box-shadow: vars.$bs-card; + @include mixins.lightDark(border-bottom-color, #DDD, #000); .header-links { display: flex; align-items: center; @@ -33,7 +36,7 @@ header { } .links a { display: inline-block; - padding: 10px $-m; + padding: 10px vars.$m; color: #FFF; border-radius: 3px; } @@ -42,7 +45,7 @@ header { background-color: rgba(255, 255, 255, .15); } .dropdown-container { - padding-inline-start: $-m; + padding-inline-start: vars.$m; padding-inline-end: 0; } .avatar, .user-name { @@ -58,12 +61,12 @@ header { display: inline-flex; align-items: center; cursor: pointer; - padding: $-s; - margin: 0 (-$-s); + padding: vars.$s; + margin: 0 (-(vars.$s)); border-radius: 3px; - gap: $-xs; + gap: vars.$xs; > span { - padding-inline-start: $-xs; + padding-inline-start: vars.$xs; display: inline-block; line-height: 1; } @@ -75,8 +78,8 @@ header { &:hover { background-color: rgba(255, 255, 255, 0.15); } - @include between($l, $xl) { - padding-inline-start: $-xs; + @include mixins.between(vars.$bp-l, vars.$bp-xl) { + padding-inline-start: vars.$xs; .name { display: none; } @@ -98,7 +101,7 @@ header .search-box { color: #EEE; z-index: 2; height: auto; - padding: $-xs*1.5; + padding: vars.$xs*1.5; padding-inline-start: 40px; &:focus { outline: none; @@ -109,7 +112,7 @@ header .search-box { color: #FFF; opacity: 0.6; } - @include between($l, $xl) { + @include mixins.between(vars.$bp-l, vars.$bp-xl) { max-width: 200px; } &:focus-within #header-search-box-button { @@ -122,7 +125,7 @@ header .search-box { top: 10px; color: #FFF; opacity: 0.6; - @include lightDark(color, rgba(255, 255, 255, 0.8), #AAA); + @include mixins.lightDark(color, rgba(255, 255, 255, 0.8), #AAA); svg { margin-inline-end: 0; } @@ -131,15 +134,15 @@ header .search-box { .global-search-suggestions { display: none; position: absolute; - top: -$-s; + top: -(vars.$s); left: 0; right: 0; z-index: -1; - margin-left: -$-xxl; - margin-right: -$-xxl; + margin-left: -(vars.$xxl); + margin-right: -(vars.$xxl); padding-top: 56px; border-radius: 3px; - box-shadow: $bs-hover; + box-shadow: vars.$bs-hover; transform-origin: top center; opacity: .5; transform: scale(0.9); @@ -166,19 +169,19 @@ header .search-box.search-active:focus-within { display: block; } input { - @include lightDark(background-color, #EEE, #333); - @include lightDark(border-color, #DDD, #111); + @include mixins.lightDark(background-color, #EEE, #333); + @include mixins.lightDark(border-color, #DDD, #111); } #header-search-box-button, input { - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(color, #444, #AAA); } } .logo { display: inline-flex; - padding: ($-s - 6px) $-s; - margin: 6px (-$-s); - gap: $-s; + padding: (vars.$s - 6px) vars.$s; + margin: 6px (-(vars.$s)); + gap: vars.$s; align-items: center; border-radius: 4px; &:hover { @@ -204,7 +207,7 @@ header .search-box.search-active:focus-within { font-size: 2em; border: 2px solid rgba(255, 255, 255, 0.8); border-radius: 4px; - padding: 0 $-xs; + padding: 0 vars.$xs; line-height: 1; cursor: pointer; user-select: none; @@ -215,18 +218,18 @@ header .search-box.search-active:focus-within { } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { header .header-links { - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(background-color, #fff, #333); display: none; z-index: 10; - inset-inline-end: $-m; + inset-inline-end: vars.$m; border-radius: 4px; overflow: hidden; position: absolute; - box-shadow: $bs-hover; - margin-top: $-m; - padding: $-xs 0; + box-shadow: vars.$bs-hover; + margin-top: vars.$m; + padding: vars.$xs 0; &.show { display: block; } @@ -235,14 +238,14 @@ header .search-box.search-active:focus-within { text-align: start; display: grid; align-items: center; - padding: 8px $-m; - gap: $-m; - color: $text-dark; + padding: 8px vars.$m; + gap: vars.$m; + color: vars.$text-dark; grid-template-columns: 16px auto; line-height: 1.4; - @include lightDark(color, $text-dark, #eee); + @include mixins.lightDark(color, vars.$text-dark, #eee); svg { - margin-inline-end: $-s; + margin-inline-end: vars.$s; width: 16px; } &:hover { @@ -251,7 +254,7 @@ header .search-box.search-active:focus-within { text-decoration: none; } &:focus { - @include lightDark(background-color, #eee, #333); + @include mixins.lightDark(background-color, #eee, #333); outline-color: var(--color-primary); color: var(--color-primary); } @@ -280,19 +283,19 @@ header .search-box.search-active:focus-within { z-index: 5; background-color: #FFF; border-bottom: 1px solid #DDD; - @include lightDark(border-bottom-color, #DDD, #333); - box-shadow: $bs-card; + @include mixins.lightDark(border-bottom-color, #DDD, #333); + box-shadow: vars.$bs-card; } .tri-layout-mobile-tab { text-align: center; border-bottom: 3px solid #BBB; cursor: pointer; margin: 0; - @include lightDark(background-color, #FFF, #222); - @include lightDark(border-bottom-color, #BBB, #333); + @include mixins.lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-bottom-color, #BBB, #333); &:first-child { border-inline-end: 1px solid #DDD; - @include lightDark(border-inline-end-color, #DDD, #000); + @include mixins.lightDark(border-inline-end-color, #DDD, #000); } &[aria-selected="true"] { border-bottom-color: currentColor !important; @@ -308,8 +311,8 @@ header .search-box.search-active:focus-within { opacity: 0.7; .icon-list-item { width: auto; - padding-top: $-xs; - padding-bottom: $-xs; + padding-top: vars.$xs; + padding-bottom: vars.$xs; } .separator { display: inline-block; @@ -326,9 +329,9 @@ header .search-box.search-active:focus-within { } } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { .breadcrumbs .icon-list-item { - padding: $-xs; + padding: vars.$xs; > span + span { display: none; } @@ -355,5 +358,5 @@ header .search-box.search-active:focus-within { .faded span.faded-text { display: inline-block; - padding: $-s; + padding: vars.$s; } \ No newline at end of file diff --git a/resources/sass/_html.scss b/resources/sass/_html.scss index 1d5defa9765..edaff081045 100644 --- a/resources/sass/_html.scss +++ b/resources/sass/_html.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + * { box-sizing: border-box; outline-color: var(--color-primary); @@ -21,9 +24,9 @@ html { } body { - font-size: $fs-m; + font-size: vars.$fs-m; line-height: 1.6; - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(color, #444, #AAA); -webkit-font-smoothing: antialiased; height: 100%; display: flex; diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 6c78419d86c..8175db948a5 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -1,13 +1,16 @@ +@use "mixins"; +@use "vars"; + /** * Generic content container */ .container { - max-width: $xxl; + max-width: vars.$bp-xxl; margin-inline-start: auto; margin-inline-end: auto; - padding-inline-start: $-m; - padding-inline-end: $-m; + padding-inline-start: vars.$m; + padding-inline-end: vars.$m; &.medium { max-width: 1100px; } @@ -24,8 +27,8 @@ */ .grid { display: grid; - grid-column-gap: $-l; - grid-row-gap: $-l; + grid-column-gap: vars.$l; + grid-row-gap: vars.$l; > * { min-width: 0; } @@ -42,15 +45,15 @@ grid-template-columns: 1fr 3fr; } &.gap-y-xs { - grid-row-gap: $-xs; + grid-row-gap: vars.$xs; } &.gap-xl { - grid-column-gap: $-xl; - grid-row-gap: $-xl; + grid-column-gap: vars.$xl; + grid-row-gap: vars.$xl; } &.gap-xxl { - grid-column-gap: $-xxl; - grid-row-gap: $-xxl; + grid-column-gap: vars.$xxl; + grid-row-gap: vars.$xxl; } &.v-center { align-items: center; @@ -67,7 +70,7 @@ } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .grid.third:not(.no-break) { grid-template-columns: 1fr 1fr; } @@ -78,8 +81,8 @@ grid-template-columns: 1fr 1fr; } .grid.gap-xl { - grid-column-gap: $-m; - grid-row-gap: $-m; + grid-column-gap: vars.$m; + grid-row-gap: vars.$m; } .grid.right-focus.reverse-collapse > *:nth-child(2) { order: 0; @@ -89,13 +92,13 @@ } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .grid.third:not(.no-break) { grid-template-columns: 1fr; } } -@include smaller-than($xs) { +@include mixins.smaller-than(vars.$bp-xs) { .grid.half.collapse-xs { grid-template-columns: 1fr; } @@ -287,19 +290,19 @@ body.flexbox { .sticky-top-m { position: sticky; - top: $-m; + top: vars.$m; } /** * Visibility */ -@each $sizeLetter, $size in $screen-sizes { - @include smaller-than($size) { +@each $sizeLetter, $size in vars.$screen-sizes { + @include mixins.smaller-than($size) { .hide-under-#{$sizeLetter} { display: none !important; } } - @include larger-than($size) { + @include mixins.larger-than($size) { .hide-over-#{$sizeLetter} { display: none !important; } @@ -333,7 +336,7 @@ body.flexbox { columns: 2; } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .dual-column-content { columns: 1; } @@ -357,11 +360,11 @@ body.flexbox { */ .tri-layout-container { display: grid; - margin-inline-start: $-xl; - margin-inline-end: $-xl; + margin-inline-start: vars.$xl; + margin-inline-end: vars.$xl; grid-template-columns: 1fr 4fr 1fr; grid-template-areas: "a b c"; - grid-column-gap: $-xl; + grid-column-gap: vars.$xl; position: relative; } .tri-layout-sides { @@ -379,7 +382,7 @@ body.flexbox { } .tri-layout-middle { grid-area: b; - padding-top: $-m; + padding-top: vars.$m; min-width: 0; z-index: 5; } @@ -392,9 +395,9 @@ body.flexbox { min-width: 0; } -@include larger-than($xxl) { +@include mixins.larger-than(vars.$bp-xxl) { .tri-layout-left-contents, .tri-layout-right-contents { - padding: $-xl $-m; + padding: vars.$xl vars.$m; position: sticky; top: 0; max-height: 100vh; @@ -413,22 +416,22 @@ body.flexbox { margin: 0 auto; } } -@include between($xxl, $xxxl) { +@include mixins.between(vars.$bp-xxl, vars.$bp-xxxl) { .tri-layout-sides-content, .tri-layout-container { - grid-template-columns: 1fr calc(940px + (2 * $-m)) 1fr; + grid-template-columns: 1fr calc(940px + (2 * vars.$m)) 1fr; } .tri-layout-container { - grid-column-gap: $-s; - margin-inline-start: $-m; - margin-inline-end: $-m; + grid-column-gap: vars.$s; + margin-inline-start: vars.$m; + margin-inline-end: vars.$m; } } -@include smaller-than($xxl) { +@include mixins.smaller-than(vars.$bp-xxl) { .tri-layout-container { grid-template-areas: "a b b"; grid-template-columns: 1fr 3fr; grid-template-rows: min-content min-content 1fr; - padding-inline-end: $-l; + padding-inline-end: vars.$l; } .tri-layout-sides { grid-column-start: a; @@ -438,7 +441,7 @@ body.flexbox { display: block; } } -@include between($l, $xxl) { +@include mixins.between(vars.$bp-l, vars.$bp-xxl) { .tri-layout-sides-content { position: sticky; top: 0; @@ -454,12 +457,12 @@ body.flexbox { } } } -@include larger-than($l) { +@include mixins.larger-than(vars.$bp-l) { .tri-layout-mobile-tabs { display: none; } .tri-layout-left-contents > *, .tri-layout-right-contents > * { - @include lightDark(opacity, 0.6, 0.75); + @include mixins.lightDark(opacity, 0.6, 0.75); transition: opacity ease-in-out 120ms; &:hover, &:focus-within { opacity: 1 !important; @@ -469,16 +472,16 @@ body.flexbox { } } } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { .tri-layout-container { grid-template-areas: none; grid-template-columns: 1fr; grid-column-gap: 0; - padding-inline-end: $-xs; - padding-inline-start: $-xs; + padding-inline-end: vars.$xs; + padding-inline-start: vars.$xs; .tri-layout-sides { - padding-inline-start: $-m; - padding-inline-end: $-m; + padding-inline-start: vars.$m; + padding-inline-end: vars.$m; grid-column: 1/1; } .tri-layout-left > *, .tri-layout-right > * { @@ -512,7 +515,7 @@ body.flexbox { } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .tri-layout-container { margin-inline-start: 0; margin-inline-end: 0; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 00aa04f485c..fd76f498ed1 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + .book-contents .entity-list-item { .icon { @@ -6,7 +9,7 @@ justify-self: stretch; align-self: stretch; height: auto; - margin-inline-end: $-xs; + margin-inline-end: vars.$xs; } .icon:after { opacity: 0.5; @@ -25,7 +28,7 @@ .entity-list-item + .chapter-expansion { display: flex; - padding: 0 $-m $-m $-m; + padding: 0 vars.$m vars.$m vars.$m; align-items: center; border: 0; width: 100%; @@ -58,7 +61,7 @@ } .chapter-contents-toggle { border-radius: 0 4px 4px 0; - padding: $-xs ($-m + $-xxs); + padding: vars.$xs (vars.$m + vars.$xxs); width: 100%; text-align: start; } @@ -86,20 +89,20 @@ } .sidebar-page-nav { - $nav-indent: $-m; + $nav-indent: vars.$m; list-style: none; - @include margin($-s, 0, $-m, $-xs); + @include mixins.margin(vars.$s, 0, vars.$m, vars.$xs); position: relative; &:after { content: ''; display: block; position: absolute; left: 0; - @include rtl { + @include mixins.rtl { left: auto; right: 0; } - @include lightDark(background-color, rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); width: 2px; top: 5px; bottom: 5px; @@ -132,7 +135,7 @@ font-weight: bold; } li:not(.current-heading) .sidebar-page-nav-bullet { - @include lightDark(background-color, #BBB, #666, true); + @include mixins.lightDark(background-color, #BBB, #666, true); } .sidebar-page-nav-bullet { width: 6px; @@ -142,9 +145,9 @@ top: 30%; border-radius: 50%; box-shadow: 0 0 0 6px #F2F2F2; - @include lightDark(box-shadow, 0 0 0 6px #F2F2F2, 0 0 0 6px #111); + @include mixins.lightDark(box-shadow, 0 0 0 6px #F2F2F2, 0 0 0 6px #111); z-index: 1; - @include rtl { + @include mixins.rtl { left: auto; right: -2px; } @@ -154,7 +157,7 @@ // Sidebar list .book-tree .sidebar-page-list { list-style: none; - @include margin($-xs, -$-s, 0, -$-s); + @include mixins.margin(vars.$xs, -(vars.$s), 0, -(vars.$s)); padding-inline-start: 0; padding-inline-end: 0; @@ -172,22 +175,22 @@ padding-inline-end: 0; .content { width: 100%; - padding-top: $-xs; - padding-bottom: $-xs; + padding-top: vars.$xs; + padding-bottom: vars.$xs; max-width: calc(100% - 20px); } } .entity-list-item.selected { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } .entity-list-item.no-hover { - margin-top: -$-xs; + margin-top: -(vars.$xs); padding-inline-end: 0; } .entity-list-item-name { font-size: 1em; margin: 0; - margin-inline-end: $-m; + margin-inline-end: vars.$m; } .chapter-child-menu { font-size: .8rem; @@ -198,13 +201,13 @@ display: block; width: 100%; text-align: start; - padding: $-xxs $-s ($-xxs * 2) $-s; + padding: vars.$xxs vars.$s (vars.$xxs * 2) vars.$s; border-radius: 0 3px 3px 0; line-height: 1; - margin-top: -$-xxs; - margin-bottom: -$-xxs; + margin-top: -(vars.$xxs); + margin-bottom: -(vars.$xxs); &:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } } .entity-list-item .icon { @@ -236,8 +239,8 @@ list-style: none; } .sort-box { - margin-bottom: $-m; - padding: $-m $-xl; + margin-bottom: vars.$m; + padding: vars.$m vars.$xl; position: relative; &::before { pointer-events: none; @@ -279,14 +282,14 @@ margin-inline-start: 0; } .sortable-page-sublist { - margin-bottom: $-m; + margin-bottom: vars.$m; margin-top: 0; - padding-inline-start: $-m; + padding-inline-start: vars.$m; } li { - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); border: 1px solid; - @include lightDark(border-color, #DDD, #666); + @include mixins.lightDark(border-color, #DDD, #666); margin-top: -1px; min-height: 38px; } @@ -294,7 +297,7 @@ border-inline-start: 2px solid currentColor; } li:first-child { - margin-top: $-xs; + margin-top: vars.$xs; } } .sortable-page-list li.placeholder { @@ -335,21 +338,21 @@ details.sort-box[open] summary .caret-container svg { } .activity-list-item { - padding: $-s 0; + padding: vars.$s 0; display: grid; grid-template-columns: min-content 1fr; - grid-column-gap: $-m; + grid-column-gap: vars.$m; font-size: 0.9em; } .card .activity-list-item { - padding-block: $-s; + padding-block: vars.$s; } .user-list-item { display: inline-grid; - padding: $-s; + padding: vars.$s; grid-template-columns: min-content 1fr; - grid-column-gap: $-m; + grid-column-gap: vars.$m; font-size: 0.9em; align-items: center; > div:first-child { @@ -360,7 +363,7 @@ details.sort-box[open] summary .caret-container svg { ul.pagination { display: inline-flex; list-style: none; - margin: $-m 0; + margin: vars.$m 0; padding-inline-start: 1px; li:first-child { a, span { @@ -374,19 +377,19 @@ ul.pagination { } a, span { display: block; - padding: $-xxs $-s; + padding: vars.$xxs vars.$s; border: 1px solid #CCC; margin-inline-start: -1px; user-select: none; - @include lightDark(color, #555, #eee); - @include lightDark(border-color, #ccc, #666); + @include mixins.lightDark(color, #555, #eee); + @include mixins.lightDark(border-color, #ccc, #666); } li.disabled { cursor: not-allowed; } li.active span { - @include lightDark(color, #111, #eee); - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.5)); + @include mixins.lightDark(color, #111, #eee); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.5)); } } @@ -395,7 +398,7 @@ ul.pagination { } .entity-list, .icon-list { - margin: 0 (-$-m); + margin: 0 (-(vars.$m)); h4 { margin: 0; } @@ -405,7 +408,7 @@ ul.pagination { .text-small.text-muted { color: #AAA; font-size: 0.75em; - margin-top: $-xs; + margin-top: vars.$xs; } .text-muted p.text-muted { margin-top: 0; @@ -420,7 +423,7 @@ ul.pagination { } .icon-list hr { - margin: $-s $-m; + margin: vars.$s vars.$m; max-width: 140px; opacity: 0.25; height: 1.1px; @@ -431,10 +434,10 @@ ul.pagination { } .entity-list-item, .icon-list-item { - padding: $-s $-m; + padding: vars.$s vars.$m; display: flex; align-items: center; - gap: $-m; + gap: vars.$m; background-color: transparent; border: 0; width: 100%; @@ -458,7 +461,7 @@ ul.pagination { cursor: pointer; } &:not(.no-hover):hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); text-decoration: none; border-radius: 4px; } @@ -466,7 +469,7 @@ ul.pagination { background-color: transparent; } &:focus { - @include lightDark(background-color, #eee, #222); + @include mixins.lightDark(background-color, #eee, #222); outline: 1px dotted #666; outline-offset: -2px; } @@ -493,7 +496,7 @@ ul.pagination { .split-icon-list-item { display: flex; align-items: center; - gap: $-m; + gap: vars.$m; background-color: transparent; border: 0; width: 100%; @@ -501,10 +504,10 @@ ul.pagination { word-break: break-word; border-radius: 4px; > a { - padding: $-s $-m; + padding: vars.$s vars.$m; display: flex; align-items: center; - gap: $-m; + gap: vars.$m; flex: 1; } > a:hover { @@ -515,7 +518,7 @@ ul.pagination { flex: none; } &:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } } @@ -532,21 +535,21 @@ ul.pagination { visibility: visible; } .icon-list-item-dropdown-toggle { - padding: $-xs; + padding: vars.$xs; display: flex; align-items: center; cursor: pointer; - @include lightDark(color, #888, #999); + @include mixins.lightDark(color, #888, #999); svg { margin: 0; } &:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } } .card .entity-list-item:not(.no-hover, .book-contents .entity-list-item):hover { - @include lightDark(background-color, #F2F2F2, #2d2d2d); + @include mixins.lightDark(background-color, #F2F2F2, #2d2d2d); border-radius: 0; } .card .entity-list-item .entity-list-item:hover { @@ -554,10 +557,10 @@ ul.pagination { } .entity-list-item-children { - padding: $-m $-l; + padding: vars.$m vars.$l; > div { overflow: hidden; - padding: 0 0 $-xs 0; + padding: 0 0 vars.$xs 0; } .entity-chip { text-overflow: ellipsis; @@ -568,7 +571,7 @@ ul.pagination { white-space: nowrap; } > .entity-list > .entity-list-item:last-child { - margin-bottom: -$-xs; + margin-bottom: -(vars.$xs); } } @@ -580,22 +583,22 @@ ul.pagination { background-position: 50% 50%; border-radius: 3px; position: relative; - margin-inline-end: $-l; + margin-inline-end: vars.$l; &.entity-list-item-image-wide { width: 220px; } .svg-icon { - @include lightDark(color, #fff, rgba(255, 255, 255, 0.6)); + @include mixins.lightDark(color, #fff, rgba(255, 255, 255, 0.6)); font-size: 1.66rem; margin-inline-end: 0; position: absolute; - bottom: $-xs; - left: $-xs; + bottom: vars.$xs; + left: vars.$xs; } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { width: 80px; } } @@ -605,7 +608,7 @@ ul.pagination { } .entity-list.compact { - font-size: 0.6 * $fs-m; + font-size: 0.6 * vars.$fs-m; h4, a { line-height: 1.2; } @@ -613,8 +616,8 @@ ul.pagination { display: none; } .entity-list-item p { - font-size: $fs-m * 0.8; - padding-top: $-xs; + font-size: vars.$fs-m * 0.8; + padding-top: vars.$xs; } .entity-list-item p:empty { padding-top: 0; @@ -624,12 +627,12 @@ ul.pagination { } > p.empty-text { display: block; - font-size: $fs-m; + font-size: vars.$fs-m; } hr { margin: 0; } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { h4 { font-size: 1.666em; } @@ -660,13 +663,13 @@ ul.pagination { top: 0; list-style: none; inset-inline-end: 0; - margin: $-m 0; - @include lightDark(background-color, #fff, #333); + margin: vars.$m 0; + @include mixins.lightDark(background-color, #fff, #333); box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.18); border-radius: 3px; min-width: 180px; - padding: $-xs 0; - @include lightDark(color, #555, #eee); + padding: vars.$xs 0; + @include mixins.lightDark(color, #555, #eee); fill: currentColor; text-align: start !important; max-height: 500px; @@ -697,11 +700,11 @@ ul.pagination { border-bottom: 1px solid #DDD; } li hr { - margin: $-xs 0; + margin: vars.$xs 0; } .icon-item, .text-item, .label-item { - padding: 8px $-m; - @include lightDark(color, #555, #eee); + padding: 8px vars.$m; + @include mixins.lightDark(color, #555, #eee); fill: currentColor; white-space: nowrap; line-height: 1.4; @@ -721,7 +724,7 @@ ul.pagination { outline-offset: -2px; } svg { - margin-inline-end: $-s; + margin-inline-end: vars.$s; display: inline-block; width: 16px; } @@ -733,7 +736,7 @@ ul.pagination { display: grid; align-items: center; grid-template-columns: auto min-content; - gap: $-m; + gap: vars.$m; } .label-item > *:nth-child(2) { opacity: 0.7; @@ -745,7 +748,7 @@ ul.pagination { display: grid; align-items: start; grid-template-columns: 16px auto; - gap: $-m; + gap: vars.$m; svg { margin-inline-end: 0; margin-block-start: 1px; @@ -757,7 +760,7 @@ ul.pagination { // being cut by scrollable container. .tri-layout-right .dropdown-menu, .tri-layout-left .dropdown-menu { - inset-inline-end: $-xs; + inset-inline-end: vars.$xs; } // Books grid view @@ -781,7 +784,7 @@ ul.pagination { .featured-image-container-wrap { position: relative; .svg-icon { - @include lightDark(color, #fff, rgba(255, 255, 255, 0.6)); + @include mixins.lightDark(color, #fff, rgba(255, 255, 255, 0.6)); font-size: 2rem; margin-inline-end: 0; position: absolute; @@ -800,30 +803,30 @@ ul.pagination { background: transparent; border: none; color: currentColor; - padding: $-m 0; + padding: vars.$m 0; } .active-link-list { a { display: inline-block; - padding: $-s; + padding: vars.$s; } a:not(.active) { - @include lightDark(color, #444, #888); + @include mixins.lightDark(color, #444, #888); } a:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); border-radius: 4px; text-decoration: none; } &.in-sidebar { a { display: block; - margin-bottom: $-xs; + margin-bottom: vars.$xs; } a.active { border-radius: 4px; - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } } } @@ -833,7 +836,7 @@ ul.pagination { line-height: 1.2; margin: 0.6em 0; align-content: start; - gap: $-s; + gap: vars.$s; a { line-height: 1.2; } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 426f7961c72..17bcfcfbf11 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + .page-editor { display: flex; flex-direction: column; @@ -22,8 +25,8 @@ .page-editor-page-area { width: 100%; border-radius: 8px; - box-shadow: $bs-card; - @include lightDark(background-color, #FFF, #333) + box-shadow: vars.$bs-card; + @include mixins.lightDark(background-color, #FFF, #333) } .page-edit-toolbar { @@ -34,7 +37,7 @@ align-items: center; } -@include larger-than($xxl) { +@include mixins.larger-than(vars.$bp-xxl) { .page-editor-wysiwyg2024 .page-edit-toolbar, .page-editor-wysiwyg2024 .page-editor-page-area, .page-editor-wysiwyg .page-edit-toolbar, @@ -48,7 +51,7 @@ } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .page-edit-toolbar { display: flex; flex-direction: row; @@ -76,7 +79,7 @@ position: relative; outline-offset: -1px; outline: 1px dashed var(--color-primary); - box-shadow: $bs-card; + box-shadow: vars.$bs-card; z-index: 50; } } @@ -96,7 +99,7 @@ body.tox-fullscreen, body.markdown-fullscreen { } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .page-edit-toolbar { overflow-x: scroll; overflow-y: visible; @@ -116,9 +119,9 @@ body.tox-fullscreen, body.markdown-fullscreen { width: 52px; height: 52px; font-size: 26px; - inset-inline-end: $-xs; - bottom: $-s; - box-shadow: $bs-hover; + inset-inline-end: vars.$xs; + bottom: vars.$s; + box-shadow: vars.$bs-hover; background-color: currentColor; text-align: center; svg { @@ -150,10 +153,10 @@ body.tox-fullscreen, body.markdown-fullscreen { } .pointer { border: 1px solid #CCC; - @include lightDark(border-color, #ccc, #000); + @include mixins.lightDark(border-color, #ccc, #000); border-radius: 4px; box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1); - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(background-color, #fff, #333); width: 275px; &.is-page-editable { @@ -174,8 +177,8 @@ body.tox-fullscreen, body.markdown-fullscreen { border-right: 1px solid #CCC; border-bottom: 1px solid #CCC; z-index: 56; - @include lightDark(background-color, #fff, #333); - @include lightDark(border-color, #ccc, #000); + @include mixins.lightDark(background-color, #fff, #333); + @include mixins.lightDark(border-color, #ccc, #000); } input, button, a { position: relative; @@ -188,14 +191,14 @@ body.tox-fullscreen, body.markdown-fullscreen { input { background-color: #FFF; border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); color: #666; width: 160px; z-index: 40; padding: 5px 10px; } .text-button { - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(color, #444, #AAA); } .input-group .button { line-height: 1; @@ -210,22 +213,22 @@ body.tox-fullscreen, body.markdown-fullscreen { height: 1.2em; } .button { - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); } } // Page editor sidebar toolbox .floating-toolbox { - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); overflow: hidden; align-items: stretch; flex-direction: row; display: flex; max-height: 100%; border-radius: 8px; - box-shadow: $bs-card; + box-shadow: vars.$bs-card; margin-bottom: auto; - margin-inline-start: $-l; + margin-inline-start: vars.$l; position: relative; &.open { position: relative; @@ -254,23 +257,23 @@ body.tox-fullscreen, body.markdown-fullscreen { } .tabs { border-inline-end: 1px solid #DDD; - @include lightDark(border-inline-end-color, #DDD, #000); + @include mixins.lightDark(border-inline-end-color, #DDD, #000); width: 40px; flex: 0 1 auto; margin-inline-end: -1px; } .tabs-inner { - @include lightDark(background-color, #FFFFFF, #222); + @include mixins.lightDark(background-color, #FFFFFF, #222); } .tabs svg { padding: 0; margin: 0; } .tabs-inner > button { - @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.5)); + @include mixins.lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.5)); display: block; cursor: pointer; - padding: 10px $-xs; + padding: 10px vars.$xs; font-size: 18px; line-height: 1.6; } @@ -295,8 +298,8 @@ body.tox-fullscreen, body.markdown-fullscreen { } h4 { font-size: 24px; - margin: $-m 0 0 0; - padding: 0 $-l $-s $-l; + margin: vars.$m 0 0 0; + padding: 0 vars.$l vars.$s vars.$l; } .tags input { max-width: 100%; @@ -304,8 +307,8 @@ body.tox-fullscreen, body.markdown-fullscreen { min-width: 50px; } .tags td, .inline-start-table > div > div > div { - padding-inline-end: $-s; - padding-top: $-s; + padding-inline-end: vars.$s; + padding-top: vars.$s; position: relative; } .handle { @@ -324,13 +327,13 @@ body.tox-fullscreen, body.markdown-fullscreen { } } -@include smaller-than($xxl) { +@include mixins.smaller-than(vars.$bp-xxl) { .floating-toolbox { - margin-inline-start: $-s; + margin-inline-start: vars.$s; } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .page-editor-page-area-wrap { margin: 4px !important; } @@ -429,7 +432,7 @@ body.tox-fullscreen, body.markdown-fullscreen { border-radius: 3px; position: relative; overflow: hidden; - padding: $-xs $-s; + padding: vars.$xs vars.$s; fill: currentColor; opacity: 0.85; transition: opacity ease-in-out 120ms; diff --git a/resources/sass/_spacing.scss b/resources/sass/_spacing.scss index 14f8918dcb4..6b5e2f837d9 100644 --- a/resources/sass/_spacing.scss +++ b/resources/sass/_spacing.scss @@ -1,8 +1,10 @@ +@use "vars"; + // Here we generate spacing utility classes for our sizes for all box sides and axis. // These will output to classes like .px-m (Padding on x-axis, medium size) or .mr-l (Margin right, large size) @mixin spacing($prop, $propLetter) { - @each $sizeLetter, $size in $spacing { + @each $sizeLetter, $size in vars.$spacing { .#{$propLetter}-#{$sizeLetter} { #{$prop}: $size !important; } @@ -31,7 +33,7 @@ @include spacing('margin', 'm'); @include spacing('padding', 'p'); -@each $sizeLetter, $size in $spacing { +@each $sizeLetter, $size in vars.$spacing { .gap-#{$sizeLetter} { gap: $size !important; } diff --git a/resources/sass/_tables.scss b/resources/sass/_tables.scss index a3da3362122..16be32fb39b 100644 --- a/resources/sass/_tables.scss +++ b/resources/sass/_tables.scss @@ -1,8 +1,11 @@ +@use "mixins"; +@use "vars"; + table { min-width: 100px; max-width: 100%; thead { - @include lightDark(background-color, #f8f8f8, #333); + @include mixins.lightDark(background-color, #f8f8f8, #333); font-weight: 500; } td, th { @@ -27,7 +30,7 @@ table.table { th, td { text-align: start; border: none; - padding: $-s $-s; + padding: vars.$s vars.$s; vertical-align: middle; margin: 0; overflow: visible; @@ -36,7 +39,7 @@ table.table { font-weight: bold; } tr:hover { - @include lightDark(background-color, #F2F2F2, #333); + @include mixins.lightDark(background-color, #F2F2F2, #333); } .text-right { text-align: end; @@ -51,10 +54,10 @@ table.table { display: inline-block; } &.expand-to-padding { - margin-left: -$-s; - margin-right: -$-s; - width: calc(100% + (2*#{$-s})); - max-width: calc(100% + (2*#{$-s})); + margin-left: -(vars.$s); + margin-right: -(vars.$s); + width: calc(100% + (2*#{vars.$s})); + max-width: calc(100% + (2*#{vars.$s})); } } @@ -66,10 +69,10 @@ table.no-style { } table.list-table { - margin: 0 (-$-xs); + margin: 0 (-(vars.$xs)); td { border: 0; vertical-align: middle; - padding: $-xs; + padding: vars.$xs; } } \ No newline at end of file diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 56e66195c38..04fd31e6df7 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Fonts */ @@ -43,7 +46,7 @@ h1, h2, h3, h4, h5, h6 { position: relative; display: block; font-family: var(--font-heading, var(--font-body)); - @include lightDark(color, #222, #BBB); + @include mixins.lightDark(color, #222, #BBB); } h5 { @@ -56,7 +59,7 @@ h5, h6 { margin-bottom: 0.66em; } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { h1 { font-size: 2.8275em; } @@ -133,16 +136,16 @@ p, ul, ol, pre, table, blockquote { hr { border: 0; height: 1px; - @include lightDark(background, #eaeaea, #555); - margin-bottom: $-l; + @include mixins.lightDark(background, #eaeaea, #555); + margin-bottom: vars.$l; &.faded { background-image: linear-gradient(to right, #FFF, #e3e0e0 20%, #e3e0e0 80%, #FFF); } &.darker { - @include lightDark(background, #DDD, #666); + @include mixins.lightDark(background, #DDD, #666); } &.margin-top, &.even { - margin-top: $-l; + margin-top: vars.$l; } } @@ -174,8 +177,8 @@ sub, .subscript { pre { font-size: 12px; border: 1px solid #DDD; - @include lightDark(background-color, #FFF, #2B2B2B); - @include lightDark(border-color, #DDD, #111); + @include mixins.lightDark(background-color, #FFF, #2B2B2B); + @include mixins.lightDark(border-color, #DDD, #111); border-radius: 4px; padding-inline-start: 26px; position: relative; @@ -189,8 +192,8 @@ pre { width: 22.4px; inset-inline-start: 0; height: 100%; - @include lightDark(background-color, #f5f5f5, #313335); - @include lightDark(border-inline-end, 1px solid #DDD, none); + @include mixins.lightDark(background-color, #f5f5f5, #313335); + @include mixins.lightDark(border-inline-end, 1px solid #DDD, none); } } @@ -208,16 +211,16 @@ blockquote { position: relative; border-left: 4px solid transparent; border-left-color: var(--color-primary); - @include lightDark(background-color, #f8f8f8, #333); - padding: $-s $-m $-s $-xl; + @include mixins.lightDark(background-color, #f8f8f8, #333); + padding: vars.$s vars.$m vars.$s vars.$xl; overflow: auto; &:before { content: "\201C"; font-size: 2em; font-weight: bold; position: absolute; - top: $-s; - left: $-s; + top: vars.$s; + left: vars.$s; color: #777; } } @@ -238,8 +241,8 @@ blockquote { font-size: 0.84em; border: 1px solid #DDD; border-radius: 3px; - @include lightDark(background-color, #f8f8f8, #2b2b2b); - @include lightDark(border-color, #DDD, #444); + @include mixins.lightDark(background-color, #f8f8f8, #2b2b2b); + @include mixins.lightDark(border-color, #DDD, #444); } code { @@ -252,7 +255,7 @@ code { span.code { @extend .code-base; - padding: 1px $-xs; + padding: 1px vars.$xs; } pre code { @@ -272,8 +275,8 @@ span.highlight { * Lists */ ul, ol { - padding-left: $-m * 2.0; - padding-right: $-m * 2.0; + padding-left: vars.$m * 2.0; + padding-right: vars.$m * 2.0; display: flow-root; p { margin: 0; @@ -300,8 +303,8 @@ li > ol, li > ul { margin-block-start: 0; padding-block-end: 0; padding-block-start: 0; - padding-left: $-m * 1.2; - padding-right: $-m * 1.2; + padding-left: vars.$m * 1.2; + padding-right: vars.$m * 1.2; } /** @@ -312,17 +315,17 @@ li > ol, li > ul { li.checkbox-item, li.task-list-item { display: list-item; list-style: none; - margin-left: -($-m * 1.2); - margin-inline-start: -($-m * 1.2); + margin-left: -(vars.$m * 1.2); + margin-inline-start: -(vars.$m * 1.2); margin-inline-end: 0; input[type="checkbox"] { - margin-right: $-xs; - margin-inline-end: $-xs; + margin-right: vars.$xs; + margin-inline-end: vars.$xs; margin-inline-start: 0; } li.checkbox-item, li.task-list-item { - margin-left: $-xs; - margin-inline-start: $-xs; + margin-left: vars.$xs; + margin-inline-start: vars.$xs; margin-inline-end: 0; } } @@ -344,8 +347,8 @@ li.checkbox-item, li.task-list-item { text-align: end; } -@each $sizeLetter, $size in $screen-sizes { - @include larger-than($size) { +@each $sizeLetter, $size in vars.$screen-sizes { + @include mixins.larger-than($size) { .text-#{$sizeLetter}-center { text-align: center; } @@ -395,7 +398,7 @@ li.checkbox-item, li.task-list-item { * Grouping */ .header-group { - margin: $-m 0; + margin: vars.$m 0; h1, h2, h3, h4, h5, h6 { margin: 0; } @@ -403,7 +406,7 @@ li.checkbox-item, li.task-list-item { span.sep { color: #BBB; - padding: 0 $-xs; + padding: 0 vars.$xs; } .list > * { @@ -419,7 +422,7 @@ span.sep { display: inline-block; position: relative; bottom: -0.105em; - margin-inline-end: $-xs; + margin-inline-end: vars.$xs; pointer-events: none; fill: currentColor; } diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 129e478212b..8cc96df4193 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + // Custom full screen mode .tox.tox-fullscreen { @@ -35,7 +38,7 @@ padding: 1rem; top: 4px; font-style: italic; - @include lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.5)) + @include mixins.lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.5)) } // Default styles for our custom root nodes @@ -74,23 +77,23 @@ body.page-content.mce-content-body { pointer-events: none; } .page-content.mce-content-body details doc-root { - padding: $-s; - margin-left: (2px - $-s); - margin-right: (2px - $-s); - margin-bottom: (2px - $-s); - margin-top: (2px - $-s); + padding: vars.$s; + margin-left: (2px - vars.$s); + margin-right: (2px - vars.$s); + margin-bottom: (2px - vars.$s); + margin-top: (2px - vars.$s); overflow: hidden; } // Allow alignment to be reflected in media embed wrappers .page-content.mce-content-body .mce-preview-object.align-right { float: right !important; - margin: $-xs 0 $-xs $-s; + margin: vars.$xs 0 vars.$xs vars.$s; } .page-content.mce-content-body .mce-preview-object.align-left { float: left !important; - margin: $-xs $-m $-m 0; + margin: vars.$xs vars.$m vars.$m 0; } .page-content.mce-content-body .mce-preview-object.align-center { diff --git a/resources/sass/_variables.scss b/resources/sass/_vars.scss similarity index 85% rename from resources/sass/_variables.scss rename to resources/sass/_vars.scss index 35586bf5830..26539656e7f 100644 --- a/resources/sass/_variables.scss +++ b/resources/sass/_vars.scss @@ -2,30 +2,30 @@ /////////////// // Screen breakpoints -$xxxl: 1700px; -$xxl: 1400px; -$xl: 1100px; -$l: 1000px; -$m: 880px; -$s: 600px; -$xs: 400px; -$xxs: 360px; +$bp-xxxl: 1700px; +$bp-xxl: 1400px; +$bp-xl: 1100px; +$bp-l: 1000px; +$bp-m: 880px; +$bp-s: 600px; +$bp-xs: 400px; +$bp-xxs: 360px; // List of screen sizes -$screen-sizes: (('xxs', $xxs), ('xs', $xs), ('s', $s), ('m', $m), ('l', $l), ('xl', $xl)); +$screen-sizes: (('xxs', $bp-xxs), ('xs', $bp-xs), ('s', $bp-s), ('m', $bp-m), ('l', $bp-l), ('xl', $bp-xl)); // Spacing (Margins+Padding) -$-xxxl: 64px; -$-xxl: 48px; -$-xl: 32px; -$-l: 24px; -$-m: 16px; -$-s: 12px; -$-xs: 6px; -$-xxs: 3px; +$xxxl: 64px; +$xxl: 48px; +$xl: 32px; +$l: 24px; +$m: 16px; +$s: 12px; +$xs: 6px; +$xxs: 3px; // List of our spacing sizes -$spacing: (('none', 0), ('xxs', $-xxs), ('xs', $-xs), ('s', $-s), ('m', $-m), ('l', $-l), ('xl', $-xl), ('xxl', $-xxl), ('auto', auto)); +$spacing: (('none', 0), ('xxs', $xxs), ('xs', $xs), ('s', $s), ('m', $m), ('l', $l), ('xl', $xl), ('xxl', $xxl), ('auto', auto)); // Fonts $font-body: -apple-system, BlinkMacSystemFont, diff --git a/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index cfa1ebdf8a6..8dd7be375e8 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -1,10 +1,11 @@ @use "sass:math"; -@import "variables"; -@import "mixins"; -@import "html"; -@import "text"; -@import "tables"; -@import "content"; + +@use "vars"; +@use "mixins"; +@use "html"; +@use "text"; +@use "tables"; +@use "content"; html, body { background-color: #FFF; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 2106f86e625..c6642d0cad6 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -1,31 +1,31 @@ -@use "sass:math"; +@use "sass:meta"; -@import "reset"; -@import "variables"; -@import "mixins"; -@import "spacing"; -@import "opacity"; -@import "html"; -@import "text"; -@import "colors"; -@import "layout"; -@import "blocks"; -@import "buttons"; -@import "tables"; -@import "forms"; -@import "animations"; -@import "tinymce"; -@import "editor"; -@import "codemirror"; -@import "components"; -@import "header"; -@import "footer"; -@import "lists"; -@import "pages"; -@import "content"; +@use "reset"; +@use "vars"; +@use "mixins"; +@use "spacing"; +@use "opacity"; +@use "html"; +@use "text"; +@use "colors"; +@use "layout"; +@use "blocks"; +@use "buttons"; +@use "tables"; +@use "forms"; +@use "animations"; +@use "tinymce"; +@use "editor"; +@use "codemirror"; +@use "components"; +@use "header"; +@use "footer"; +@use "lists"; +@use "pages"; +@use "content"; @media print { - @import "print"; + @include meta.load-css("print"); } // Jquery Sortable Styles @@ -41,7 +41,7 @@ body.dragging, body.dragging * { // User Avatar Images .avatar { border-radius: 100%; - @include lightDark(background-color, #eee, #000); + @include mixins.lightDark(background-color, #eee, #000); width: 30px; height: 30px; &.med { @@ -60,7 +60,7 @@ body.dragging, body.dragging * { border-radius: 3px; } &[src$="user_avatar.png"] { - @include whenDark { + @include mixins.whenDark { filter: invert(1); } } @@ -71,7 +71,7 @@ $loadingSize: 10px; .loading-container { position: relative; display: block; - margin: $-xl auto; + margin: vars.$xl auto; > div { width: $loadingSize; height: $loadingSize; @@ -79,7 +79,7 @@ $loadingSize: 10px; display: inline-block; vertical-align: top; transform: translate3d(-10px, 0, 0); - margin-top: $-xs; + margin-top: vars.$xs; animation-name: loadingBob; animation-duration: 1.4s; animation-iteration-count: infinite; @@ -89,17 +89,17 @@ $loadingSize: 10px; animation-delay: -300ms; } > div:first-child { - left: -($loadingSize+$-xs); + left: -($loadingSize+vars.$xs); background-color: var(--color-book); animation-delay: -600ms; } > div:last-of-type { - left: $loadingSize+$-xs; + left: $loadingSize+vars.$xs; background-color: var(--color-chapter); animation-delay: 0ms; } > span { - margin-inline-start: $-s; + margin-inline-start: vars.$s; font-style: italic; color: #888; vertical-align: top; @@ -107,7 +107,7 @@ $loadingSize: 10px; } .inline.block .loading-container { - margin: $-xs $-s; + margin: vars.$xs vars.$s; } .skip-to-content-link { @@ -118,10 +118,10 @@ $loadingSize: 10px; z-index: 15; border-radius: 0 4px 4px 0; display: block; - box-shadow: $bs-dark; + box-shadow: vars.$bs-dark; font-weight: bold; &:focus { - top: $-xl; + top: vars.$xl; outline-offset: -10px; outline: 2px dotted var(--color-link); } @@ -129,7 +129,7 @@ $loadingSize: 10px; .entity-selector { border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #111); + @include mixins.lightDark(border-color, #ddd, #111); border-radius: 3px; overflow: hidden; font-size: 0.8em; @@ -140,7 +140,7 @@ $loadingSize: 10px; border: 0; border-bottom: 1px solid #DDD; font-size: 16px; - padding: $-s $-m; + padding: vars.$s vars.$m; } input[type="text"]:focus { outline: 1px solid var(--color-primary); @@ -150,12 +150,12 @@ $loadingSize: 10px; .entity-list { overflow-y: scroll; height: 400px; - @include lightDark(background-color, #eee, #222); + @include mixins.lightDark(background-color, #eee, #222); margin-inline-end: 0; margin-inline-start: 0; } .entity-list-item { - @include lightDark(background-color, #fff, #222); + @include mixins.lightDark(background-color, #fff, #222); } .entity-list-item p { margin-bottom: 0; @@ -165,11 +165,11 @@ $loadingSize: 10px; outline-offset: -4px; } .entity-list-item.selected { - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } .loading { height: 400px; - padding-top: $-l; + padding-top: vars.$l; } &.compact { font-size: 10px; @@ -182,14 +182,14 @@ $loadingSize: 10px; } &.small { .entity-list-item { - padding: $-xs $-m; + padding: vars.$xs vars.$m; } .entity-list, .loading { height: 300px; } input[type="text"] { font-size: 13px; - padding: $-xs $-m; + padding: vars.$xs vars.$m; height: auto; } } @@ -207,11 +207,11 @@ $loadingSize: 10px; z-index: 150; } -@include between($s, $m) { +@include mixins.between(vars.$bp-s, vars.$bp-m) { #home-default > .grid.third { display: block; columns: 2; - column-gap: $-l !important; + column-gap: vars.$l !important; } } @@ -222,29 +222,29 @@ $loadingSize: 10px; } .list-sort { display: inline-grid; - margin-inline-start: $-s; + margin-inline-start: vars.$s; grid-template-columns: minmax(120px, max-content) 40px; font-size: 0.9rem; border: 2px solid #DDD; - @include lightDark(border-color, #ddd, #444); + @include mixins.lightDark(border-color, #ddd, #444); border-radius: 4px; } .list-sort-label { font-weight: bold; display: inline-block; - @include lightDark(color, #555, #888); + @include mixins.lightDark(color, #555, #888); } .list-sort-type { text-align: start; } .list-sort-type, .list-sort-dir { - padding: $-xs $-s; + padding: vars.$xs vars.$s; cursor: pointer; } .list-sort-dir { border-inline-start: 2px solid #DDD; color: #888; - @include lightDark(border-color, #ddd, #444); + @include mixins.lightDark(border-color, #ddd, #444); .svg-icon { transition: transform ease-in-out 120ms; } @@ -256,5 +256,5 @@ $loadingSize: 10px; .import-item { border-inline-start: 2px solid currentColor; - padding-inline-start: $-xs; + padding-inline-start: vars.$xs; } \ No newline at end of file From 5632fef6212001ec8357c854f565b5e6a1df9c0f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 14:22:48 +0000 Subject: [PATCH 252/800] Auth: Added specific guards against guest account login Hardened things to enforce the intent that the guest account should not be used for logins. Currently this would not be allowed due to empty set password, and no password fields on user edit forms, but an error could occur if the login was attempted. This adds: - Handling to show normal invalid user warning on login instead of a hash check error. - Prevention of guest user via main login route, in the event that inventive workarounds would be used by admins to set a password for this account. - Test for guest user login. --- app/Access/LoginService.php | 35 +++++++++++++++++-- .../LoginAttemptInvalidUserException.php | 7 ++++ tests/Auth/AuthTest.php | 20 +++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 app/Exceptions/LoginAttemptInvalidUserException.php diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index cc48e0f9bab..6607969afae 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -5,6 +5,7 @@ use BookStack\Access\Mfa\MfaSession; use BookStack\Activity\ActivityType; use BookStack\Exceptions\LoginAttemptException; +use BookStack\Exceptions\LoginAttemptInvalidUserException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Facades\Activity; use BookStack\Facades\Theme; @@ -29,10 +30,14 @@ public function __construct( * a reason to (MFA or Unconfirmed Email). * Returns a boolean to indicate the current login result. * - * @throws StoppedAuthenticationException + * @throws StoppedAuthenticationException|LoginAttemptInvalidUserException */ public function login(User $user, string $method, bool $remember = false): void { + if ($user->isGuest()) { + throw new LoginAttemptInvalidUserException('Login not allowed for guest user'); + } + if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) { $this->setLastLoginAttemptedForUser($user, $method, $remember); @@ -58,7 +63,7 @@ public function login(User $user, string $method, bool $remember = false): void * * @throws Exception */ - public function reattemptLoginFor(User $user) + public function reattemptLoginFor(User $user): void { if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) { throw new Exception('Login reattempt user does align with current session state'); @@ -152,16 +157,40 @@ public function awaitingEmailConfirmation(User $user): bool */ public function attempt(array $credentials, string $method, bool $remember = false): bool { + if ($this->areCredentialsForGuest($credentials)) { + return false; + } + $result = auth()->attempt($credentials, $remember); if ($result) { $user = auth()->user(); auth()->logout(); - $this->login($user, $method, $remember); + try { + $this->login($user, $method, $remember); + } catch (LoginAttemptInvalidUserException $e) { + // Catch and return false for non-login accounts + // so it looks like a normal invalid login. + return false; + } } return $result; } + /** + * Check if the given credentials are likely for the system guest account. + */ + protected function areCredentialsForGuest(array $credentials): bool + { + if (isset($credentials['email'])) { + return User::query()->where('email', '=', $credentials['email']) + ->where('system_name', '=', 'public') + ->exists(); + } + + return false; + } + /** * Logs the current user out of the application. * Returns an app post-redirect path. diff --git a/app/Exceptions/LoginAttemptInvalidUserException.php b/app/Exceptions/LoginAttemptInvalidUserException.php new file mode 100644 index 00000000000..163484c5aa2 --- /dev/null +++ b/app/Exceptions/LoginAttemptInvalidUserException.php @@ -0,0 +1,7 @@ +assertSee('Too many login attempts. Please try again in'); } + public function test_login_specifically_disabled_for_guest_account() + { + $guest = $this->users->guest(); + + $resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']); + $resp->assertRedirect('/login'); + $resp = $this->followRedirects($resp); + $resp->assertSee('These credentials do not match our records.'); + + // Test login even with password somehow set + $guest->password = Hash::make('password'); + $guest->save(); + + $resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']); + $resp->assertRedirect('/login'); + $resp = $this->followRedirects($resp); + $resp->assertSee('These credentials do not match our records.'); + } + /** * Perform a login. */ From 509af2463d94210fd500c10c651fa8f883e87fd8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 15:53:57 +0000 Subject: [PATCH 253/800] Search Index: Fixed SQL error when indexing large pages Due to hitting statement placeholder limits (typically 65k) when inserting index terms for single page. Added test to cover. Also added skipped tests for tests we don't always want to run. For #5322 --- app/Search/SearchIndex.php | 20 +++++++++++++++----- tests/Entity/EntitySearchTest.php | 20 ++++++++++++++++++++ tests/LanguageTest.php | 4 +++- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index d9fc4e7aadc..c7d9d6502e2 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -30,7 +30,7 @@ public function indexEntity(Entity $entity): void { $this->deleteEntityTerms($entity); $terms = $this->entityToTermDataArray($entity); - SearchTerm::query()->insert($terms); + $this->insertTerms($terms); } /** @@ -46,10 +46,7 @@ public function indexEntities(array $entities): void array_push($terms, ...$entityTerms); } - $chunkedTerms = array_chunk($terms, 500); - foreach ($chunkedTerms as $termChunk) { - SearchTerm::query()->insert($termChunk); - } + $this->insertTerms($terms); } /** @@ -99,6 +96,19 @@ public function deleteEntityTerms(Entity $entity): void $entity->searchTerms()->delete(); } + /** + * Insert the given terms into the database. + * Chunks through the given terms to remain within database limits. + * @param array[] $terms + */ + protected function insertTerms(array $terms): void + { + $chunkedTerms = array_chunk($terms, 500); + foreach ($chunkedTerms as $termChunk) { + SearchTerm::query()->insert($termChunk); + } + } + /** * Create a scored term array from the given text, where the keys are the terms * and the values are their scores. diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index cabf23bd3d3..5ace70e3ab2 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use Illuminate\Support\Str; use Tests\TestCase; class EntitySearchTest extends TestCase @@ -477,6 +478,25 @@ public function test_terms_in_headers_have_an_adjusted_index_score() $this->assertEquals(2, $scoreByTerm->get('TermG')); } + public function test_indexing_works_as_expected_for_page_with_lots_of_terms() + { + $this->markTestSkipped('Time consuming test'); + + $count = 100000; + $text = ''; + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#'; + for ($i = 0; $i < $count; $i++) { + $text .= substr(str_shuffle($chars), 0, 5) . ' '; + } + + $page = $this->entities->newPage(['name' => 'Test page A', 'html' => '

                                ' . $text . '

                                ']); + + $termCount = $page->searchTerms()->count(); + + // Expect at least 90% unique rate + $this->assertGreaterThan($count * 0.9, $termCount); + } + public function test_name_and_content_terms_are_merged_to_single_score() { $page = $this->entities->newPage(['name' => 'TermA', 'html' => ' diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index abe06407e1e..28491c3af9d 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -27,8 +27,10 @@ public function test_locales_list_set_properly() } // Not part of standard phpunit test runs since we sometimes expect non-added langs. - public function do_test_locales_all_have_language_dropdown_entry() + public function test_locales_all_have_language_dropdown_entry() { + $this->markTestSkipped('Only used when checking language inclusion'); + $dropdownLocales = array_keys(trans('settings.language_select', [], 'en')); sort($dropdownLocales); sort($this->langs); From 0ece664475180119b6e5c6c589302628c080cc71 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 18:50:10 +0000 Subject: [PATCH 254/800] CI: Added php8.4 to CI suites, bumped action/os versions --- .github/workflows/analyse-php.yml | 4 ++-- .github/workflows/lint-php.yml | 6 +++--- .github/workflows/test-migrations.yml | 6 +++--- .github/workflows/test-php.yml | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/analyse-php.yml b/.github/workflows/analyse-php.yml index dbeaf94724a..647835aeb2f 100644 --- a/.github/workflows/analyse-php.yml +++ b/.github/workflows/analyse-php.yml @@ -11,9 +11,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: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml index b2f8b3d2b4a..cb9dedcb25a 100644 --- a/.github/workflows/lint-php.yml +++ b/.github/workflows/lint-php.yml @@ -11,14 +11,14 @@ 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: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 tools: phpcs - name: Run formatting check diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 10cfbe17204..2d6d280b2ee 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -13,12 +13,12 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 269f326320a..ee9cf39bc2a 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -16,9 +16,9 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 From fcf0bf79a98c9b9b545d36ef728ee9cbe054048e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 20:38:30 +0000 Subject: [PATCH 255/800] Attachments: Hid edit/delete controls where lacking permission Added test to cover. Also migrated related ajax-delete-row component to ts. For #5323 --- ...{ajax-delete-row.js => ajax-delete-row.ts} | 12 +++-- resources/js/components/component.js | 6 +-- .../views/attachments/manager-list.blade.php | 34 +++++++------- tests/Uploads/AttachmentTest.php | 44 +++++++++++++++++++ 4 files changed, 74 insertions(+), 22 deletions(-) rename resources/js/components/{ajax-delete-row.js => ajax-delete-row.ts} (65%) diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.ts similarity index 65% rename from resources/js/components/ajax-delete-row.js rename to resources/js/components/ajax-delete-row.ts index 6ed3deedf4d..4c7942a9e36 100644 --- a/resources/js/components/ajax-delete-row.js +++ b/resources/js/components/ajax-delete-row.ts @@ -1,12 +1,16 @@ -import {onSelect} from '../services/dom.ts'; +import {onSelect} from '../services/dom'; import {Component} from './component'; export class AjaxDeleteRow extends Component { + protected row!: HTMLElement; + protected url!: string; + protected deleteButtons: HTMLElement[] = []; + setup() { this.row = this.$el; this.url = this.$opts.url; - this.deleteButtons = this.$manyRefs.delete; + this.deleteButtons = this.$manyRefs.delete || []; onSelect(this.deleteButtons, this.runDelete.bind(this)); } @@ -21,8 +25,8 @@ export class AjaxDeleteRow extends Component { } this.row.remove(); }).catch(() => { - this.row.style.opacity = null; - this.row.style.pointerEvents = null; + this.row.style.removeProperty('opacity'); + this.row.style.removeProperty('pointer-events'); }); } diff --git a/resources/js/components/component.js b/resources/js/components/component.js index 654f41a9664..c23898bbcbb 100644 --- a/resources/js/components/component.js +++ b/resources/js/components/component.js @@ -8,20 +8,20 @@ export class Component { /** * The element that the component is registered upon. - * @type {Element} + * @type {HTMLElement} */ $el = null; /** * Mapping of referenced elements within the component. - * @type {Object} + * @type {Object} */ $refs = {}; /** * Mapping of arrays of referenced elements within the component so multiple * references, sharing the same name, can be fetched. - * @type {Object} + * @type {Object} */ $manyRefs = {}; diff --git a/resources/views/attachments/manager-list.blade.php b/resources/views/attachments/manager-list.blade.php index 0e841a042f7..6314aa7b5d7 100644 --- a/resources/views/attachments/manager-list.blade.php +++ b/resources/views/attachments/manager-list.blade.php @@ -15,23 +15,27 @@ class="card drag-card"> option:event-emit-select:name="insert" type="button" title="{{ trans('entities.attachments_insert_link') }}" - class="drag-card-action text-center text-link">@icon('link') - -
                                - + @if(userCan('attachment-update', $attachment)) + - + @endif
                                @endforeach diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index de448d93a4c..2eaf21d9c6f 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -267,6 +267,50 @@ public function test_data_and_js_links_cannot_be_attached_to_a_page() } } + public function test_attachment_delete_only_shows_with_permission() + { + $this->asAdmin(); + $page = $this->entities->page(); + $this->files->uploadAttachmentFile($this, 'upload_test.txt', $page->id); + $attachment = $page->attachments()->first(); + $viewer = $this->users->viewer(); + + $this->permissions->grantUserRolePermissions($viewer, ['page-update-all', 'attachment-create-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"]"); + $html->assertElementNotExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Delete\"]"); + + $this->permissions->grantUserRolePermissions($viewer, ['attachment-delete-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Delete\"]"); + } + + public function test_attachment_edit_only_shows_with_permission() + { + $this->asAdmin(); + $page = $this->entities->page(); + $this->files->uploadAttachmentFile($this, 'upload_test.txt', $page->id); + $attachment = $page->attachments()->first(); + $viewer = $this->users->viewer(); + + $this->permissions->grantUserRolePermissions($viewer, ['page-update-all', 'attachment-create-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"]"); + $html->assertElementNotExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Edit\"]"); + + $this->permissions->grantUserRolePermissions($viewer, ['attachment-update-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Edit\"]"); + } + public function test_file_access_with_open_query_param_provides_inline_response_with_correct_content_type() { $page = $this->entities->page(); From 19ee1c9be740de037dbe61ec8e82e74f24276322 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 12 Dec 2024 21:45:52 +0000 Subject: [PATCH 256/800] Notifications: Logged errors and prevented them blocking user Failed notification sends could block the user action, whereas it's probably more important that the user action takes places uninteruupted than showing an error screen for the user to debug. Logs notification errors so issues can still be debugged by admins. Closes #5315 --- .../Handlers/BaseNotificationHandler.php | 7 +++++- tests/Activity/WatchTest.php | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php index b5f339b2ce0..3a9b0c1dc80 100644 --- a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Log; abstract class BaseNotificationHandler implements NotificationHandler { @@ -36,7 +37,11 @@ protected function sendNotificationToUserIds(string $notification, array $userId } // Send the notification - $user->notify(new $notification($detail, $initiator)); + try { + $user->notify(new $notification($detail, $initiator)); + } catch (\Exception $exception) { + Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}"); + } } } } diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index 605b60fd498..c405b07aed7 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -13,6 +13,8 @@ use BookStack\Activity\WatchLevels; use BookStack\Entities\Models\Entity; use BookStack\Settings\UserNotificationPreferences; +use Illuminate\Contracts\Notifications\Dispatcher; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -365,6 +367,29 @@ public function test_notifications_sent_in_right_language() } } + public function test_failed_notifications_dont_block_and_log_errors() + { + $logger = $this->withTestLogger(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $page = $this->entities->page(); + $book = $page->book; + $activityLogger = app()->make(ActivityLogger::class); + + $watches = new UserEntityWatchOptions($editor, $book); + $watches->updateLevelByValue(WatchLevels::UPDATES); + + $mockDispatcher = $this->mock(Dispatcher::class); + $mockDispatcher->shouldReceive('send')->once() + ->andThrow(\Exception::class, 'Failed to connect to mail server'); + + $this->actingAs($admin); + + $activityLogger->add(ActivityType::PAGE_UPDATE, $page); + + $this->assertTrue($logger->hasErrorThatContains("Failed to send email notification to user [id:{$editor->id}] with error: Failed to connect to mail server")); + } + public function test_notifications_not_sent_if_lacking_view_permission_for_related_item() { $notifications = Notification::fake(); From 7e1a8e5ec676a2f9a27acef71da6d6c0b5916384 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Dec 2024 14:21:04 +0000 Subject: [PATCH 257/800] API: Added cover to book/shelf list endpoints Aligns with what we provide in the UI. Added/updated tests to cover, and updated API examples. For 5180. --- .../Controllers/BookApiController.php | 1 + .../Controllers/BookshelfApiController.php | 1 + dev/api/responses/books-list.json | 10 ++++++-- dev/api/responses/shelves-list.json | 13 ++++++++--- tests/Api/BooksApiTest.php | 23 +++++++++++++++++++ tests/Api/ShelvesApiTest.php | 23 +++++++++++++++++++ tests/Helpers/EntityProvider.php | 1 + 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index c1e38e72fe7..a617ee2da68 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -30,6 +30,7 @@ public function list() { $books = $this->queries ->visibleForList() + ->with(['cover:id,name,url']) ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($books, [ diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index a665bcb6bab..b512f2d0553 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -26,6 +26,7 @@ public function list() { $shelves = $this->queries ->visibleForList() + ->with(['cover:id,name,url']) ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($shelves, [ diff --git a/dev/api/responses/books-list.json b/dev/api/responses/books-list.json index 0f8458feda2..50c8c49e6ef 100644 --- a/dev/api/responses/books-list.json +++ b/dev/api/responses/books-list.json @@ -9,7 +9,8 @@ "updated_at": "2019-12-11T20:57:31.000000Z", "created_by": 1, "updated_by": 1, - "owned_by": 1 + "owned_by": 1, + "cover": null }, { "id": 2, @@ -20,7 +21,12 @@ "updated_at": "2019-12-11T20:57:23.000000Z", "created_by": 4, "updated_by": 3, - "owned_by": 3 + "owned_by": 3, + "cover": { + "id": 11, + "name": "cat_banner.jpg", + "url": "https://example.com/uploads/images/cover_book/2021-10/cat-banner.jpg" + } } ], "total": 14 diff --git a/dev/api/responses/shelves-list.json b/dev/api/responses/shelves-list.json index 4b1a1b43f93..d5debfaef10 100644 --- a/dev/api/responses/shelves-list.json +++ b/dev/api/responses/shelves-list.json @@ -9,7 +9,12 @@ "updated_at": "2020-04-10T13:00:45.000000Z", "created_by": 4, "updated_by": 1, - "owned_by": 1 + "owned_by": 1, + "cover": { + "id": 4, + "name": "shelf.jpg", + "url": "https://example.com/uploads/images/cover_bookshelf/2024-12/shelf.jpg" + } }, { "id": 9, @@ -20,7 +25,8 @@ "updated_at": "2020-04-10T13:00:58.000000Z", "created_by": 4, "updated_by": 1, - "owned_by": 1 + "owned_by": 1, + "cover": null }, { "id": 10, @@ -31,7 +37,8 @@ "updated_at": "2020-04-10T13:00:53.000000Z", "created_by": 4, "updated_by": 1, - "owned_by": 4 + "owned_by": 4, + "cover": null } ], "total": 3 diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 0de98dc323b..084cb59bd5c 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -3,6 +3,7 @@ namespace Tests\Api; use BookStack\Entities\Models\Book; +use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -27,6 +28,28 @@ public function test_index_endpoint_returns_expected_book() 'owned_by' => $firstBook->owned_by, 'created_by' => $firstBook->created_by, 'updated_by' => $firstBook->updated_by, + 'cover' => null, + ], + ]]); + } + + public function test_index_endpoint_includes_cover_if_set() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $baseRepo = $this->app->make(BaseRepo::class); + $image = $this->files->uploadedImage('book_cover'); + $baseRepo->updateCoverImage($book, $image); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $book->id); + $resp->assertJson(['data' => [ + [ + 'id' => $book->id, + 'cover' => [ + 'id' => $book->cover->id, + 'url' => $book->cover->url, + ], ], ]]); } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index be276e11004..ba13c0153b1 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -28,6 +29,28 @@ public function test_index_endpoint_returns_expected_shelf() 'owned_by' => $firstBookshelf->owned_by, 'created_by' => $firstBookshelf->created_by, 'updated_by' => $firstBookshelf->updated_by, + 'cover' => null, + ], + ]]); + } + + public function test_index_endpoint_includes_cover_if_set() + { + $this->actingAsApiEditor(); + $shelf = $this->entities->shelf(); + + $baseRepo = $this->app->make(BaseRepo::class); + $image = $this->files->uploadedImage('shelf_cover'); + $baseRepo->updateCoverImage($shelf, $image); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $shelf->id); + $resp->assertJson(['data' => [ + [ + 'id' => $shelf->id, + 'cover' => [ + 'id' => $shelf->cover->id, + 'url' => $shelf->cover->url, + ], ], ]]); } diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 1897abefa28..22e554f74b5 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookshelfRepo; From a8ef820443cc444423f0fd21c4e1ce9fb64cbac0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Dec 2024 15:19:09 +0000 Subject: [PATCH 258/800] Users: Hid lanuage preference for guest user Hiding since it's not really used, and may mislead on how to set default app language (which should be done via env options). Updated test to cover. For #5356 --- resources/views/users/edit.blade.php | 4 +++- tests/User/UserManagementTest.php | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 2b736d81ede..611653d6a80 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -34,7 +34,9 @@ class="setting-list-label">{{ trans('settings.users_avatar') }}
                                - @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) + @if(!$user->isGuest()) + @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) + @endif
                                diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index 37d9b383544..d92f13f0b3f 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -202,9 +202,13 @@ public function test_delete_removes_user_preferences() public function test_guest_profile_shows_limited_form() { $guest = $this->users->guest(); + $resp = $this->asAdmin()->get('/settings/users/' . $guest->id); $resp->assertSee('Guest'); - $this->withHtml($resp)->assertElementNotExists('#password'); + $html = $this->withHtml($resp); + + $html->assertElementNotExists('#password'); + $html->assertElementNotExists('[name="language"]'); } public function test_guest_profile_cannot_be_deleted() From 97b201f61f98aaccf779d08634c247c8cfbbfbb5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 14 Dec 2024 12:35:13 +0000 Subject: [PATCH 259/800] Lexical: Added auto links on enter/space --- resources/js/wysiwyg/index.ts | 2 + .../services/__tests__/auto-links.test.ts | 91 +++++++++++++++++++ resources/js/wysiwyg/services/auto-links.ts | 74 +++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 4 + 4 files changed, 171 insertions(+) create mode 100644 resources/js/wysiwyg/services/__tests__/auto-links.test.ts create mode 100644 resources/js/wysiwyg/services/auto-links.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9066b402f33..510ab1f9230 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -15,6 +15,7 @@ import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; import {registerKeyboardHandling} from "./services/keyboard-handling"; +import {registerAutoLinks} from "./services/auto-links"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), registerNodeResizer(context), + registerAutoLinks(editor), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts new file mode 100644 index 00000000000..d3b120b7064 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -0,0 +1,91 @@ +import {initializeUnitTest} from "lexical/__tests__/utils"; +import {SerializedLinkNode} from "@lexical/link"; +import { + $getRoot, + ParagraphNode, + SerializedParagraphNode, + SerializedTextNode, + TextNode +} from "lexical"; +import {registerAutoLinks} from "../auto-links"; + +describe('Auto-link service tests', () => { + initializeUnitTest((testEnv) => { + + test('space after link in text', async () => { + const {editor} = testEnv; + + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.update(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(35, 35); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: ' ', + keyCode: 62, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + const link = paragraph.children[1] as SerializedLinkNode; + expect(link.url).toBe('https://example.com?test=true'); + const linkText = link.children[0] as SerializedTextNode; + expect(linkText.text).toBe('https://example.com?test=true'); + }); + + test('enter after link in text', async () => { + const {editor} = testEnv; + + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.update(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(35, 35); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter', + keyCode: 66, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + const link = paragraph.children[1] as SerializedLinkNode; + expect(link.url).toBe('https://example.com?test=true'); + const linkText = link.children[0] as SerializedTextNode; + expect(linkText.text).toBe('https://example.com?test=true'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts new file mode 100644 index 00000000000..44a78ec8577 --- /dev/null +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -0,0 +1,74 @@ +import { + $getSelection, BaseSelection, + COMMAND_PRIORITY_NORMAL, + KEY_ENTER_COMMAND, + KEY_SPACE_COMMAND, + LexicalEditor, + TextNode +} from "lexical"; +import {$getTextNodeFromSelection} from "../utils/selection"; +import {$createLinkNode, LinkNode} from "@lexical/link"; + + +function isLinkText(text: string): boolean { + const lower = text.toLowerCase(); + if (!lower.startsWith('http')) { + return false; + } + + const linkRegex = /(http|https):\/\/(\S+)\.\S+$/; + return linkRegex.test(text); +} + + +function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) { + const selectionRange = selection.getStartEndPoints(); + if (!selectionRange) { + return; + } + + const cursorPoint = selectionRange[0].offset - 1; + const nodeText = node.getTextContent(); + const rTrimText = nodeText.slice(0, cursorPoint); + const priorSpaceIndex = rTrimText.lastIndexOf(' '); + const startIndex = priorSpaceIndex + 1; + const textSegment = nodeText.slice(startIndex, cursorPoint); + + if (!isLinkText(textSegment)) { + return; + } + + editor.update(() => { + const linkNode: LinkNode = $createLinkNode(textSegment); + linkNode.append(new TextNode(textSegment)); + + const splits = node.splitText(startIndex, cursorPoint); + const targetIndex = splits.length === 3 ? 1 : 0; + const targetText = splits[targetIndex]; + if (targetText) { + targetText.replace(linkNode); + } + }); +} + + +export function registerAutoLinks(editor: LexicalEditor): () => void { + + const handler = (payload: KeyboardEvent): boolean => { + const selection = $getSelection(); + const textNode = $getTextNodeFromSelection(selection); + if (textNode && selection) { + handlePotentialLinkEvent(textNode, selection, editor); + } + + return false; + }; + + const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + + return (): void => { + unregisterSpace(); + unregisterEnter(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 28e729e92ae..167ab32adca 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher: return null; } +export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null { + return $getNodeFromSelection(selection, $isTextNode) as TextNode|null; +} + export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean { if (!selection) { return false; From a71aa241ada17bfc5497c59914774260003b3339 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 14 Dec 2024 15:17:33 +0000 Subject: [PATCH 260/800] Lexical: Added dark mode styles, fixed autolink range --- .../services/__tests__/auto-links.test.ts | 4 +- resources/js/wysiwyg/services/auto-links.ts | 2 +- resources/sass/_editor.scss | 47 ++++++++++++------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index d3b120b7064..30dc925659e 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -24,7 +24,7 @@ describe('Auto-link service tests', () => { pNode.append(text); $getRoot().append(pNode); - text.select(35, 35); + text.select(34, 34); }); editor.commitUpdates(); @@ -62,7 +62,7 @@ describe('Auto-link service tests', () => { pNode.append(text); $getRoot().append(pNode); - text.select(35, 35); + text.select(34, 34); }); editor.commitUpdates(); diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 44a78ec8577..1c3b1c73010 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -27,7 +27,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit return; } - const cursorPoint = selectionRange[0].offset - 1; + const cursorPoint = selectionRange[0].offset; const nodeText = node.getTextContent(); const rTrimText = nodeText.slice(0, cursorPoint); const priorSpaceIndex = rTrimText.lastIndexOf(' '); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e273f1942d5..bdf6ea44c3d 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -8,18 +8,20 @@ // Main UI elements .editor-container { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); position: relative; &.fullscreen { z-index: 500; } } + .editor-toolbar-main { display: flex; flex-wrap: wrap; justify-content: center; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #000); } body.editor-is-fullscreen { @@ -46,6 +48,7 @@ body.editor-is-fullscreen { font-size: 12px; padding: 4px; color: #444; + @include mixins.lightDark(color, #444, #999); border-radius: 4px; display: flex; align-items: center; @@ -54,6 +57,7 @@ body.editor-is-fullscreen { } .editor-button:hover { background-color: #EEE; + @include mixins.lightDark(background-color, #EEE, #333); cursor: pointer; color: #000; } @@ -63,7 +67,7 @@ body.editor-is-fullscreen { opacity: .6; } .editor-button-active, .editor-button-active:hover { - background-color: #ceebff; + @include mixins.lightDark(background-color, #ceebff, #444); color: #000; } .editor-button-long { @@ -75,7 +79,7 @@ body.editor-is-fullscreen { } .editor-button-text { font-weight: 400; - color: #000; + @include mixins.lightDark(color, #000, #AAA); font-size: 14px; flex: 1; padding-inline-end: 4px; @@ -126,7 +130,8 @@ body.editor-is-fullscreen { } } &:hover { - outline: 1px solid #DDD; + outline: 1px solid; + @include mixins.lightDark(outline-color, #DDD, #111); outline-offset: -3px; } } @@ -137,11 +142,14 @@ body.editor-is-fullscreen { } .editor-dropdown-menu { position: absolute; - background-color: #FFF; - box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); + border: 1px solid; + @include mixins.lightDark(background-color, #FFF, #292929); + @include mixins.lightDark(border-color, #FFF, #333); + @include mixins.lightDark(box-shadow, 0 0 6px 0 rgba(0, 0, 0, 0.15), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); z-index: 99; display: flex; flex-direction: row; + border-radius: 3px; } .editor-dropdown-menu-vertical { display: flex; @@ -163,8 +171,8 @@ body.editor-is-fullscreen { .editor-separator { display: block; height: 1px; - background-color: #DDD; opacity: .8; + @include mixins.lightDark(background-color, #DDD, #000); } .editor-format-menu-toggle { @@ -199,6 +207,7 @@ body.editor-is-fullscreen { display: flex; border-inline: 1px solid #DDD; padding-inline: 4px; + @include mixins.lightDark(border-color, #DDD, #000); &:first-child { border-inline-start: none; } @@ -212,11 +221,12 @@ body.editor-is-fullscreen { .editor-context-toolbar { position: fixed; - background-color: #FFF; border: 1px solid #DDD; + @include mixins.lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #DDD, #333); + @include mixins.lightDark(box-shadow, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); padding: .2rem; border-radius: 4px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); display: flex; flex-direction: row; &:before { @@ -226,9 +236,10 @@ body.editor-is-fullscreen { width: 8px; height: 8px; position: absolute; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-top: 1px solid #DDD; border-left: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #333); transform: rotate(45deg); left: 50%; margin-left: -4px; @@ -252,7 +263,7 @@ body.editor-is-fullscreen { height: 100%; } .editor-modal { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); @@ -314,7 +325,8 @@ body.editor-is-fullscreen { display: flex; } .editor-table-creator-cell { - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); width: 15px; height: 15px; cursor: pointer; @@ -347,7 +359,7 @@ body.editor-is-fullscreen { height: 10px; border: 2px solid var(--editor-color-primary); z-index: 3; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #000); user-select: none; &.nw { inset-inline-start: -5px; @@ -477,10 +489,11 @@ body.editor-is-fullscreen { display: block; width: 100%; min-width: 250px; - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; - color: #444; + @include mixins.lightDark(color, #444, #BBB); } textarea.editor-form-field-input { font-family: var(--font-code); @@ -557,7 +570,7 @@ textarea.editor-form-field-input { .editor-form-tab-control { font-weight: bold; font-size: 14px; - color: #444; + @include mixins.lightDark(color, #444, #666); border-bottom: 2px solid transparent; position: relative; cursor: pointer; @@ -565,7 +578,7 @@ textarea.editor-form-field-input { text-align: start; &[aria-selected="true"] { border-color: var(--editor-color-primary); - color: var(--editor-color-primary); + color: var(--editor-color-primary) !important; } &[aria-selected="true"]:after, &:hover:after { background-color: var(--editor-color-primary); From 5f07f31c9fc21c4f82b757eb2e78027ff3ad6337 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 14:03:08 +0000 Subject: [PATCH 261/800] Lexical: Added mobile toolbar support Adds dynamic and fixed (out of DOM order) positioning with location adjustment depending on space. Also adds smarter hiding to prevent disappearing when mouse leaves but within the same space as the toggle. --- .../ui/framework/blocks/button-with-menu.ts | 1 + .../ui/framework/blocks/dropdown-button.ts | 3 ++ .../wysiwyg/ui/framework/helpers/dropdowns.ts | 52 +++++++++++++++++-- resources/js/wysiwyg/ui/toolbars.ts | 4 +- resources/sass/_editor.scss | 8 +++ resources/sass/_pages.scss | 1 + 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts index 30dd237f60f..2aec7c3352e 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts @@ -16,6 +16,7 @@ export class EditorButtonWithMenu extends EditorContainerUiElement { button: {label: 'Menu', icon: caretDownIcon}, showOnHover: false, direction: 'vertical', + showAside: false, }, menuItems); this.addChildren(this.dropdownButton); } diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index cba141f6c77..d7f02d5732b 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button"; export type EditorDropdownButtonOptions = { showOnHover?: boolean; direction?: 'vertical'|'horizontal'; + showAside?: boolean; button: EditorBasicButtonDefinition|EditorButton; }; const defaultOptions: EditorDropdownButtonOptions = { showOnHover: false, direction: 'horizontal', + showAside: undefined, button: {label: 'Menu'}, } @@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { handleDropdown({toggle: button, menu : menu, showOnHover: this.options.showOnHover, + showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'), onOpen : () => { this.open = true; this.getContext().manager.triggerStateUpdateForElement(this.button); diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index e8cef3c8d20..ccced685867 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -1,20 +1,48 @@ - - - interface HandleDropdownParams { toggle: HTMLElement; menu: HTMLElement; showOnHover?: boolean, onOpen?: Function | undefined; onClose?: Function | undefined; + showAside?: boolean; +} + +function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) { + const toggleRect = toggle.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + + menu.style.position = 'fixed'; + + if (showAside) { + let targetLeft = toggleRect.right; + const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.left - menuBounds.width, 0); + } + + menu.style.top = toggleRect.top + 'px'; + menu.style.left = targetLeft + 'px'; + } else { + const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth; + let targetLeft = toggleRect.left; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.right - menuBounds.width, 0); + } + + menu.style.top = toggleRect.bottom + 'px'; + menu.style.left = targetLeft + 'px'; + } } export function handleDropdown(options: HandleDropdownParams) { - const {menu, toggle, onClose, onOpen, showOnHover} = options; + const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options; let clickListener: Function|null = null; const hide = () => { menu.hidden = true; + menu.style.removeProperty('position'); + menu.style.removeProperty('left'); + menu.style.removeProperty('top'); if (clickListener) { window.removeEventListener('click', clickListener as EventListener); } @@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) { const show = () => { menu.hidden = false + positionMenu(menu, toggle, Boolean(showAside)); clickListener = (event: MouseEvent) => { if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { hide(); @@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) { toggle.addEventListener('mouseenter', toggleShowing); } - menu.parentElement?.addEventListener('mouseleave', hide); + menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => { + + // Prevent mouseleave hiding if withing the same bounds of the toggle. + // Avoids hiding in the event the mouse is interrupted by a high z-index + // item like a browser scrollbar. + const toggleBounds = toggle.getBoundingClientRect(); + const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left; + const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top; + const withinToggle = withinX && withinY; + + if (!withinToggle) { + hide(); + } + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 35146e5a440..886e1394b20 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorOverflowContainer(4, [ new EditorButton(link), - new EditorDropdownButton({button: table, direction: 'vertical'}, [ - new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [ + new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [ + new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [ new EditorTableCreator(), ]), new EditorSeparator(), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index bdf6ea44c3d..e481318374c 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -24,6 +24,14 @@ @include mixins.lightDark(border-color, #DDD, #000); } +@include mixins.smaller-than(vars.$bp-xl) { + .editor-toolbar-main { + overflow-x: scroll; + flex-wrap: nowrap; + justify-content: start; + } +} + body.editor-is-fullscreen { overflow: hidden; .edit-area { diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 17bcfcfbf11..45e58ffc865 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -26,6 +26,7 @@ width: 100%; border-radius: 8px; box-shadow: vars.$bs-card; + min-width: 300px; @include mixins.lightDark(background-color, #FFF, #333) } From 2f119d3033c9543a22109c34d2b77ef99e5486af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 15:29:00 +0000 Subject: [PATCH 262/800] Lexical: Adjusted modals and content area for mobile sizes --- resources/sass/_editor.scss | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e481318374c..3eef4c2c6b9 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -48,6 +48,7 @@ body.editor-is-fullscreen { .editor-content-wrap { position: relative; overflow-y: scroll; + padding-inline: vars.$s; flex: 1; } @@ -275,6 +276,9 @@ body.editor-is-fullscreen { border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); + margin: vars.$xs; + max-height: 100%; + overflow-y: auto; } .editor-modal-header { display: flex; @@ -490,19 +494,29 @@ body.editor-is-fullscreen { /** * Form elements */ +$inputWidth: 260px; + .editor-form-field-wrapper { margin-bottom: .5rem; } .editor-form-field-input { display: block; - width: 100%; - min-width: 250px; + width: $inputWidth; + min-width: 100px; + max-width: 100%; border: 1px solid; @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; @include mixins.lightDark(color, #444, #BBB); } + +@include mixins.smaller-than(vars.$bp-xs) { + .editor-form-field-input { + min-width: 160px; + } +} + textarea.editor-form-field-input { font-family: var(--font-code); width: 350px; @@ -575,6 +589,17 @@ textarea.editor-form-field-input { align-items: stretch; gap: .25rem; } + +@include mixins.smaller-than(vars.$bp-m) { + .editor-form-tab-container { + flex-direction: column; + gap: .5rem; + } + .editor-form-tab-controls { + flex-direction: row; + } +} + .editor-form-tab-control { font-weight: bold; font-size: 14px; @@ -601,7 +626,8 @@ textarea.editor-form-field-input { } } .editor-form-tab-contents { - width: 360px; + width: $inputWidth; + max-width: 100%; } .editor-action-input-container { display: flex; @@ -612,6 +638,9 @@ textarea.editor-form-field-input { .editor-button { margin-bottom: 12px; } + input { + width: $inputWidth - 40px; + } } // Editor theme styles From 3f86937f74f2e5dc1e11fc2fd84694ce0793308f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 17:11:02 +0000 Subject: [PATCH 263/800] Lexical: Made summary part of details node To provide more control of the summary as part of details. To support, added a way to ignore elements during import DOM, allowing up to read summaries when parsing details without duplicate nodes involved. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 7 +- resources/js/wysiwyg/lexical/html/index.ts | 5 + .../lexical/rich-text/LexicalDetailsNode.ts | 98 ++++++++----------- resources/js/wysiwyg/nodes.ts | 4 +- 4 files changed, 54 insertions(+), 60 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index c6bc2e642ee..a6c9b6023e5 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -142,10 +142,15 @@ export type DOMConversionMap = Record< >; type NodeName = string; +/** + * Output for a DOM conversion. + * Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode + * including all its children. + */ export type DOMConversionOutput = { after?: (childLexicalNodes: Array) => Array; forChild?: DOMChildConversion; - node: null | LexicalNode | Array; + node: null | LexicalNode | Array | 'ignore'; }; export type DOMExportOutputMap = Map< diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 3e962ec72f7..5c3cb6cce29 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -217,6 +217,11 @@ function $createNodesFromDOM( if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; + + if (transformNodes === 'ignore') { + return lexicalNodes; + } + currentLexicalNode = Array.isArray(transformNodes) ? transformNodes[transformNodes.length - 1] : transformNodes; diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 178b0d9531d..18d47110316 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -5,18 +5,19 @@ import { LexicalEditor, LexicalNode, SerializedElementNode, Spread, - EditorConfig, + EditorConfig, DOMExportOutput, } from 'lexical'; -import {el} from "../../utils/dom"; import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedDetailsNode = Spread<{ id: string; + summary: string; }, SerializedElementNode> export class DetailsNode extends ElementNode { __id: string = ''; + __summary: string = ''; static getType() { return 'details'; @@ -32,10 +33,21 @@ export class DetailsNode extends ElementNode { return self.__id; } + setSummary(summary: string) { + const self = this.getWritable(); + self.__summary = summary; + } + + getSummary(): string { + const self = this.getLatest(); + return self.__summary; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; + newNode.__summary = node.__summary; return newNode; } @@ -49,6 +61,11 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + const summary = document.createElement('summary'); + summary.textContent = this.__summary; + summary.setAttribute('contenteditable', 'false'); + el.append(summary); + return el; } @@ -71,20 +88,42 @@ export class DetailsNode extends ElementNode { node.setDirection(extractDirectionFromElement(element)); } + const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY'); + node.setSummary(summaryElem?.textContent || ''); + return {node}; }, priority: 3, }; }, + summary(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return {node: 'ignore'}; + }, + priority: 3, + }; + }, }; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config, editor); + const editable = element.querySelectorAll('[contenteditable]'); + for (const elem of editable) { + elem.removeAttribute('contenteditable'); + } + + return {element}; + } + exportJSON(): SerializedDetailsNode { return { ...super.exportJSON(), type: 'details', version: 1, id: this.__id, + summary: this.__summary, }; } @@ -104,58 +143,3 @@ export function $createDetailsNode() { export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode { 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): SummaryNode { - return $createSummaryNode(); - } - -} - -export function $createSummaryNode(): SummaryNode { - return new SummaryNode(); -} - -export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode { - return node instanceof SummaryNode; -} diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index eb836bdce02..8a47f322d6d 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -8,7 +8,7 @@ import { } from "lexical"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; -import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; @@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableCellNode, ImageNode, // TODO - Alignment HorizontalRuleNode, - DetailsNode, SummaryNode, + DetailsNode, CodeBlockNode, DiagramNode, MediaNode, // TODO - Alignment From 5887322178c0fd95319ed104abee3bd4733c5d0d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 18:13:49 +0000 Subject: [PATCH 264/800] Lexical: Added details toolbar Includes unwrap and toggle open actions. --- resources/icons/editor/details-toggle.svg | 1 + .../lexical/rich-text/LexicalDetailsNode.ts | 33 ++++++++++- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 59 ++++++++++++++++++- .../js/wysiwyg/ui/defaults/forms/objects.ts | 34 +++++++++++ resources/js/wysiwyg/ui/defaults/modals.ts | 6 +- resources/js/wysiwyg/ui/index.ts | 7 ++- resources/js/wysiwyg/ui/toolbars.ts | 10 +++- 7 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 resources/icons/editor/details-toggle.svg diff --git a/resources/icons/editor/details-toggle.svg b/resources/icons/editor/details-toggle.svg new file mode 100644 index 00000000000..37194e059ca --- /dev/null +++ b/resources/icons/editor/details-toggle.svg @@ -0,0 +1 @@ + diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 18d47110316..3c845359aec 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -18,6 +18,7 @@ export type SerializedDetailsNode = Spread<{ export class DetailsNode extends ElementNode { __id: string = ''; __summary: string = ''; + __open: boolean = false; static getType() { return 'details'; @@ -43,11 +44,22 @@ export class DetailsNode extends ElementNode { return self.__summary; } + setOpen(open: boolean) { + const self = this.getWritable(); + self.__open = open; + } + + getOpen(): boolean { + const self = this.getLatest(); + return self.__open; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; newNode.__summary = node.__summary; + newNode.__open = node.__open; return newNode; } @@ -61,17 +73,34 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + if (this.__open) { + el.setAttribute('open', 'true'); + } + const summary = document.createElement('summary'); summary.textContent = this.__summary; summary.setAttribute('contenteditable', 'false'); + summary.addEventListener('click', event => { + event.preventDefault(); + _editor.update(() => { + this.select(); + }) + }); + el.append(summary); return el; } updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + + if (prevNode.__open !== this.__open) { + dom.toggleAttribute('open', this.__open); + } + return prevNode.__id !== this.__id - || prevNode.__dir !== this.__dir; + || prevNode.__dir !== this.__dir + || prevNode.__summary !== this.__summary; } static importDOM(): DOMConversionMap|null { @@ -114,6 +143,8 @@ export class DetailsNode extends ElementNode { elem.removeAttribute('contenteditable'); } + element.removeAttribute('open'); + return {element}; } diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index f9c029ff14c..6612c0dc451 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -19,6 +19,9 @@ import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import detailsIcon from "@icons/editor/details.svg"; +import detailsToggleIcon from "@icons/editor/details-toggle.svg"; +import tableDeleteIcon from "@icons/editor/table-delete.svg"; +import tagIcon from "@icons/tag.svg"; import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; @@ -29,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -216,4 +219,58 @@ export const details: EditorButtonDefinition = { isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDetailsNode); } +} + +export const detailsEditLabel: EditorButtonDefinition = { + label: 'Edit label', + icon: tagIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + $showDetailsForm(details, context); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsToggle: EditorButtonDefinition = { + label: 'Toggle open/closed', + icon: detailsToggleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + details.setOpen(!details.getOpen()); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsUnwrap: EditorButtonDefinition = { + label: 'Unwrap', + icon: tableDeleteIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + const children = details.getChildren(); + for (const child of children) { + details.insertBefore(child); + } + details.remove(); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } } \ 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 f00a08bb5f5..21d333c3aa2 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -19,6 +19,7 @@ import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; import {LinkField} from "../../framework/blocks/link-field"; import {insertOrUpdateLink} from "../../../utils/formats"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -262,4 +263,37 @@ export const media: EditorFormDefinition = { } }, ], +}; + +export function $showDetailsForm(details: DetailsNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('details'); + if (!details) { + return; + } + + linkModal.show({ + summary: details.getSummary() + }); +} + +export const details: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const node = $getNodeFromSelection($getSelection(), $isDetailsNode); + const summary = (formData.get('summary') || '').toString().trim(); + if ($isDetailsNode(node)) { + node.setSummary(summary); + } + }); + + return true; + }, + fields: [ + { + label: 'Toggle label', + name: 'summary', + type: 'text', + }, + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index c4392377828..da385926626 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,5 +1,5 @@ import {EditorFormModalDefinition} from "../framework/modals"; -import {image, link, media} from "./forms/objects"; +import {details, image, link, media} from "./forms/objects"; import {source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; @@ -32,4 +32,8 @@ export const modals: Record = { title: 'Table Properties', form: tableProperties, }, + details: { + title: 'Edit collapsible block', + form: details, + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3811f44b9bf..40df433479f 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,6 +1,6 @@ import {LexicalEditor} from "lexical"; import { - getCodeToolbarContent, + getCodeToolbarContent, getDetailsToolbarContent, getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent @@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro selector: '.editor-code-block-wrap', content: getCodeToolbarContent(), }); - manager.registerContextToolbar('table', { selector: 'td,th', content: getTableToolbarContent(), @@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro return originalTarget.closest('table') as HTMLTableElement; } }); + manager.registerContextToolbar('details', { + selector: 'details', + content: getDetailsToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('code', CodeBlockDecorator); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 886e1394b20..1230cbdd298 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -68,7 +68,7 @@ import { } from "./defaults/buttons/lists"; import { codeBlock, - details, + details, detailsEditLabel, detailsToggle, detailsUnwrap, diagram, diagramManager, editCodeBlock, horizontalRule, @@ -253,4 +253,12 @@ export function getTableToolbarContent(): EditorUiElement[] { new EditorButton(deleteColumn), ]), ]; +} + +export function getDetailsToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(detailsEditLabel), + new EditorButton(detailsToggle), + new EditorButton(detailsUnwrap), + ]; } \ No newline at end of file From 8486775edf9d7979f9d6f1f555460bf8678d6653 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Dec 2024 14:30:06 +0000 Subject: [PATCH 265/800] Lexical: Added mulitple methods to escape details block Enter on empty last line, or down on last empty line, will focus on the next node after details, or created a new paragraph to focus on if needed. --- .../js/wysiwyg/services/keyboard-handling.ts | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 6a1345fac6d..08eed764596 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -3,7 +3,7 @@ import { $createParagraphNode, $getSelection, $isDecoratorNode, - COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -13,9 +13,10 @@ import { import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; -import {$getNearestNodeBlockParent} from "../utils/nodes"; +import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean { return false; } +/** + * Delete the current node in the selection if the selection contains a single + * selected node (like image, media etc...). + */ function deleteSingleSelectedNode(editor: LexicalEditor) { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { } } +/** + * Insert a new empty node after the selection if the selection contains a single + * selected node (like image, media etc...). + */ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -58,6 +67,94 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +/** + * Insert a new node after a details node, if inside a details node that's + * the last element, and if the cursor is at the last block within the details node. + */ +function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null || scenario.detailsSibling) { + return false; + } + + editor.update(() => { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + }); + event?.preventDefault(); + + return true; +} + +/** + * If within a details block, move after it, creating a new node if required, if we're on + * the last empty block element within the details node. + */ +function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null) { + return false; + } + + if (scenario.parentBlock.getTextContent() !== '') { + return false; + } + + event?.preventDefault() + + const nextSibling = scenario.parentDetails.getNextSibling(); + editor.update(() => { + if (nextSibling) { + nextSibling.selectStart(); + } else { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + } + scenario.parentBlock.remove(); + }); + + return true; +} + +/** + * Get the common nodes used for a details node scenario, relative to current selection. + * Returns null if not found, or if the parent block is not the last in the parent details node. + */ +function getDetailsScenario(editor: LexicalEditor): { + parentDetails: DetailsNode; + parentBlock: LexicalNode; + detailsSibling: LexicalNode | null +} | null { + const selection = getLastSelection(editor); + const firstNode = selection?.getNodes()[0]; + if (!firstNode) { + return null; + } + + const block = $getNearestNodeBlockParent(firstNode); + const details = $getParentOfType(firstNode, $isDetailsNode); + if (!$isDetailsNode(details) || block === null) { + return null; + } + + if (block.getKey() !== details.getLastChild()?.getKey()) { + return null; + } + + const nextSibling = details.getNextSibling(); + return { + parentDetails: details, + parentBlock: block, + detailsSibling: nextSibling, + } +} + +/** + * Inset the nodes within selection when a range of nodes is selected + * or if a list node is selected. + */ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); @@ -85,17 +182,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { }, COMMAND_PRIORITY_LOW); const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { - return insertAfterSingleSelectedNode(context.editor, event); + return insertAfterSingleSelectedNode(context.editor, event) + || moveAfterDetailsOnEmptyLine(context.editor, event); }, COMMAND_PRIORITY_LOW); const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { return handleInsetOnTab(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { + return insertAfterDetails(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { unregisterBackspace(); unregisterDelete(); unregisterEnter(); unregisterTab(); + unregisterDown(); }; } \ No newline at end of file From e50cd33277026a93983b696014c6468a4d34fe6f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Dec 2024 16:24:47 +0000 Subject: [PATCH 266/800] Lexical: Added testing for some added shortcuts Also: - Added svg loading support (dummy stub) for jest. - Updated headless test case due to node changes. - Split out editor change detected to where appropriate. - Added functions to help with testing, like mocking our context. --- dev/build/svg-blank-transform.js | 14 +++ jest.config.ts | 1 + resources/js/wysiwyg/index.ts | 36 +------ .../js/wysiwyg/lexical/core/LexicalEditor.ts | 8 ++ .../lexical/core/__tests__/utils/index.ts | 46 +++++++++ .../unit/LexicalHeadlessEditor.test.ts | 1 - .../__tests__/unit/LexicalDetailsNode.test.ts | 40 ++++++++ .../__tests__/keyboard-handling.test.ts | 95 +++++++++++++++++++ .../js/wysiwyg/services/common-events.ts | 14 ++- resources/js/wysiwyg/ui/framework/manager.ts | 18 +++- 10 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 dev/build/svg-blank-transform.js create mode 100644 resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts create mode 100644 resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts diff --git a/dev/build/svg-blank-transform.js b/dev/build/svg-blank-transform.js new file mode 100644 index 00000000000..5183014c880 --- /dev/null +++ b/dev/build/svg-blank-transform.js @@ -0,0 +1,14 @@ +// This is a basic transformer stub to help jest handle SVG files. +// Essentially blanks them since we don't really need to involve them +// in our tests (yet). +module.exports = { + process() { + return { + code: 'module.exports = \'\';', + }; + }, + getCacheKey() { + // The output is always the same. + return 'svgTransform'; + }, +}; diff --git a/jest.config.ts b/jest.config.ts index 3c04f05b218..53bfceb053e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -185,6 +185,7 @@ const config: Config = { // A map from regular expressions to paths to transformers transform: { "^.+.tsx?$": ["ts-jest",{}], + "^.+.svg$": ["/dev/build/svg-blank-transform.js",{}], }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 510ab1f9230..ffdc7d7e82c 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -75,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st const debugView = document.getElementById('lexical-debug'); if (debugView) { debugView.hidden = true; - } - - let changeFromLoading = true; - 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) { - if (changeFromLoading) { - changeFromLoading = false; - } else { - window.$events.emit('editor-html-change', ''); - } - } - - // Debug logic - // console.log('editorState', editorState.toJSON()); - if (debugView) { + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Debug logic + // console.log('editorState', editorState.toJSON()); debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); - } - }); + }); + } // @ts-ignore window.debugEditorState = () => { diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts index 092429156be..364f6c6b7c3 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -1188,6 +1188,14 @@ export class LexicalEditor { updateEditor(this, updateFn, options); } + /** + * Helper to run the update and commitUpdates methods in a single call. + */ + updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void { + this.update(updateFn, options); + this.commitUpdates(); + } + /** * Focuses the editor * @param callbackFn - A function to run after the editor is focused. diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e9d14ef1139..2fc57315b7c 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import { + $getSelection, $isRangeSelection, createEditor, DecoratorNode, @@ -37,6 +38,10 @@ import { import {resetRandomKey} from '../../LexicalUtils'; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {EditorUiContext} from "../../../../ui/framework/core"; +import {EditorUIManager} from "../../../../ui/framework/manager"; +import {registerRichText} from "@lexical/rich-text"; type TestEnv = { @@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable | LexicalNodeR TableRowNode, AutoLinkNode, LinkNode, + DetailsNode, TestElementNode, TestSegmentedNode, TestExcludeFromCopyElementNode, @@ -451,6 +457,7 @@ export function createTestEditor( ...config, nodes: DEFAULT_NODES.concat(customNodes), }); + return editor; } @@ -465,6 +472,26 @@ export function createTestHeadlessEditor( }); } +export function createTestContext(env: TestEnv): EditorUiContext { + const context = { + containerDOM: document.createElement('div'), + editor: env.editor, + editorDOM: document.createElement('div'), + error(text: string | Error): void { + }, + manager: new EditorUIManager(), + options: {}, + scrollDOM: document.createElement('div'), + translate(text: string): string { + return ""; + } + }; + + context.manager.setContext(context); + + return context; +} + export function $assertRangeSelection(selection: unknown): RangeSelection { if (!$isRangeSelection(selection)) { throw new Error(`Expected RangeSelection, got ${selection}`); @@ -717,4 +744,23 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); +} + +export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) { + const nodeDomEl = editor.getElementByKey(node.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + }); + nodeDomEl?.dispatchEvent(event); +} + +export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { + editor.getEditorState().read((): void => { + const node = $getSelection()?.getNodes()[0] || null; + if (node) { + dispatchKeydownEventForNode(node, editor, key); + } + }); } \ No newline at end of file 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 122516d45b6..c03f1bdb2fb 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => { 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 () => { diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts new file mode 100644 index 00000000000..faa31d8874d --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts @@ -0,0 +1,40 @@ +import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical"; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + }, +}); + +describe('LexicalDetailsNode tests', () => { + initializeUnitTest((testEnv) => { + + test('createDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = details.createDOM(editorConfig, editor).outerHTML; + }); + + expect(html).toBe(`
                                `); + }); + + test('exportDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = (details.exportDOM(editor).element as HTMLElement).outerHTML; + }); + + expect(html).toBe(`
                                `); + }); + + + }); +}) \ No newline at end of file diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts new file mode 100644 index 00000000000..14a1ea973a5 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -0,0 +1,95 @@ +import { + createTestContext, + dispatchKeydownEventForNode, + dispatchKeydownEventForSelectedNode, + initializeUnitTest +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $createTextNode, + $getRoot, LexicalNode, + ParagraphNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {registerKeyboardHandling} from "../keyboard-handling"; +import {registerRichText} from "@lexical/rich-text"; + +describe('Keyboard-handling service tests', () => { + initializeUnitTest((testEnv) => { + + test('Details: down key on last lines creates new sibling node', () => { + const {editor} = testEnv; + + registerRichText(editor); + registerKeyboardHandling(createTestContext(testEnv)); + + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; + + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + detailsPara = $createParagraphNode(); + details.append(detailsPara); + $getRoot().append(details); + detailsPara.select(); + + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); + editor.commitUpdates(); + + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); + + test('Details: enter on last empy block creates new sibling node', () => { + const {editor} = testEnv; + + registerRichText(editor); + registerKeyboardHandling(createTestContext(testEnv)); + + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; + + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + const text = $createTextNode('Hello!'); + detailsPara = $createParagraphNode(); + detailsPara.append(text); + details.append(detailsPara); + $getRoot().append(details); + text.selectEnd(); + + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); + editor.commitUpdates(); + + dispatchKeydownEventForSelectedNode(editor, 'Enter'); + editor.commitUpdates(); + + let detailsChildren!: LexicalNode[]; + let lastDetailsText!: string; + + editor.getEditorState().read(() => { + detailsChildren = (lastRootChild as DetailsNode).getChildren(); + lastRootChild = $getRoot().getLastChild(); + lastDetailsText = detailsChildren[0].getTextContent(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + expect(detailsChildren).toHaveLength(1); + expect(lastDetailsText).toBe('Hello!'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index 16522d66b71..2ffa722e40c 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,4 +1,4 @@ -import {LexicalEditor} from "lexical"; +import {$getSelection, LexicalEditor} from "lexical"; import { appendHtmlToEditor, focusEditor, @@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void { window.$events.listen('editor::focus', () => { focusEditor(editor); }); + + let changeFromLoading = true; + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // 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', ''); + } + } + }); } diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 185cd5dccd0..0f501d9faae 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,7 +1,7 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; -import {BaseSelection, LexicalEditor} from "lexical"; +import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; @@ -231,6 +231,22 @@ export class EditorUIManager { }); } editor.registerDecoratorListener(domDecorateListener); + + // Watch for changes to update local state + editor.registerUpdateListener(({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(); + this.triggerStateUpdate({ + editor, selection, + }); + }); + } + }); } protected setupEventListeners(context: EditorUiContext) { From ace8af077dfa5173c24cdf8b50eb82ccbd1dbf7e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 14:44:10 +0000 Subject: [PATCH 267/800] Lexical: Improved list tab handling, Improved test utils - Made tab work on empty list items - Improved select preservation on single list item tab - Altered test context creation for more standard testing --- .../lexical/core/__tests__/utils/index.ts | 32 +++- .../__tests__/keyboard-handling.test.ts | 158 +++++++++++------- .../js/wysiwyg/services/keyboard-handling.ts | 11 +- resources/js/wysiwyg/utils/lists.ts | 17 +- 4 files changed, 151 insertions(+), 67 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 2fc57315b7c..7815d4f0dc6 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -472,16 +472,34 @@ export function createTestHeadlessEditor( }); } -export function createTestContext(env: TestEnv): EditorUiContext { +export function createTestContext(): EditorUiContext { + + const container = document.createElement('div'); + document.body.appendChild(container); + + const scrollWrap = document.createElement('div'); + const editorDOM = document.createElement('div'); + editorDOM.setAttribute('contenteditable', 'true'); + + scrollWrap.append(editorDOM); + container.append(scrollWrap); + + const editor = createTestEditor({ + namespace: 'testing', + theme: {}, + }); + + editor.setRootElement(editorDOM); + const context = { - containerDOM: document.createElement('div'), - editor: env.editor, - editorDOM: document.createElement('div'), + containerDOM: container, + editor: editor, + editorDOM: editorDOM, error(text: string | Error): void { }, manager: new EditorUIManager(), options: {}, - scrollDOM: document.createElement('div'), + scrollDOM: scrollWrap, translate(text: string): string { return ""; } @@ -492,6 +510,10 @@ export function createTestContext(env: TestEnv): EditorUiContext { return context; } +export function destroyFromContext(context: EditorUiContext) { + context.containerDOM.remove(); +} + export function $assertRangeSelection(selection: unknown): RangeSelection { if (!$isRangeSelection(selection)) { throw new Error(`Expected RangeSelection, got ${selection}`); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 14a1ea973a5..0ab6935fb1a 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -1,95 +1,135 @@ import { - createTestContext, + createTestContext, destroyFromContext, dispatchKeydownEventForNode, dispatchKeydownEventForSelectedNode, - initializeUnitTest } from "lexical/__tests__/utils"; import { $createParagraphNode, $createTextNode, - $getRoot, LexicalNode, - ParagraphNode, + $getRoot, $getSelection, LexicalEditor, LexicalNode, + ParagraphNode, TextNode, } from "lexical"; import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {registerKeyboardHandling} from "../keyboard-handling"; import {registerRichText} from "@lexical/rich-text"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list"; describe('Keyboard-handling service tests', () => { - initializeUnitTest((testEnv) => { - test('Details: down key on last lines creates new sibling node', () => { - const {editor} = testEnv; + let context!: EditorUiContext; + let editor!: LexicalEditor; - registerRichText(editor); - registerKeyboardHandling(createTestContext(testEnv)); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerKeyboardHandling(context); + }); - let lastRootChild!: LexicalNode|null; - let detailsPara!: ParagraphNode; + afterEach(() => { + destroyFromContext(context); + }); - editor.updateAndCommit(() => { - const root = $getRoot() - const details = $createDetailsNode(); - detailsPara = $createParagraphNode(); - details.append(detailsPara); - $getRoot().append(details); - detailsPara.select(); + test('Details: down key on last lines creates new sibling node', () => { + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; - lastRootChild = root.getLastChild(); - }); + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + detailsPara = $createParagraphNode(); + details.append(detailsPara); + $getRoot().append(details); + detailsPara.select(); - expect(lastRootChild).toBeInstanceOf(DetailsNode); + lastRootChild = root.getLastChild(); + }); - dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); - editor.commitUpdates(); + expect(lastRootChild).toBeInstanceOf(DetailsNode); - editor.getEditorState().read(() => { - lastRootChild = $getRoot().getLastChild(); - }); + dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); + editor.commitUpdates(); - expect(lastRootChild).toBeInstanceOf(ParagraphNode); + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); }); - test('Details: enter on last empy block creates new sibling node', () => { - const {editor} = testEnv; + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); + + test('Details: enter on last empty block creates new sibling node', () => { + registerRichText(editor); + + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; - registerRichText(editor); - registerKeyboardHandling(createTestContext(testEnv)); + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + const text = $createTextNode('Hello!'); + detailsPara = $createParagraphNode(); + detailsPara.append(text); + details.append(detailsPara); + $getRoot().append(details); + text.selectEnd(); - let lastRootChild!: LexicalNode|null; - let detailsPara!: ParagraphNode; + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); + editor.commitUpdates(); - editor.updateAndCommit(() => { - const root = $getRoot() - const details = $createDetailsNode(); - const text = $createTextNode('Hello!'); - detailsPara = $createParagraphNode(); - detailsPara.append(text); - details.append(detailsPara); - $getRoot().append(details); - text.selectEnd(); + dispatchKeydownEventForSelectedNode(editor, 'Enter'); + editor.commitUpdates(); - lastRootChild = root.getLastChild(); - }); + let detailsChildren!: LexicalNode[]; + let lastDetailsText!: string; - expect(lastRootChild).toBeInstanceOf(DetailsNode); + editor.getEditorState().read(() => { + detailsChildren = (lastRootChild as DetailsNode).getChildren(); + lastRootChild = $getRoot().getLastChild(); + lastDetailsText = detailsChildren[0].getTextContent(); + }); - dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); - editor.commitUpdates(); + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + expect(detailsChildren).toHaveLength(1); + expect(lastDetailsText).toBe('Hello!'); + }); - dispatchKeydownEventForSelectedNode(editor, 'Enter'); - editor.commitUpdates(); + test('Lists: tab on empty list item insets item', () => { - let detailsChildren!: LexicalNode[]; - let lastDetailsText!: string; + let list!: ListNode; + let listItemB!: ListItemNode; - editor.getEditorState().read(() => { - detailsChildren = (lastRootChild as DetailsNode).getChildren(); - lastRootChild = $getRoot().getLastChild(); - lastDetailsText = detailsChildren[0].getTextContent(); - }); + editor.updateAndCommit(() => { + const root = $getRoot(); + list = $createListNode('bullet'); + const listItemA = $createListItemNode(); + listItemA.append($createTextNode('Hello!')); + listItemB = $createListItemNode(); + list.append(listItemA, listItemB); + root.append(list); + listItemB.selectStart(); + }); - expect(lastRootChild).toBeInstanceOf(ParagraphNode); - expect(detailsChildren).toHaveLength(1); - expect(lastDetailsText).toBe('Hello!'); + dispatchKeydownEventForNode(listItemB, editor, 'Tab'); + editor.commitUpdates(); + + editor.getEditorState().read(() => { + const list = $getRoot().getChildren()[0] as ListNode; + const listChild = list.getChildren()[0] as ListItemNode; + const children = listChild.getChildren(); + expect(children).toHaveLength(2); + expect(children[0]).toBeInstanceOf(TextNode); + expect(children[0].getTextContent()).toBe('Hello!'); + expect(children[1]).toBeInstanceOf(ListNode); + + const innerList = children[1] as ListNode; + const selectedNode = $getSelection()?.getNodes()[0]; + expect(selectedNode).toBeInstanceOf(ListItemNode); + expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey()); }); }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 08eed764596..ff6117b2b87 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -151,6 +151,15 @@ function getDetailsScenario(editor: LexicalEditor): { } } +function $isSingleListItem(nodes: LexicalNode[]): boolean { + if (nodes.length !== 1) { + return false; + } + + const node = nodes[0]; + return $isListItemNode(node) || $isListItemNode(node.getParent()); +} + /** * Inset the nodes within selection when a range of nodes is selected * or if a list node is selected. @@ -159,7 +168,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); const nodes = selection?.getNodes() || []; - if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) { + if (nodes.length > 1 || $isSingleListItem(nodes)) { editor.update(() => { $setInsetForSelection(editor, change); }); diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 646f341c2ba..2fc1c5f6b4e 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,4 +1,4 @@ -import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {nodeHasInset} from "./nodes"; import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; @@ -93,6 +93,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[ export function $setInsetForSelection(editor: LexicalEditor, change: number): void { const selection = $getSelection(); + const selectionBounds = selection?.getStartEndPoints(); const listItemsInSelection = getListItemsForSelection(selection); const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); @@ -110,7 +111,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo alteredListItems.reverse(); } - $selectNodes(alteredListItems); + if (alteredListItems.length === 1 && selectionBounds) { + // Retain selection range if moving just one item + const listItem = alteredListItems[0] as ListItemNode; + let child = listItem.getChildren()[0] as TextNode; + if (!child) { + child = $createTextNode(''); + listItem.append(child); + } + child.select(selectionBounds[0].offset, selectionBounds[1].offset); + } else { + $selectNodes(alteredListItems); + } + return; } From fca8f928a380350a1a6441530a722f4578f8ae14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 16:50:03 +0000 Subject: [PATCH 268/800] Lexical: Aligned new empty item behaviour for nested lists - Makes enter on empty nested list item un-nest instead of just creating new list items. - Also updated existing lists tests to use newer helper setup. --- .../lexical/core/__tests__/utils/index.ts | 1 + .../lexical/list/LexicalListItemNode.ts | 11 +- .../unit/LexicalListItemNode.test.ts | 1041 +++++++++-------- .../__tests__/keyboard-handling.test.ts | 5 - 4 files changed, 538 insertions(+), 520 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 7815d4f0dc6..d90853b7c34 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -776,6 +776,7 @@ export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEd key, }); nodeDomEl?.dispatchEvent(event); + editor.commitUpdates(); } export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 33b021298a6..239c49a8c30 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode { insertNewAfter( _: RangeSelection, restoreSelection = true, - ): ListItemNode | ParagraphNode { + ): ListItemNode | ParagraphNode | null { if (this.getTextContent().trim() === '' && this.isLastChild()) { const list = this.getParentOrThrow(); - if (!$isListItemNode(list.getParent())) { + const parentListItem = list.getParent(); + if ($isListItemNode(parentListItem)) { + // Un-nest list item if empty nested item + parentListItem.insertAfter(this); + this.selectStart(); + return null; + } else { + // Insert empty paragraph after list if adding after last empty child const paragraph = $createParagraphNode(); list.insertAfter(paragraph, restoreSelection); this.remove(); 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 567714bcd9f..10ff0fc6699 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -9,13 +9,13 @@ import { $createParagraphNode, $createRangeSelection, - $getRoot, + $getRoot, LexicalEditor, TextNode, } from 'lexical'; import { + createTestContext, destroyFromContext, expectHtmlToBeEqual, html, - initializeUnitTest, } from 'lexical/__tests__/utils'; import { @@ -24,49 +24,49 @@ import { ListItemNode, ListNode, } from '../..'; - -const editorConfig = Object.freeze({ - namespace: '', - theme: { - list: { - listitem: 'my-listItem-item-class', - nested: { - listitem: 'my-nested-list-listItem-class', - }, - }, - }, -}); +import {EditorUiContext} from "../../../../ui/framework/core"; +import {$htmlToBlockNodes} from "../../../../utils/nodes"; describe('LexicalListItemNode tests', () => { - initializeUnitTest((testEnv) => { - test('ListItemNode.constructor', async () => { - const {editor} = testEnv; - await editor.update(() => { - const listItemNode = new ListItemNode(); + let context!: EditorUiContext; + let editor!: LexicalEditor; - expect(listItemNode.getType()).toBe('listitem'); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); - expect(listItemNode.getTextContent()).toBe(''); - }); + afterEach(() => { + destroyFromContext(context); + }); + + test('ListItemNode.constructor', async () => { + + await editor.update(() => { + const listItemNode = new ListItemNode(); - expect(() => new ListItemNode()).toThrow(); + expect(listItemNode.getType()).toBe('listitem'); + + expect(listItemNode.getTextContent()).toBe(''); }); - test('ListItemNode.createDOM()', async () => { - const {editor} = testEnv; + expect(() => new ListItemNode()).toThrow(); + }); - await editor.update(() => { - const listItemNode = new ListItemNode(); + test('ListItemNode.createDOM()', async () => { - expectHtmlToBeEqual( - listItemNode.createDOM(editorConfig).outerHTML, + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expectHtmlToBeEqual( + listItemNode.createDOM(editor._config).outerHTML, html`
                              • `, - ); + ); - expectHtmlToBeEqual( + expectHtmlToBeEqual( listItemNode.createDOM({ namespace: '', theme: {}, @@ -74,108 +74,105 @@ describe('LexicalListItemNode tests', () => { html`
                              • `, - ); - }); + ); }); + }); - describe('ListItemNode.updateDOM()', () => { - test('base', async () => { - const {editor} = testEnv; + describe('ListItemNode.updateDOM()', () => { + test('base', async () => { - await editor.update(() => { - const listItemNode = new ListItemNode(); + await editor.update(() => { + const listItemNode = new ListItemNode(); - const domElement = listItemNode.createDOM(editorConfig); + const domElement = listItemNode.createDOM(editor._config); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
                              • `, - ); - const newListItemNode = new ListItemNode(); + ); + const newListItemNode = new ListItemNode(); - const result = newListItemNode.updateDOM( + const result = newListItemNode.updateDOM( listItemNode, domElement, - editorConfig, - ); + editor._config, + ); - expect(result).toBe(false); + expect(result).toBe(false); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
                              • `, - ); - }); + ); }); + }); - test('nested list', async () => { - const {editor} = testEnv; + test('nested list', async () => { - await editor.update(() => { - const parentListNode = new ListNode('bullet', 1); - const parentlistItemNode = new ListItemNode(); + await editor.update(() => { + const parentListNode = new ListNode('bullet', 1); + const parentlistItemNode = new ListItemNode(); - parentListNode.append(parentlistItemNode); - const domElement = parentlistItemNode.createDOM(editorConfig); + parentListNode.append(parentlistItemNode); + const domElement = parentlistItemNode.createDOM(editor._config); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
                              • `, - ); - const nestedListNode = new ListNode('bullet', 1); - nestedListNode.append(new ListItemNode()); - parentlistItemNode.append(nestedListNode); - const result = parentlistItemNode.updateDOM( + ); + const nestedListNode = new ListNode('bullet', 1); + nestedListNode.append(new ListItemNode()); + parentlistItemNode.append(nestedListNode); + const result = parentlistItemNode.updateDOM( parentlistItemNode, domElement, - editorConfig, - ); + editor._config, + ); - expect(result).toBe(false); + expect(result).toBe(false); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
                              • `, - ); - }); + ); }); }); + }); - describe('ListItemNode.replace()', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.replace()', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode1.append(new TextNode('one')); - listItemNode2 = new ListItemNode(); + listItemNode1.append(new TextNode('one')); + listItemNode2 = new ListItemNode(); - listItemNode2.append(new TextNode('two')); - listItemNode3 = new ListItemNode(); + listItemNode2.append(new TextNode('two')); + listItemNode3 = new ListItemNode(); - listItemNode3.append(new TextNode('three')); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - }); + listItemNode3.append(new TextNode('three')); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - test('another list item node', async () => { - const {editor} = testEnv; + test('another list item node', async () => { - await editor.update(() => { - const newListItemNode = new ListItemNode(); + await editor.update(() => { + const newListItemNode = new ListItemNode(); - newListItemNode.append(new TextNode('bar')); - listItemNode1.replace(newListItemNode); - }); + newListItemNode.append(new TextNode('bar')); + listItemNode1.replace(newListItemNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - test('first list item with a non list item node', async () => { - const {editor} = testEnv; + test('first list item with a non list item node', async () => { - await editor.update(() => { - return; - }); + await editor.update(() => { + return; + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); + ); - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - test('last list item with a non list item node', async () => { - const {editor} = testEnv; + test('last list item with a non list item node', async () => { - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode3.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode3.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {


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


                                `, - ); - }); + ); }); + }); - describe('ListItemNode.remove()', () => { - // - A - // - x - // - B - test('siblings are not nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + describe('ListItemNode.remove()', () => { + // - A + // - x + // - B + test('siblings are not nested', async () => { + let x: ListItemNode; - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); - x = new ListItemNode(); - x.append(new TextNode('x')); + x = new ListItemNode(); + x.append(new TextNode('x')); - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - // - A - // - x - // - B - test('the previous sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + // - A + // - x + // - B + test('the previous sibling is nested', async () => { + let x: ListItemNode; - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + 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')); + 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')); + x = new ListItemNode(); + x.append(new TextNode('x')); - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -504,12 +494,12 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -524,39 +514,38 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); - }); + ); + }); - // - A - // - x - // - B - test('the next sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + // - A + // - x + // - B + test('the next sibling is nested', async () => { + let x: ListItemNode; - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); - x = new ListItemNode(); - x.append(new TextNode('x')); + 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')); + 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); - }); + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -574,12 +563,12 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -594,43 +583,42 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); - }); + ); + }); - // - 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); - }); + // - A + // - x + // - B + test('both siblings are nested', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + 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( + context.editorDOM.innerHTML, html`
                                • @@ -652,12 +640,12 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -672,51 +660,50 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); - }); + ); + }); - // - 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); - }); + // - A1 + // - A2 + // - x + // - B + test('the previous sibling is nested deeper than the next sibling', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + 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( + context.editorDOM.innerHTML, html`
                                • @@ -745,12 +732,12 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -772,51 +759,50 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); - }); + ); + }); - // - 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); - }); + // - A + // - x + // - B1 + // - B2 + test('the next sibling is nested deeper than the previous sibling', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + 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( + context.editorDOM.innerHTML, html`
                                • @@ -845,12 +831,12 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -872,59 +858,58 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); - }); + ); + }); - // - 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); - }); + // - A1 + // - A2 + // - x + // - B1 + // - B2 + test('both siblings are deeply nested', async () => { + let x: ListItemNode; - expectHtmlToBeEqual( - testEnv.innerHTML, + 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( + context.editorDOM.innerHTML, html`
                                • @@ -960,12 +945,12 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
                                • @@ -990,37 +975,36 @@ describe('LexicalListItemNode tests', () => {
                                `, - ); - }); + ); }); + }); - describe('ListItemNode.insertNewAfter(): non-empty list items', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.insertNewAfter(): non-empty list items', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode2 = new ListItemNode(); + listItemNode2 = new ListItemNode(); - listItemNode3 = 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')); - }); + 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, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - test('first list item', async () => { - const {editor} = testEnv; + test('first list item', async () => { - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - test('last list item', async () => { - const {editor} = testEnv; + test('last list item', async () => { - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); + }); - test('middle list item', async () => { - const {editor} = testEnv; + test('middle list item', async () => { - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); - - test('the only list item', async () => { - const {editor} = testEnv; + ); + }); - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); + test('the only list item', async () => { + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); + ); - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
                                {
                                `, - ); - }); + ); }); + }); - test('$createListItemNode()', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listItemNode = new ListItemNode(); - - const createdListItemNode = $createListItemNode(); + describe('ListItemNode.insertNewAfter()', () => { + test('new items after empty nested items un-nests the current item instead of creating new', () => { + let nestedItem!: ListItemNode; + const input = `
                                  +
                                • + Item A +
                                  • Nested item A
                                  +
                                • +
                                • Item B
                                • +
                                `; + + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemA = list.getFirstChild() as ListItemNode; + const nestedList = itemA.getLastChild() as ListNode; + nestedItem = nestedList.getFirstChild() as ListItemNode; + nestedList.selectEnd(); + }); - expect(listItemNode.__type).toEqual(createdListItemNode.__type); - expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); - expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + editor.updateAndCommit(() => { + nestedItem.insertNewAfter($createRangeSelection()); + const newItem = nestedItem.getNextSibling() as ListItemNode; + newItem.insertNewAfter($createRangeSelection()); }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, + html`
                                  +
                                • + Item A +
                                  • Nested item A
                                  +
                                • +

                                • +
                                • Item B
                                • +
                                `, + ); }); + }); - test('$isListItemNode()', async () => { - const {editor} = testEnv; + test('$createListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); - await editor.update(() => { - const listItemNode = new ListItemNode(); + const createdListItemNode = $createListItemNode(); - expect($isListItemNode(listItemNode)).toBe(true); - }); + expect(listItemNode.__type).toEqual(createdListItemNode.__type); + expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); + expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + }); + }); + + test('$isListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect($isListItemNode(listItemNode)).toBe(true); }); }); }); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 0ab6935fb1a..736c3573c5b 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -48,7 +48,6 @@ describe('Keyboard-handling service tests', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode); dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); - editor.commitUpdates(); editor.getEditorState().read(() => { lastRootChild = $getRoot().getLastChild(); @@ -79,10 +78,7 @@ describe('Keyboard-handling service tests', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode); dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); - editor.commitUpdates(); - dispatchKeydownEventForSelectedNode(editor, 'Enter'); - editor.commitUpdates(); let detailsChildren!: LexicalNode[]; let lastDetailsText!: string; @@ -115,7 +111,6 @@ describe('Keyboard-handling service tests', () => { }); dispatchKeydownEventForNode(listItemB, editor, 'Tab'); - editor.commitUpdates(); editor.getEditorState().read(() => { const list = $getRoot().getChildren()[0] as ListNode; From f4005a139b0ac102674cad0185fa6c32265e68f5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 18:07:46 +0000 Subject: [PATCH 269/800] Lexical: Adjusted handling of child/sibling list items on nesting Sibling/child items will now remain at the same visual level during nesting/un-nested, so only the selected item level is visually altered. Also added new model-based editor content matching system for tests. --- .../lexical/core/__tests__/utils/index.ts | 43 +++++- .../js/wysiwyg/utils/__tests__/lists.test.ts | 124 ++++++++++++++++++ resources/js/wysiwyg/utils/lists.ts | 22 ++++ 3 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 resources/js/wysiwyg/utils/__tests__/lists.test.ts diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index d90853b7c34..b13bba6977e 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -30,18 +30,14 @@ import { TextNode, } from 'lexical'; -import { - CreateEditorArgs, - HTMLConfig, - LexicalNodeReplacement, -} from '../../LexicalEditor'; +import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor'; import {resetRandomKey} from '../../LexicalUtils'; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; -import {registerRichText} from "@lexical/rich-text"; +import {turtle} from "@codemirror/legacy-modes/mode/turtle"; type TestEnv = { @@ -764,6 +760,41 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { expect(formatHtml(expected)).toBe(formatHtml(actual)); } +type nodeTextShape = { + text: string; +}; + +type nodeShape = { + type: string; + children?: (nodeShape|nodeTextShape)[]; +} + +export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape { + // @ts-ignore + const children: SerializedLexicalNode[] = (node.children || []); + + const shape: nodeShape = { + type: node.type, + }; + + if (shape.type === 'text') { + // @ts-ignore + return {text: node.text} + } + + if (children.length > 0) { + shape.children = children.map(c => getNodeShape(c)); + } + + return shape; +} + +export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) { + const json = editor.getEditorState().toJSON(); + const shape = getNodeShape(json.root) as nodeShape; + expect(shape.children).toMatchObject(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/utils/__tests__/lists.test.ts b/resources/js/wysiwyg/utils/__tests__/lists.test.ts new file mode 100644 index 00000000000..20dcad24098 --- /dev/null +++ b/resources/js/wysiwyg/utils/__tests__/lists.test.ts @@ -0,0 +1,124 @@ +import { + createTestContext, destroyFromContext, + dispatchKeydownEventForNode, expectNodeShapeToMatch, +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $getRoot, LexicalEditor, LexicalNode, + ParagraphNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$htmlToBlockNodes} from "../nodes"; +import {ListItemNode, ListNode} from "@lexical/list"; +import {$nestListItem, $unnestListItem} from "../lists"; + +describe('List Utils', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); + + afterEach(() => { + destroyFromContext(context); + }); + + describe('$nestListItem', () => { + test('nesting handles child items to leave at the same level', () => { + const input = `
                                  +
                                • Inner A
                                • +
                                • Inner B
                                    +
                                  • Inner C
                                  • +
                                • +
                                `; + let list!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + list = $getRoot().getFirstChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $nestListItem(list.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Inner A'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner B'}]}, + {type: 'listitem', children: [{text: 'Inner C'}]}, + ] + } + ] + }, + ] + } + ]); + }); + }); + + describe('$unnestListItem', () => { + test('middle in nested list converts to new parent item at same place', () => { + const input = `
                                  +
                                • Nested list:
                                    +
                                  • Inner A
                                  • +
                                  • Inner B
                                  • +
                                  • Inner C
                                  • +
                                • +
                                `; + let innerList!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $unnestListItem(innerList.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Nested list:'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner A'}]}, + ], + } + ], + }, + { + type: 'listitem', + children: [ + {text: 'Inner B'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner C'}]}, + ], + } + ], + } + ] + } + ]); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 2fc1c5f6b4e..005b05f9816 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode { return node; } + const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null; + const nodeChildItems = nodeChildList?.getChildren() || []; + const listItems = list.getChildren() as ListItemNode[]; const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); const isFirst = nodeIndex === 0; @@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode { node.remove(); } + if (nodeChildList) { + for (const child of nodeChildItems) { + newListItem.insertAfter(child); + } + nodeChildList.remove(); + } + return newListItem; } @@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { return node; } + const laterSiblings = node.getNextSiblings(); + parentListItem.insertAfter(node); if (list.getChildren().length === 0) { list.remove(); @@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { parentListItem.remove(); } + if (laterSiblings.length > 0) { + const childList = $createListNode(list.getListType()); + childList.append(...laterSiblings); + node.append(childList); + } + + if (list.getChildrenSize() === 0) { + list.remove(); + } + return node; } From ebe2ca7faff580ccb8064920a24fb2fad6412105 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 22:40:28 +0000 Subject: [PATCH 270/800] Lexical: Added about button/view Re-used existing route and moved tinymce help to its own different route. Added test to cover. Added new external-content block to support in editor UI. --- lang/en/editor.php | 2 + .../icons/editor/{help.svg => about.svg} | 0 resources/js/wysiwyg-tinymce/plugins-about.js | 2 +- .../wysiwyg/ui/defaults/buttons/controls.ts | 15 +- .../js/wysiwyg/ui/defaults/forms/controls.ts | 17 +- resources/js/wysiwyg/ui/defaults/modals.ts | 6 +- .../js/wysiwyg/ui/{ => defaults}/toolbars.ts | 39 +-- .../ui/framework/blocks/external-content.ts | 29 ++ resources/js/wysiwyg/ui/index.ts | 2 +- resources/sass/_editor.scss | 7 + resources/views/help/tinymce.blade.php | 146 +++++++++ resources/views/help/wysiwyg.blade.php | 280 +++++++++--------- routes/web.php | 1 + tests/Meta/HelpTest.php | 12 +- 14 files changed, 388 insertions(+), 170 deletions(-) rename resources/icons/editor/{help.svg => about.svg} (100%) rename resources/js/wysiwyg/ui/{ => defaults}/toolbars.ts (88%) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/external-content.ts create mode 100644 resources/views/help/tinymce.blade.php diff --git a/lang/en/editor.php b/lang/en/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/resources/icons/editor/help.svg b/resources/icons/editor/about.svg similarity index 100% rename from resources/icons/editor/help.svg rename to resources/icons/editor/about.svg diff --git a/resources/js/wysiwyg-tinymce/plugins-about.js b/resources/js/wysiwyg-tinymce/plugins-about.js index 096b4f96805..75cf476cf9f 100644 --- a/resources/js/wysiwyg-tinymce/plugins-about.js +++ b/resources/js/wysiwyg-tinymce/plugins-about.js @@ -4,7 +4,7 @@ function register(editor) { const aboutDialog = { title: 'About the WYSIWYG Editor', - url: window.baseUrl('/help/wysiwyg'), + url: window.baseUrl('/help/tinymce'), }; editor.ui.registry.addButton('about', { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 77223dac3f7..5e32005393e 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -11,8 +11,9 @@ import { } from "lexical"; import redoIcon from "@icons/editor/redo.svg"; import sourceIcon from "@icons/editor/source-view.svg"; -import {getEditorContentAsHtml} from "../../../utils/actions"; import fullscreenIcon from "@icons/editor/fullscreen.svg"; +import aboutIcon from "@icons/editor/about.svg"; +import {getEditorContentAsHtml} from "../../../utils/actions"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -80,4 +81,16 @@ export const fullscreen: EditorButtonDefinition = { isActive(selection, context: EditorUiContext) { return context.containerDOM.classList.contains('fullscreen'); } +}; + +export const about: EditorButtonDefinition = { + label: 'About the editor', + icon: aboutIcon, + async action(context: EditorUiContext, button: EditorButton) { + const modal = context.manager.createModal('about'); + modal.show({}); + }, + isActive(selection, context: EditorUiContext) { + return false; + } }; \ 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 index fc461f6626f..8e7219d6749 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -1,6 +1,7 @@ import {EditorFormDefinition} from "../../framework/forms"; -import {EditorUiContext} from "../../framework/core"; +import {EditorUiContext, EditorUiElement} from "../../framework/core"; import {setEditorContentFromHtml} from "../../../utils/actions"; +import {ExternalContent} from "../../framework/blocks/external-content"; export const source: EditorFormDefinition = { submitText: 'Save', @@ -15,4 +16,18 @@ export const source: EditorFormDefinition = { type: 'textarea', }, ], +}; + +export const about: EditorFormDefinition = { + submitText: 'Close', + async action() { + return true; + }, + fields: [ + { + build(): EditorUiElement { + return new ExternalContent('/help/wysiwyg'); + } + } + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index da385926626..830a42935c7 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,6 +1,6 @@ import {EditorFormModalDefinition} from "../framework/modals"; import {details, image, link, media} from "./forms/objects"; -import {source} from "./forms/controls"; +import {about, source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; export const modals: Record = { @@ -35,5 +35,9 @@ export const modals: Record = { details: { title: 'Edit collapsible block', form: details, + }, + about: { + title: 'About the WYSIWYG Editor', + form: about, } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts similarity index 88% rename from resources/js/wysiwyg/ui/toolbars.ts rename to resources/js/wysiwyg/ui/defaults/toolbars.ts index 1230cbdd298..61baa3c3260 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -1,12 +1,12 @@ -import {EditorButton} from "./framework/buttons"; -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"; -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 {EditorButton} from "../framework/buttons"; +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"; +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 { cellProperties, clearTableFormatting, copyColumn, @@ -29,8 +29,8 @@ import { rowProperties, splitCell, table, tableProperties -} from "./defaults/buttons/tables"; -import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; +} from "./buttons/tables"; +import {about, fullscreen, redo, source, undo} from "./buttons/controls"; import { blockquote, dangerCallout, h2, @@ -41,7 +41,7 @@ import { paragraph, successCallout, warningCallout -} from "./defaults/buttons/block-formats"; +} from "./buttons/block-formats"; import { bold, clearFormating, code, highlightColor, @@ -50,7 +50,7 @@ import { superscript, textColor, underline -} from "./defaults/buttons/inline-formats"; +} from "./buttons/inline-formats"; import { alignCenter, alignJustify, @@ -58,14 +58,14 @@ import { alignRight, directionLTR, directionRTL -} from "./defaults/buttons/alignments"; +} from "./buttons/alignments"; import { bulletList, indentDecrease, indentIncrease, numberList, taskList -} from "./defaults/buttons/lists"; +} from "./buttons/lists"; import { codeBlock, details, detailsEditLabel, detailsToggle, detailsUnwrap, @@ -75,10 +75,10 @@ import { image, link, media, unlink -} from "./defaults/buttons/objects"; -import {el} from "../utils/dom"; -import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; -import {EditorSeparator} from "./framework/blocks/separator"; +} from "./buttons/objects"; +import {el} from "../../utils/dom"; +import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; +import {EditorSeparator} from "../framework/blocks/separator"; export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { @@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai // Meta elements new EditorOverflowContainer(3, [ new EditorButton(source), + new EditorButton(about), new EditorButton(fullscreen), // Test diff --git a/resources/js/wysiwyg/ui/framework/blocks/external-content.ts b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts new file mode 100644 index 00000000000..b53c43e5511 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts @@ -0,0 +1,29 @@ +import {EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; + +export class ExternalContent extends EditorUiElement { + + /** + * The URL for HTML to be loaded from. + */ + protected url: string = ''; + + constructor(url: string) { + super(); + this.url = url; + } + + buildDOM(): HTMLElement { + const wrapper = el('div', { + class: 'editor-external-content', + }); + + window.$http.get(this.url).then(resp => { + if (typeof resp.data === 'string') { + wrapper.innerHTML = resp.data; + } + }); + + return wrapper; + } +} diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 40df433479f..fda37085ef3 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -4,7 +4,7 @@ import { getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent -} from "./toolbars"; +} from "./defaults/toolbars"; import {EditorUIManager} from "./framework/manager"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 3eef4c2c6b9..2446c141670 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -350,6 +350,13 @@ body.editor-is-fullscreen { text-align: center; padding: 0.2em; } +.editor-external-content { + min-width: 500px; + min-height: 500px; + h4:first-child { + margin-top: 0; + } +} // In-editor elements .editor-image-wrap { diff --git a/resources/views/help/tinymce.blade.php b/resources/views/help/tinymce.blade.php new file mode 100644 index 00000000000..8ff59c8d664 --- /dev/null +++ b/resources/views/help/tinymce.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.plain') +@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) + +@section('content') +
                                + +

                                {{ trans('editor.editor_license') }}

                                +

                                + {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} +
                                + {{ trans('editor.editor_tiny_license_link') }} +

                                + +

                                {{ trans('editor.shortcuts') }}

                                + +

                                {{ trans('editor.shortcuts_intro') }}

                                + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
                                Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
                                Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
                                Ctrl+BCmd+B{{ trans('editor.bold') }}
                                Ctrl+ICmd+I{{ trans('editor.italic') }}
                                + Ctrl+1
                                + Ctrl+2
                                + Ctrl+3
                                + Ctrl+4 +
                                + Cmd+1
                                + Cmd+2
                                + Cmd+3
                                + Cmd+4 +
                                + {{ trans('editor.header_large') }}
                                + {{ trans('editor.header_medium') }}
                                + {{ trans('editor.header_small') }}
                                + {{ trans('editor.header_tiny') }} +
                                + Ctrl+5
                                + Ctrl+D +
                                + Cmd+5
                                + Cmd+D +
                                {{ trans('editor.paragraph') }}
                                + Ctrl+6
                                + Ctrl+Q +
                                + Cmd+6
                                + Cmd+Q +
                                {{ trans('editor.blockquote') }}
                                + Ctrl+7
                                + Ctrl+E +
                                + Cmd+7
                                + Cmd+E +
                                {{ trans('editor.insert_code_block') }}
                                + Ctrl+8
                                + Ctrl+Shift+E +
                                + Cmd+8
                                + Cmd+Shift+E +
                                {{ trans('editor.inline_code') }}
                                Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
                                + {{ trans('editor.callouts_cycle') }} +
                                + Ctrl+O
                                + Ctrl+P +
                                + Cmd+O
                                + Cmd+P +
                                + {{ trans('editor.list_numbered') }}
                                + {{ trans('editor.list_bullet') }} +
                                + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
                                + +
                                +@endsection + diff --git a/resources/views/help/wysiwyg.blade.php b/resources/views/help/wysiwyg.blade.php index 8ff59c8d664..4fc00b0e1e6 100644 --- a/resources/views/help/wysiwyg.blade.php +++ b/resources/views/help/wysiwyg.blade.php @@ -1,146 +1,138 @@ -@extends('layouts.plain') -@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) +

                                {{ trans('editor.shortcuts') }}

                                -@section('content') -
                                - -

                                {{ trans('editor.editor_license') }}

                                -

                                - {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} -
                                - {{ trans('editor.editor_tiny_license_link') }} -

                                - -

                                {{ trans('editor.shortcuts') }}

                                - -

                                {{ trans('editor.shortcuts_intro') }}

                                - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
                                {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
                                Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
                                Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
                                Ctrl+BCmd+B{{ trans('editor.bold') }}
                                Ctrl+ICmd+I{{ trans('editor.italic') }}
                                - Ctrl+1
                                - Ctrl+2
                                - Ctrl+3
                                - Ctrl+4 -
                                - Cmd+1
                                - Cmd+2
                                - Cmd+3
                                - Cmd+4 -
                                - {{ trans('editor.header_large') }}
                                - {{ trans('editor.header_medium') }}
                                - {{ trans('editor.header_small') }}
                                - {{ trans('editor.header_tiny') }} -
                                - Ctrl+5
                                - Ctrl+D -
                                - Cmd+5
                                - Cmd+D -
                                {{ trans('editor.paragraph') }}
                                - Ctrl+6
                                - Ctrl+Q -
                                - Cmd+6
                                - Cmd+Q -
                                {{ trans('editor.blockquote') }}
                                - Ctrl+7
                                - Ctrl+E -
                                - Cmd+7
                                - Cmd+E -
                                {{ trans('editor.insert_code_block') }}
                                - Ctrl+8
                                - Ctrl+Shift+E -
                                - Cmd+8
                                - Cmd+Shift+E -
                                {{ trans('editor.inline_code') }}
                                Ctrl+9Cmd+9 - {{ trans('editor.callouts') }}
                                - {{ trans('editor.callouts_cycle') }} -
                                - Ctrl+O
                                - Ctrl+P -
                                - Cmd+O
                                - Cmd+P -
                                - {{ trans('editor.list_numbered') }}
                                - {{ trans('editor.list_bullet') }} -
                                - Ctrl+Shift+K - - Cmd+Shift+K - {{ trans('editor.link_selector') }}
                                - -
                                -@endsection +

                                {{ trans('editor.shortcuts_intro') }}

                                + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
                                Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
                                Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
                                Ctrl+BCmd+B{{ trans('editor.bold') }}
                                Ctrl+ICmd+I{{ trans('editor.italic') }}
                                + Ctrl+1
                                + Ctrl+2
                                + Ctrl+3
                                + Ctrl+4 +
                                + Cmd+1
                                + Cmd+2
                                + Cmd+3
                                + Cmd+4 +
                                + {{ trans('editor.header_large') }}
                                + {{ trans('editor.header_medium') }}
                                + {{ trans('editor.header_small') }}
                                + {{ trans('editor.header_tiny') }} +
                                + Ctrl+5
                                + Ctrl+D +
                                + Cmd+5
                                + Cmd+D +
                                {{ trans('editor.paragraph') }}
                                + Ctrl+6
                                + Ctrl+Q +
                                + Cmd+6
                                + Cmd+Q +
                                {{ trans('editor.blockquote') }}
                                + Ctrl+7
                                + Ctrl+E +
                                + Cmd+7
                                + Cmd+E +
                                {{ trans('editor.insert_code_block') }}
                                + Ctrl+8
                                + Ctrl+Shift+E +
                                + Cmd+8
                                + Cmd+Shift+E +
                                {{ trans('editor.inline_code') }}
                                Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
                                + {{ trans('editor.callouts_cycle') }} +
                                + Ctrl+O
                                + Ctrl+P +
                                + Cmd+O
                                + Cmd+P +
                                + {{ trans('editor.list_numbered') }}
                                + {{ trans('editor.list_bullet') }} +
                                + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
                                +

                                {{ trans('editor.editor_license') }}

                                +

                                + {!! trans('editor.editor_lexical_license', ['lexicalLink' => 'Lexical']) !!} +
                                + Copyright (c) Meta Platforms, Inc. and affiliates. +
                                + {{ trans('editor.editor_lexical_license_link') }} +

                                \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 85f83352859..318147ef518 100644 --- a/routes/web.php +++ b/routes/web.php @@ -361,6 +361,7 @@ Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); // Metadata routes +Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); Route::fallback([MetaController::class, 'notFound'])->name('fallback'); diff --git a/tests/Meta/HelpTest.php b/tests/Meta/HelpTest.php index e1de96bc8df..acce65394a3 100644 --- a/tests/Meta/HelpTest.php +++ b/tests/Meta/HelpTest.php @@ -6,9 +6,9 @@ class HelpTest extends TestCase { - public function test_wysiwyg_help_shows_tiny_and_tiny_license_link() + public function test_tinymce_help_shows_tiny_and_tiny_license_link() { - $resp = $this->get('/help/wysiwyg'); + $resp = $this->get('/help/tinymce'); $resp->assertOk(); $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]'); $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]'); @@ -22,4 +22,12 @@ public function test_tiny_license_exists_where_expected() $contents = file_get_contents($expectedPath); $this->assertStringContainsString('MIT License', $contents); } + + public function test_wysiwyg_help_shows_lexical_and_licenses_link() + { + $resp = $this->get('/help/wysiwyg'); + $resp->assertOk(); + $this->withHtml($resp)->assertElementExists('a[href="https://lexical.dev/"]'); + $this->withHtml($resp)->assertElementExists('a[href="' . url('/licenses') . '"]'); + } } From 01825ddb9392212aef0b1bfba55e5191a7888119 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Dec 2024 15:48:46 +0000 Subject: [PATCH 271/800] Dependancies: Bumped up composer dep versions --- composer.lock | 337 ++++++++++++++++++++++++-------------------------- 1 file changed, 165 insertions(+), 172 deletions(-) diff --git a/composer.lock b/composer.lock index f744f76208c..2a71d3e7cb0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4a5a18010b7f4b32b7f0ae2a3e6305bb", + "content-hash": "9c0520d8b0c13ae46bd0213c4dec5e38", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.331.0", + "version": "3.336.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400" + "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f8b3f63ba7b296afedcb3e6a43ce140831b9400", - "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/954bfdfc048840ca34afe2a2e1cbcff6681989c4", + "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.331.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.2" }, - "time": "2024-11-27T19:12:58+00:00" + "time": "2024-12-20T19:05:10+00:00" }, { "name": "bacon/bacon-qr-code", @@ -674,29 +674,27 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.3", + "version": "1.1.4", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", + "doctrine/coding-standard": "^9 || ^12", + "phpstan/phpstan": "1.4.10 || 2.0.3", + "phpstan/phpstan-phpunit": "^1.0 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "psr/log": "^1 || ^2 || ^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -704,7 +702,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -715,9 +713,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + "source": "https://github.com/doctrine/deprecations/tree/1.1.4" }, - "time": "2024-01-30T19:34:25+00:00" + "time": "2024-12-07T21:18:45+00:00" }, { "name": "doctrine/event-manager", @@ -980,16 +978,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59" + "reference": "2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/fbc7c5ee5d94f7a910b78b43feb7931b7f971b59", - "reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6", + "reference": "2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6", "shasum": "" }, "require": { @@ -1038,22 +1036,22 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.0.0" + "source": "https://github.com/dompdf/dompdf/tree/v3.0.1" }, - "time": "2024-04-29T14:01:28+00:00" + "time": "2024-12-05T14:59:38+00:00" }, { "name": "dompdf/php-font-lib", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/dompdf/php-font-lib.git", - "reference": "991d6a954f6bbd7e41022198f00586b230731441" + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/991d6a954f6bbd7e41022198f00586b230731441", - "reference": "991d6a954f6bbd7e41022198f00586b230731441", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", "shasum": "" }, "require": { @@ -1083,9 +1081,9 @@ "homepage": "https://github.com/dompdf/php-font-lib", "support": { "issues": "https://github.com/dompdf/php-font-lib/issues", - "source": "https://github.com/dompdf/php-font-lib/tree/1.0.0" + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" }, - "time": "2024-04-29T13:40:38+00:00" + "time": "2024-12-02T14:37:59+00:00" }, { "name": "dompdf/php-svg-lib", @@ -1942,16 +1940,16 @@ }, { "name": "intervention/image", - "version": "3.9.1", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683" + "reference": "1ddc9a096b3a641958515700e09be910bf03a5bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/b496d1f6b9f812f96166623358dfcafb8c3b1683", - "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683", + "url": "https://api.github.com/repos/Intervention/image/zipball/1ddc9a096b3a641958515700e09be910bf03a5bd", + "reference": "1ddc9a096b3a641958515700e09be910bf03a5bd", "shasum": "" }, "require": { @@ -1961,8 +1959,8 @@ }, "require-dev": { "mockery/mockery": "^1.6", - "phpstan/phpstan": "^1", - "phpunit/phpunit": "^10.0", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.0 || ^11.0", "slevomat/coding-standard": "~8.0", "squizlabs/php_codesniffer": "^3.8" }, @@ -1998,7 +1996,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.9.1" + "source": "https://github.com/Intervention/image/tree/3.10.0" }, "funding": [ { @@ -2014,7 +2012,7 @@ "type": "ko_fi" } ], - "time": "2024-10-27T10:15:54+00:00" + "time": "2024-12-21T07:41:40+00:00" }, { "name": "knplabs/knp-snappy", @@ -2411,16 +2409,16 @@ }, { "name": "laravel/socialite", - "version": "v5.16.0", + "version": "v5.16.1", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf" + "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf", - "reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf", + "url": "https://api.github.com/repos/laravel/socialite/zipball/4e5be83c0b3ecf81b2ffa47092e917d1f79dce71", + "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71", "shasum": "" }, "require": { @@ -2430,7 +2428,7 @@ "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "league/oauth1-client": "^1.10.1", + "league/oauth1-client": "^1.11", "php": "^7.2|^8.0", "phpseclib/phpseclib": "^3.0" }, @@ -2442,16 +2440,16 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - }, "laravel": { - "providers": [ - "Laravel\\Socialite\\SocialiteServiceProvider" - ], "aliases": { "Socialite": "Laravel\\Socialite\\Facades\\Socialite" - } + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" } }, "autoload": { @@ -2479,7 +2477,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2024-09-03T09:46:57+00:00" + "time": "2024-12-11T16:43:51+00:00" }, { "name": "laravel/tinker", @@ -2549,16 +2547,16 @@ }, { "name": "league/commonmark", - "version": "2.5.3", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "b650144166dfa7703e62a22e493b853b58d874b0" + "reference": "d150f911e0079e90ae3c106734c93137c184f932" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/b650144166dfa7703e62a22e493b853b58d874b0", - "reference": "b650144166dfa7703e62a22e493b853b58d874b0", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d150f911e0079e90ae3c106734c93137c184f932", + "reference": "d150f911e0079e90ae3c106734c93137c184f932", "shasum": "" }, "require": { @@ -2583,8 +2581,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 || ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 || ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0" }, @@ -2594,7 +2593,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.6-dev" + "dev-main": "2.7-dev" } }, "autoload": { @@ -2651,7 +2650,7 @@ "type": "tidelift" } ], - "time": "2024-08-16T11:46:16+00:00" + "time": "2024-12-07T15:34:16+00:00" }, { "name": "league/config", @@ -3069,16 +3068,16 @@ }, { "name": "league/oauth1-client", - "version": "v1.10.1", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth1-client.git", - "reference": "d6365b901b5c287dd41f143033315e2f777e1167" + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/d6365b901b5c287dd41f143033315e2f777e1167", - "reference": "d6365b901b5c287dd41f143033315e2f777e1167", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", "shasum": "" }, "require": { @@ -3139,41 +3138,36 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth1-client/issues", - "source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.1" + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" }, - "time": "2022-04-15T14:02:14+00:00" + "time": "2024-12-10T19:59:05+00:00" }, { "name": "league/oauth2-client", - "version": "2.7.0", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", - "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/3d5cf8d0543731dfb725ab30e4d7289891991e13", + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^6.0 || ^7.0", - "paragonie/random_compat": "^1 || ^2 || ^9.99", - "php": "^5.6 || ^7.0 || ^8.0" + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.5.0" }, "require-dev": { "mockery/mockery": "^1.3.5", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", - "squizlabs/php_codesniffer": "^2.3 || ^3.0" + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.0.x-dev" - } - }, "autoload": { "psr-4": { "League\\OAuth2\\Client\\": "src/" @@ -3209,9 +3203,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.0" }, - "time": "2023-04-16T18:19:15+00:00" + "time": "2024-12-11T05:05:52+00:00" }, { "name": "masterminds/html5", @@ -3282,16 +3276,16 @@ }, { "name": "monolog/monolog", - "version": "3.8.0", + "version": "3.8.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67" + "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67", - "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/aef6ee73a77a66e404dd6540934a9ef1b3c855b4", + "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4", "shasum": "" }, "require": { @@ -3369,7 +3363,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.8.0" + "source": "https://github.com/Seldaek/monolog/tree/3.8.1" }, "funding": [ { @@ -3381,7 +3375,7 @@ "type": "tidelift" } ], - "time": "2024-11-12T13:57:08+00:00" + "time": "2024-12-05T17:15:07+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3493,10 +3487,6 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.x-dev", - "dev-2.x": "2.x-dev" - }, "laravel": { "providers": [ "Carbon\\Laravel\\ServiceProvider" @@ -3506,6 +3496,10 @@ "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" } }, "autoload": { @@ -4105,16 +4099,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.42", + "version": "3.0.43", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98" + "reference": "709ec107af3cb2f385b9617be72af8cf62441d02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98", - "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02", + "reference": "709ec107af3cb2f385b9617be72af8cf62441d02", "shasum": "" }, "require": { @@ -4195,7 +4189,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.42" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43" }, "funding": [ { @@ -4211,7 +4205,7 @@ "type": "tidelift" } ], - "time": "2024-09-16T03:06:04+00:00" + "time": "2024-12-14T21:12:59+00:00" }, { "name": "pragmarx/google2fa", @@ -4789,16 +4783,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.4", + "version": "v0.12.7", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "2fd717afa05341b4f8152547f142cd2f130f6818" + "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/2fd717afa05341b4f8152547f142cd2f130f6818", - "reference": "2fd717afa05341b4f8152547f142cd2f130f6818", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", + "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", "shasum": "" }, "require": { @@ -4825,12 +4819,12 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-main": "0.12.x-dev" - }, "bamarni-bin": { "bin-links": false, "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" } }, "autoload": { @@ -4862,9 +4856,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.4" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.7" }, - "time": "2024-06-10T01:18:23+00:00" + "time": "2024-12-10T01:58:33+00:00" }, { "name": "ralouphie/getallheaders", @@ -5516,17 +5510,15 @@ }, { "name": "ssddanbrown/htmldiff", - "version": "v1.0.3", + "version": "v1.0.4", "source": { "type": "git", - "url": "https://github.com/ssddanbrown/HtmlDiff.git", - "reference": "92da405f8138066834b71ac7bedebbda6327761b" + "url": "https://codeberg.org/danb/HtmlDiff", + "reference": "d5cbd43f66c4e512cc0ab71d0e0b07271e7d6af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/92da405f8138066834b71ac7bedebbda6327761b", - "reference": "92da405f8138066834b71ac7bedebbda6327761b", - "shasum": "" + "url": "https://codeberg.org/api/v1/repos/danb/HtmlDiff/archive/%prettyVersion%.zip" }, "require": { "ext-mbstring": "*", @@ -5549,23 +5541,23 @@ "authors": [ { "name": "Dan Brown", - "email": "ssddanbrown@googlemail.com", + "homepage": "https://danb.me", "role": "Developer" } ], "description": "HTML Content Diff Generator", - "homepage": "https://github.com/ssddanbrown/htmldiff", - "support": { - "issues": "https://github.com/ssddanbrown/HtmlDiff/issues", - "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.3" - }, + "homepage": "https://codeberg.org/danb/HtmlDiff", "funding": [ { - "url": "https://github.com/ssddanbrown", + "url": "https://github.com/sponsors/ssddanbrown", "type": "github" + }, + { + "url": "https://ko-fi.com/ssddanbrown", + "type": "kofi" } ], - "time": "2024-03-29T16:51:55+00:00" + "time": "2024-12-12T16:45:37+00:00" }, { "name": "ssddanbrown/symfony-mailer", @@ -6464,8 +6456,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6540,8 +6532,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6619,8 +6611,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6701,8 +6693,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6785,8 +6777,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6859,8 +6851,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6939,8 +6931,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -7021,8 +7013,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -8177,16 +8169,16 @@ }, { "name": "itsgoingd/clockwork", - "version": "v5.3.1", + "version": "v5.3.2", "source": { "type": "git", "url": "https://github.com/itsgoingd/clockwork.git", - "reference": "7b0c40418df761f7a78e88762a323386a139d83d" + "reference": "ffd1f1626830005e92461a538ad58372641e065a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/7b0c40418df761f7a78e88762a323386a139d83d", - "reference": "7b0c40418df761f7a78e88762a323386a139d83d", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/ffd1f1626830005e92461a538ad58372641e065a", + "reference": "ffd1f1626830005e92461a538ad58372641e065a", "shasum": "" }, "require": { @@ -8204,12 +8196,12 @@ "type": "library", "extra": { "laravel": { - "providers": [ - "Clockwork\\Support\\Laravel\\ClockworkServiceProvider" - ], "aliases": { "Clockwork": "Clockwork\\Support\\Laravel\\Facade" - } + }, + "providers": [ + "Clockwork\\Support\\Laravel\\ClockworkServiceProvider" + ] } }, "autoload": { @@ -8241,7 +8233,7 @@ ], "support": { "issues": "https://github.com/itsgoingd/clockwork/issues", - "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.1" + "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.2" }, "funding": [ { @@ -8249,7 +8241,7 @@ "type": "github" } ], - "time": "2024-11-19T17:25:22+00:00" + "time": "2024-12-02T22:59:59+00:00" }, { "name": "larastan/larastan", @@ -8703,16 +8695,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.10.1", + "version": "5.10.2", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17" + "reference": "72afbce7e4b421593b60d2eb7281e37a50734df8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/b14fd66496a22d8dd7f7e2791edd9e8674422f17", - "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/72afbce7e4b421593b60d2eb7281e37a50734df8", + "reference": "72afbce7e4b421593b60d2eb7281e37a50734df8", "shasum": "" }, "require": { @@ -8786,20 +8778,20 @@ "type": "other" } ], - "time": "2024-11-10T04:10:31+00:00" + "time": "2024-12-05T15:04:09+00:00" }, { "name": "phpstan/phpstan", - "version": "1.12.11", + "version": "1.12.13", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733" + "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", - "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", + "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", "shasum": "" }, "require": { @@ -8844,7 +8836,7 @@ "type": "github" } ], - "time": "2024-11-17T14:08:01+00:00" + "time": "2024-12-17T17:00:20+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9169,16 +9161,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.38", + "version": "10.5.40", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132" + "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a86773b9e887a67bc53efa9da9ad6e3f2498c132", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6ddda95af52f69c1e0c7b4f977cccb58048798c", + "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c", "shasum": "" }, "require": { @@ -9188,7 +9180,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.12.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -9250,7 +9242,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.38" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.40" }, "funding": [ { @@ -9266,7 +9258,7 @@ "type": "tidelift" } ], - "time": "2024-10-28T13:06:21+00:00" + "time": "2024-12-21T05:49:06+00:00" }, { "name": "sebastian/cli-parser", @@ -10186,16 +10178,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.1", + "version": "3.11.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87" + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1368f4a58c3c52114b86b1abe8f4098869cb0079", + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079", "shasum": "" }, "require": { @@ -10262,7 +10254,7 @@ "type": "open_collective" } ], - "time": "2024-11-16T12:02:36+00:00" + "time": "2024-12-11T16:04:26+00:00" }, { "name": "ssddanbrown/asserthtml", @@ -10453,7 +10445,8 @@ "ext-gd": "*", "ext-json": "*", "ext-mbstring": "*", - "ext-xml": "*" + "ext-xml": "*", + "ext-zip": "*" }, "platform-dev": {}, "platform-overrides": { From c84d99945649941c82b654ec4247f56cfa98a81e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Dec 2024 12:43:26 +0000 Subject: [PATCH 272/800] ZIP Exports: Prevent book child page drafts from being included Added test to cover --- .../ZipExports/Models/ZipExportBook.php | 2 +- tests/Exports/ZipExportTest.php | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 4f641d25bd7..39176ded420 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -70,7 +70,7 @@ public static function fromModel(Book $model, ZipExportFiles $files): self foreach ($children as $child) { if ($child instanceof Chapter) { $chapters[] = $child; - } else if ($child instanceof Page) { + } else if ($child instanceof Page && !$child->draft) { $pages[] = $child; } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index ebe07d052bc..163828c1b6e 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -198,7 +198,7 @@ public function test_page_export_link_attachments() public function test_book_export() { - $book = $this->entities->book(); + $book = $this->entities->bookHasChaptersAndPages(); $book->tags()->saveMany(Tag::factory()->count(2)->make()); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); @@ -251,6 +251,35 @@ public function test_chapter_export() $this->assertCount($chapter->pages()->count(), $chapterData['pages']); } + public function test_draft_pages_are_not_included() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $book = $entities['book']; + $page = $entities['page']; + $chapter = $entities['chapter']; + $book->tags()->saveMany(Tag::factory()->count(2)->make()); + + $page->created_by = $editor->id; + $page->draft = true; + $page->save(); + + $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertCount(0, $zip->data['book']['chapters'][0]['pages'] ?? ['cat']); + + $zipResp = $this->actingAs($editor)->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertCount(0, $zip->data['chapter']['pages'] ?? ['cat']); + + $page->chapter_id = 0; + $page->save(); + + $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertCount(0, $zip->data['book']['pages'] ?? ['cat']); + } + public function test_cross_reference_links_are_converted() { From 2be504e0d20aa7c95b1fe006f675928faf0bd1a9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Dec 2024 11:23:44 +0000 Subject: [PATCH 273/800] Updated translations with latest Crowdin changes (#5345) --- lang/ar/activities.php | 8 + lang/ar/editor.php | 2 + lang/ar/entities.php | 21 ++ lang/ar/errors.php | 12 + lang/ar/settings.php | 1 + lang/ar/validation.php | 5 + lang/bg/activities.php | 8 + lang/bg/editor.php | 2 + lang/bg/entities.php | 21 ++ lang/bg/errors.php | 12 + lang/bg/settings.php | 1 + lang/bg/validation.php | 5 + lang/bn/activities.php | 132 +++++++++ lang/bn/auth.php | 117 ++++++++ lang/bn/common.php | 113 ++++++++ lang/bn/components.php | 46 ++++ lang/bn/editor.php | 179 +++++++++++++ lang/bn/entities.php | 460 ++++++++++++++++++++++++++++++++ lang/bn/errors.php | 133 +++++++++ lang/bn/notifications.php | 27 ++ lang/bn/pagination.php | 12 + lang/bn/passwords.php | 15 ++ lang/bn/preferences.php | 51 ++++ lang/bn/settings.php | 339 +++++++++++++++++++++++ lang/bn/validation.php | 122 +++++++++ lang/bs/activities.php | 8 + lang/bs/editor.php | 2 + lang/bs/entities.php | 21 ++ lang/bs/errors.php | 12 + lang/bs/settings.php | 1 + lang/bs/validation.php | 5 + lang/ca/activities.php | 8 + lang/ca/editor.php | 2 + lang/ca/entities.php | 21 ++ lang/ca/errors.php | 12 + lang/ca/settings.php | 1 + lang/ca/validation.php | 5 + lang/cs/activities.php | 8 + lang/cs/editor.php | 2 + lang/cs/entities.php | 21 ++ lang/cs/errors.php | 12 + lang/cs/settings.php | 1 + lang/cs/validation.php | 5 + lang/cy/activities.php | 8 + lang/cy/editor.php | 2 + lang/cy/entities.php | 21 ++ lang/cy/errors.php | 12 + lang/cy/settings.php | 1 + lang/cy/validation.php | 5 + lang/da/activities.php | 8 + lang/da/editor.php | 2 + lang/da/entities.php | 21 ++ lang/da/errors.php | 12 + lang/da/settings.php | 1 + lang/da/validation.php | 5 + lang/de/activities.php | 8 + lang/de/editor.php | 2 + lang/de/entities.php | 21 ++ lang/de/errors.php | 12 + lang/de/settings.php | 1 + lang/de/validation.php | 5 + lang/de_informal/activities.php | 8 + lang/de_informal/common.php | 2 +- lang/de_informal/editor.php | 2 + lang/de_informal/entities.php | 25 +- lang/de_informal/errors.php | 14 +- lang/de_informal/settings.php | 1 + lang/de_informal/validation.php | 7 +- lang/el/activities.php | 26 +- lang/el/auth.php | 2 +- lang/el/common.php | 10 +- lang/el/components.php | 26 +- lang/el/editor.php | 12 +- lang/el/entities.php | 21 ++ lang/el/errors.php | 12 + lang/el/notifications.php | 34 +-- lang/el/settings.php | 31 +-- lang/el/validation.php | 5 + lang/es/activities.php | 8 + lang/es/editor.php | 2 + lang/es/entities.php | 21 ++ lang/es/errors.php | 12 + lang/es/settings.php | 1 + lang/es/validation.php | 5 + lang/es_AR/activities.php | 8 + lang/es_AR/editor.php | 2 + lang/es_AR/entities.php | 21 ++ lang/es_AR/errors.php | 12 + lang/es_AR/settings.php | 1 + lang/es_AR/validation.php | 5 + lang/et/activities.php | 8 + lang/et/editor.php | 2 + lang/et/entities.php | 21 ++ lang/et/errors.php | 12 + lang/et/settings.php | 1 + lang/et/validation.php | 5 + lang/eu/activities.php | 8 + lang/eu/editor.php | 2 + lang/eu/entities.php | 21 ++ lang/eu/errors.php | 12 + lang/eu/settings.php | 1 + lang/eu/validation.php | 5 + lang/fa/activities.php | 8 + lang/fa/editor.php | 2 + lang/fa/entities.php | 21 ++ lang/fa/errors.php | 12 + lang/fa/settings.php | 1 + lang/fa/validation.php | 5 + lang/fi/activities.php | 8 + lang/fi/editor.php | 2 + lang/fi/entities.php | 21 ++ lang/fi/errors.php | 12 + lang/fi/settings.php | 1 + lang/fi/validation.php | 5 + lang/fr/activities.php | 8 + lang/fr/editor.php | 2 + lang/fr/entities.php | 21 ++ lang/fr/errors.php | 12 + lang/fr/settings.php | 1 + lang/fr/validation.php | 5 + lang/he/activities.php | 8 + lang/he/editor.php | 2 + lang/he/entities.php | 21 ++ lang/he/errors.php | 12 + lang/he/settings.php | 1 + lang/he/validation.php | 5 + lang/hr/activities.php | 8 + lang/hr/editor.php | 2 + lang/hr/entities.php | 21 ++ lang/hr/errors.php | 12 + lang/hr/settings.php | 1 + lang/hr/validation.php | 5 + lang/hu/activities.php | 8 + lang/hu/editor.php | 2 + lang/hu/entities.php | 21 ++ lang/hu/errors.php | 12 + lang/hu/settings.php | 1 + lang/hu/validation.php | 5 + lang/id/activities.php | 8 + lang/id/editor.php | 2 + lang/id/entities.php | 21 ++ lang/id/errors.php | 12 + lang/id/settings.php | 1 + lang/id/validation.php | 5 + lang/is/activities.php | 132 +++++++++ lang/is/auth.php | 117 ++++++++ lang/is/common.php | 113 ++++++++ lang/is/components.php | 46 ++++ lang/is/editor.php | 179 +++++++++++++ lang/is/entities.php | 460 ++++++++++++++++++++++++++++++++ lang/is/errors.php | 133 +++++++++ lang/is/notifications.php | 27 ++ lang/is/pagination.php | 12 + lang/is/passwords.php | 15 ++ lang/is/preferences.php | 51 ++++ lang/is/settings.php | 339 +++++++++++++++++++++++ lang/is/validation.php | 122 +++++++++ lang/it/activities.php | 8 + lang/it/editor.php | 2 + lang/it/entities.php | 21 ++ lang/it/errors.php | 12 + lang/it/settings.php | 1 + lang/it/validation.php | 5 + lang/ja/activities.php | 8 + lang/ja/editor.php | 2 + lang/ja/entities.php | 21 ++ lang/ja/errors.php | 12 + lang/ja/settings.php | 1 + lang/ja/validation.php | 5 + lang/ka/activities.php | 8 + lang/ka/editor.php | 2 + lang/ka/entities.php | 21 ++ lang/ka/errors.php | 12 + lang/ka/settings.php | 1 + lang/ka/validation.php | 5 + lang/ko/activities.php | 10 +- lang/ko/common.php | 4 +- lang/ko/components.php | 6 +- lang/ko/editor.php | 2 + lang/ko/entities.php | 37 ++- lang/ko/errors.php | 12 + lang/ko/settings.php | 1 + lang/ko/validation.php | 5 + lang/lt/activities.php | 8 + lang/lt/editor.php | 2 + lang/lt/entities.php | 21 ++ lang/lt/errors.php | 12 + lang/lt/settings.php | 1 + lang/lt/validation.php | 5 + lang/lv/activities.php | 8 + lang/lv/editor.php | 2 + lang/lv/entities.php | 21 ++ lang/lv/errors.php | 12 + lang/lv/settings.php | 1 + lang/lv/validation.php | 5 + lang/nb/activities.php | 8 + lang/nb/common.php | 2 +- lang/nb/editor.php | 2 + lang/nb/entities.php | 25 +- lang/nb/errors.php | 14 +- lang/nb/settings.php | 1 + lang/nb/validation.php | 5 + lang/nl/activities.php | 8 + lang/nl/editor.php | 2 + lang/nl/entities.php | 21 ++ lang/nl/errors.php | 12 + lang/nl/settings.php | 1 + lang/nl/validation.php | 5 + lang/nn/activities.php | 8 + lang/nn/editor.php | 2 + lang/nn/entities.php | 21 ++ lang/nn/errors.php | 12 + lang/nn/settings.php | 1 + lang/nn/validation.php | 5 + lang/pl/activities.php | 8 + lang/pl/editor.php | 2 + lang/pl/entities.php | 21 ++ lang/pl/errors.php | 12 + lang/pl/settings.php | 1 + lang/pl/validation.php | 5 + lang/pt/activities.php | 8 + lang/pt/editor.php | 2 + lang/pt/entities.php | 21 ++ lang/pt/errors.php | 12 + lang/pt/settings.php | 1 + lang/pt/validation.php | 5 + lang/pt_BR/activities.php | 8 + lang/pt_BR/editor.php | 2 + lang/pt_BR/entities.php | 23 +- lang/pt_BR/errors.php | 12 + lang/pt_BR/settings.php | 1 + lang/pt_BR/validation.php | 5 + lang/ro/activities.php | 8 + lang/ro/editor.php | 2 + lang/ro/entities.php | 21 ++ lang/ro/errors.php | 12 + lang/ro/settings.php | 1 + lang/ro/validation.php | 5 + lang/ru/activities.php | 8 + lang/ru/editor.php | 2 + lang/ru/entities.php | 21 ++ lang/ru/errors.php | 12 + lang/ru/settings.php | 1 + lang/ru/validation.php | 5 + lang/sk/activities.php | 8 + lang/sk/editor.php | 2 + lang/sk/entities.php | 21 ++ lang/sk/errors.php | 12 + lang/sk/settings.php | 1 + lang/sk/validation.php | 5 + lang/sl/activities.php | 8 + lang/sl/editor.php | 2 + lang/sl/entities.php | 21 ++ lang/sl/errors.php | 12 + lang/sl/settings.php | 1 + lang/sl/validation.php | 5 + lang/sq/activities.php | 8 + lang/sq/editor.php | 2 + lang/sq/entities.php | 21 ++ lang/sq/errors.php | 12 + lang/sq/settings.php | 1 + lang/sq/validation.php | 5 + lang/sr/activities.php | 8 + lang/sr/editor.php | 2 + lang/sr/entities.php | 21 ++ lang/sr/errors.php | 12 + lang/sr/settings.php | 1 + lang/sr/validation.php | 5 + lang/sv/activities.php | 8 + lang/sv/editor.php | 2 + lang/sv/entities.php | 21 ++ lang/sv/errors.php | 12 + lang/sv/settings.php | 1 + lang/sv/validation.php | 5 + lang/tk/activities.php | 8 + lang/tk/editor.php | 2 + lang/tk/entities.php | 21 ++ lang/tk/errors.php | 12 + lang/tk/settings.php | 1 + lang/tk/validation.php | 5 + lang/tr/activities.php | 8 + lang/tr/editor.php | 2 + lang/tr/entities.php | 21 ++ lang/tr/errors.php | 12 + lang/tr/settings.php | 1 + lang/tr/validation.php | 5 + lang/uk/activities.php | 8 + lang/uk/editor.php | 2 + lang/uk/entities.php | 21 ++ lang/uk/errors.php | 12 + lang/uk/settings.php | 1 + lang/uk/validation.php | 5 + lang/uz/activities.php | 8 + lang/uz/editor.php | 2 + lang/uz/entities.php | 21 ++ lang/uz/errors.php | 12 + lang/uz/settings.php | 1 + lang/uz/validation.php | 5 + lang/vi/activities.php | 8 + lang/vi/editor.php | 2 + lang/vi/entities.php | 21 ++ lang/vi/errors.php | 12 + lang/vi/settings.php | 1 + lang/vi/validation.php | 5 + lang/zh_CN/activities.php | 8 + lang/zh_CN/components.php | 2 +- lang/zh_CN/editor.php | 2 + lang/zh_CN/entities.php | 21 ++ lang/zh_CN/errors.php | 12 + lang/zh_CN/settings.php | 1 + lang/zh_CN/validation.php | 5 + lang/zh_TW/activities.php | 8 + lang/zh_TW/common.php | 2 +- lang/zh_TW/editor.php | 2 + lang/zh_TW/entities.php | 25 +- lang/zh_TW/errors.php | 14 +- lang/zh_TW/settings.php | 1 + lang/zh_TW/validation.php | 5 + 318 files changed, 5889 insertions(+), 94 deletions(-) create mode 100644 lang/bn/activities.php create mode 100644 lang/bn/auth.php create mode 100644 lang/bn/common.php create mode 100644 lang/bn/components.php create mode 100644 lang/bn/editor.php create mode 100644 lang/bn/entities.php create mode 100644 lang/bn/errors.php create mode 100644 lang/bn/notifications.php create mode 100644 lang/bn/pagination.php create mode 100644 lang/bn/passwords.php create mode 100644 lang/bn/preferences.php create mode 100644 lang/bn/settings.php create mode 100644 lang/bn/validation.php create mode 100644 lang/is/activities.php create mode 100644 lang/is/auth.php create mode 100644 lang/is/common.php create mode 100644 lang/is/components.php create mode 100644 lang/is/editor.php create mode 100644 lang/is/entities.php create mode 100644 lang/is/errors.php create mode 100644 lang/is/notifications.php create mode 100644 lang/is/pagination.php create mode 100644 lang/is/passwords.php create mode 100644 lang/is/preferences.php create mode 100644 lang/is/settings.php create mode 100644 lang/is/validation.php diff --git a/lang/ar/activities.php b/lang/ar/activities.php index 134dce4cd98..127ddbe1aba 100644 --- a/lang/ar/activities.php +++ b/lang/ar/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'حذف webhook', 'webhook_delete_notification' => 'تم حذف Webhook بنجاح', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'إنشاء مستخدم', 'user_create_notification' => 'تم انشاء الحساب', diff --git a/lang/ar/editor.php b/lang/ar/editor.php index 6fb6e3d1153..2254eb9ba6e 100644 --- a/lang/ar/editor.php +++ b/lang/ar/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/ar/entities.php b/lang/ar/entities.php index 1740ca9f999..065516bcfd8 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'ملف PDF', 'export_text' => 'ملف نص عادي', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'الأذونات', diff --git a/lang/ar/errors.php b/lang/ar/errors.php index 7788c54caca..73e76a5cc01 100644 --- a/lang/ar/errors.php +++ b/lang/ar/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName لا يعمل حالياً', 'back_soon' => 'سيعود للعمل قريباً.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'لم يتم العثور على رمز ترخيص مميز في الطلب', 'api_bad_authorization_format' => 'تم العثور على رمز ترخيص مميز في الطلب ولكن يبدو أن التنسيق غير صحيح', diff --git a/lang/ar/settings.php b/lang/ar/settings.php index 0d8ebcfea4a..ca8a7d90916 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API', 'role_manage_settings' => 'إدارة إعدادات التطبيق', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'أذونات الأصول', diff --git a/lang/ar/validation.php b/lang/ar/validation.php index a9793c54844..f35c920348c 100644 --- a/lang/ar/validation.php +++ b/lang/ar/validation.php @@ -105,6 +105,11 @@ 'url' => 'صيغة :attribute غير صالحة.', 'uploaded' => 'تعذر تحميل الملف. قد لا يقبل الخادم ملفات بهذا الحجم.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/bg/activities.php b/lang/bg/activities.php index e1e43a0d267..d7ac37c1053 100644 --- a/lang/bg/activities.php +++ b/lang/bg/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'изтрита уебкука', 'webhook_delete_notification' => 'Уебкуката е изтрита успешно', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/bg/editor.php b/lang/bg/editor.php index 5f6fa1a6a7a..a530820381b 100644 --- a/lang/bg/editor.php +++ b/lang/bg/editor.php @@ -163,6 +163,8 @@ 'about' => 'За редактора', 'about_title' => 'Относно визуалния редактор', 'editor_license' => 'Лиценз, авторски и сходни права на редактора', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Този редактор е изграден посредством :tinyLink, което е предоставен под лиценз MIT.', 'editor_tiny_license_link' => 'Авторското и сходните му права, както и лицензът на TinyMCE, могат да бъдат намерени тук.', 'save_continue' => 'Запази страницата и продължи', diff --git a/lang/bg/entities.php b/lang/bg/entities.php index 468256d7bac..1fdb3f6346b 100644 --- a/lang/bg/entities.php +++ b/lang/bg/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF файл', 'export_text' => 'Обикновен текстов файл', 'export_md' => 'Markdown файл', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Права', diff --git a/lang/bg/errors.php b/lang/bg/errors.php index 73909b344a9..dd024518083 100644 --- a/lang/bg/errors.php +++ b/lang/bg/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName не е достъпно в момента', 'back_soon' => 'Ще се върне обратно онлайн скоро.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Но беше намерен код за достъп в заявката', 'api_bad_authorization_format' => 'В заявката имаше код за достъп, но формата изглежда е неправилен', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index 7faca69222a..1bc617fc9ed 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Достъп до API на системата', 'role_manage_settings' => 'Управление на настройките на приложението', 'role_export_content' => 'Експортирай съдържанието', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Настройки за достъп до активи', diff --git a/lang/bg/validation.php b/lang/bg/validation.php index fb6c4dd4a63..e08eb55de61 100644 --- a/lang/bg/validation.php +++ b/lang/bg/validation.php @@ -105,6 +105,11 @@ 'url' => 'Форматът на :attribute не е валиден.', 'uploaded' => 'Файлът не можа да бъде качен. Сървърът може да не приема файлове с такъв размер.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/bn/activities.php b/lang/bn/activities.php new file mode 100644 index 00000000000..e46d85fbb0a --- /dev/null +++ b/lang/bn/activities.php @@ -0,0 +1,132 @@ + 'নতুন পৃষ্ঠা সৃষ্টি করেছেন', + 'page_create_notification' => 'পৃষ্ঠাটি সার্থকভাবে তৈরী করা হয়েছে', + 'page_update' => 'পৃষ্ঠা হালনাগাদ করেছেন', + 'page_update_notification' => 'পৃষ্ঠাটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'page_delete' => 'পৃষ্ঠা মুছে ফেলেছেন', + 'page_delete_notification' => 'পৃষ্ঠাটি সার্থকভাবে মুছে ফেলা হয়েছে', + 'page_restore' => 'মুছে ফেলা পৃষ্ঠা পুনরুদ্ধার করেছেন', + 'page_restore_notification' => 'পৃষ্ঠাটি সার্থকভাবে পুনরুদ্ধার করা হয়েছে', + 'page_move' => 'পৃষ্ঠা স্থানান্তর করেছেন', + 'page_move_notification' => 'পৃষ্ঠাটি সার্থকভাবে স্থানান্তর করা হয়েছে', + + // Chapters + 'chapter_create' => 'নতুন অধ্যায় সৃষ্টি করেছেন', + 'chapter_create_notification' => 'অধ্যায়টি সার্থকভাবে তৈরী করা হয়েছে', + 'chapter_update' => 'অধ্যায় হালনাগাদ করেছেন', + 'chapter_update_notification' => 'অধ্যায়টি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'chapter_delete' => 'অধ্যায় মুছে ফেলেছেন', + 'chapter_delete_notification' => 'অধ্যায়টি সার্থকভাবে মুছে ফেলা হয়েছে', + 'chapter_move' => 'অধ্যায় স্থানান্তর করেছেন', + 'chapter_move_notification' => 'অধ্যায়টি সার্থকভাবে স্থানান্তর করা হয়েছে', + + // Books + 'book_create' => 'নতুন বই সৃষ্টি করেছেন', + 'book_create_notification' => 'বইটি সার্থকভাবে তৈরী করা হয়েছে', + 'book_create_from_chapter' => 'অধ্যায়কে বইতে রূপান্তরিত করেছেন', + 'book_create_from_chapter_notification' => 'অধ্যায়কে বইতে রূপান্তর করার প্রক্রিয়া সফলভাবে সম্পন্ন হয়েছে', + 'book_update' => 'বই হালনাগাদ করেছেন', + 'book_update_notification' => 'বইটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'book_delete' => 'বই মুছে ফেলেছেন', + 'book_delete_notification' => 'বইটি সার্থকভাবে মুছে ফেলা হয়েছে', + 'book_sort' => 'বইটি ক্রমানুযায়ী সাজিয়েছেন', + 'book_sort_notification' => 'বইটি সার্থকভাবে ক্রমানুযায়ী সাজানো হয়েছে', + + // Bookshelves + 'bookshelf_create' => 'নতুন বুকশেলফ তৈরী করেছেন', + 'bookshelf_create_notification' => 'বুকশেলফটি সার্থকভাবে তৈরী করা হয়েছে', + 'bookshelf_create_from_book' => 'বইটিকে বুকশেলফে রূপান্তরিত করার প্রক্রিয়া সফলভাবে সম্পন্ন হয়েছে', + 'bookshelf_create_from_book_notification' => 'বইকে বুকশেলফে রূপান্তর করার প্রক্রিয়া সফলভাবে সম্পন্ন হয়েছে', + 'bookshelf_update' => 'বুকশেলফটি হালনাগাদ করেছেন', + 'bookshelf_update_notification' => 'বুকশেলফটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'bookshelf_delete' => 'বুকশেলফটি মুছে ফেলেছেন', + 'bookshelf_delete_notification' => 'বুকশেলফটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Revisions + 'revision_restore' => 'সংশোধনী পুনঃস্থাপন করেছেন', + 'revision_delete' => 'সংশোধনী মুছে ফেলেছেন', + 'revision_delete_notification' => 'সংশোধনী সার্থকভাবে মুছে ফেলা হয়েছে', + + // Favourites + 'favourite_add_notification' => 'আপনার প্রিয় তালিকায় ":name" যোগ করা হয়েছে', + 'favourite_remove_notification' => 'আপনার প্রিয় তালিকা হতে ":name"-কে মুছে ফেলা হয়েছে', + + // Watching + 'watch_update_level_notification' => 'পর্যবেক্ষণনীতি সার্থকভাবে হালনাগাদ করা হয়েছে', + + // Auth + 'auth_login' => 'লগড ইন অবস্থায় আছেন', + 'auth_register' => 'নতুন ব্যবহারকারী হিসাবে নিবন্ধিত', + 'auth_password_reset_request' => 'ব্যবহারকারীর পাসওয়ার্ড রিসেটের আবেদন করেছেন', + 'auth_password_reset_update' => 'ব্যবহারকারী পাসওয়ার্ড রিসেট করুন', + 'mfa_setup_method' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন সক্রিয় করেছেন', + 'mfa_setup_method_notification' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন সার্থকভাবে সক্রিয় করা হয়েছে', + 'mfa_remove_method' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন নিষ্ক্রিয় করেছেন', + 'mfa_remove_method_notification' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন সার্থকভাবে নিষ্ক্রিয় করা হয়েছে', + + // Settings + 'settings_update' => 'সেটিংস হালনাগাদ করেছেন', + 'settings_update_notification' => 'সেটিংস সার্থকভাবে হালনাগাদ করা হয়েছে', + 'maintenance_action_run' => 'রক্ষণাবেক্ষণ কার্যক্রম চালু করেছেন', + + // Webhooks + 'webhook_create' => 'নতুন ওয়েবহুক তৈরী করেছেন', + 'webhook_create_notification' => 'নতুন ওয়েবহুক সার্থকভাবে তৈরী করা হয়েছে', + 'webhook_update' => 'ওয়েবহুকটি হালনাগাদ করেছেন', + 'webhook_update_notification' => 'ওয়েবহুকটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'webhook_delete' => 'ওয়েবহুকটি মুছে ফেলেছেন', + 'webhook_delete_notification' => 'ওয়েবহুকটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Imports + 'import_create' => 'ইমপোর্টটি তৈরী করেছেন', + 'import_create_notification' => 'ইমপোর্টটি সার্থকভাবে আপলোড করা হয়েছে', + 'import_run' => 'ইমপোর্টটি হালনাগাদ করেছেন', + 'import_run_notification' => 'কনটেন্ট সার্থকভাবে ইমপোর্ট করা হয়েছে', + 'import_delete' => 'ইমপোর্টটি মুছে ফেলেছেন', + 'import_delete_notification' => 'ইমপোর্টটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Users + 'user_create' => 'নতুন ব্যবহারকারী তৈরী করেছেন', + 'user_create_notification' => 'নতুন ব্যবহারকারী সার্থকভাবে তৈরী করা হয়েছে', + 'user_update' => 'ব্যবহারকারীটি হালনাগাদ করেছেন', + 'user_update_notification' => 'ব্যবহারকারীটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'user_delete' => 'ব্যবহারকারীটি মুছে ফেলেছেন', + 'user_delete_notification' => 'ব্যবহারকারীটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // API Tokens + 'api_token_create' => 'এপিআই টোকেনটি তৈরী করেছেন', + 'api_token_create_notification' => 'এপিআই টোকেনটি সার্থকভাবে তৈরী করা হয়েছে', + 'api_token_update' => 'এপিআই টোকেনটি হালনাগাদ করেছেন', + 'api_token_update_notification' => 'এপিআই টোকেনটি হালনাগাদ করা হয়েছে', + 'api_token_delete' => 'এপিআই টোকেনটি মুছে ফেলেছেন', + 'api_token_delete_notification' => 'এপিআই টোকেনটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Roles + 'role_create' => 'রোলটি তৈরী করেছেন', + 'role_create_notification' => 'রোলটি সার্থকভাবে তৈরী করা হয়েছে', + 'role_update' => 'রোলটি হালনাগাদ করেছেন', + 'role_update_notification' => 'রোলটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'role_delete' => 'রোলটি মুছে ফেলেছেন', + 'role_delete_notification' => 'রোলটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Recycle Bin + 'recycle_bin_empty' => 'রিসাইকেল বিন খালি করে ফেলেছেন', + 'recycle_bin_restore' => 'রিসাইকেল বিন হতে প্রত্যাবর্তন করা হয়েছে', + 'recycle_bin_destroy' => 'রিসাইকেল বিন হতে অপসারণ করা হয়েছে', + + // Comments + 'commented_on' => 'মন্তব্য প্রদান করেছেন', + 'comment_create' => 'মন্তব্য যোগ করেছেন', + 'comment_update' => 'মন্তব্য হালনাগাদ করেছেন', + 'comment_delete' => 'মন্তব্য মুছে ফেলেছেন', + + // Other + 'permissions_update' => 'অনুমতিক্রম হালনাগাদ করেছেন', +]; diff --git a/lang/bn/auth.php b/lang/bn/auth.php new file mode 100644 index 00000000000..aca585962cc --- /dev/null +++ b/lang/bn/auth.php @@ -0,0 +1,117 @@ + 'প্রদত্ত তথ্যনিরূপিত কোন রেকর্ড পাওয়া যায়নি।', + 'throttle' => 'লগইন প্রচেষ্টার সীমা অতিক্রান্ত। দয়া করে :seconds সেকেন্ড পর আবার চেষ্টা করুন।', + + // Login & Register + 'sign_up' => 'নিবন্ধিত হোন', + 'log_in' => 'লগ ইন করুন', + 'log_in_with' => ':socialDriver দ্বারা লগইন করুন', + 'sign_up_with' => ':socialDriver দ্বারা নিবন্ধিত হোন', + 'logout' => 'Logout', + + 'name' => 'Name', + 'username' => 'Username', + 'email' => 'Email', + 'password' => 'Password', + 'password_confirm' => 'Confirm Password', + 'password_hint' => 'Must be at least 8 characters', + 'forgot_password' => 'Forgot Password?', + 'remember_me' => 'Remember Me', + 'ldap_email_hint' => 'Please enter an email to use for this account.', + 'create_account' => 'Create Account', + 'already_have_account' => 'Already have an account?', + 'dont_have_account' => 'Don\'t have an account?', + 'social_login' => 'Social Login', + 'social_registration' => 'Social Registration', + 'social_registration_text' => 'Register and sign in using another service.', + + 'register_thanks' => 'Thanks for registering!', + 'register_confirm' => 'Please check your email and click the confirmation button to access :appName.', + 'registrations_disabled' => 'Registrations are currently disabled', + 'registration_email_domain_invalid' => 'That email domain does not have access to this application', + 'register_success' => 'Thanks for signing up! You are now registered and signed in.', + + // Login auto-initiation + 'auto_init_starting' => 'Attempting Login', + 'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.', + 'auto_init_start_link' => 'Proceed with authentication', + + // Password Reset + 'reset_password' => 'Reset Password', + 'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.', + 'reset_password_send_button' => 'Send Reset Link', + 'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.', + 'reset_password_success' => 'Your password has been successfully reset.', + 'email_reset_subject' => 'Reset your :appName password', + 'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.', + 'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.', + + // Email Confirmation + 'email_confirm_subject' => 'Confirm your email on :appName', + 'email_confirm_greeting' => 'Thanks for joining :appName!', + 'email_confirm_text' => 'Please confirm your email address by clicking the button below:', + 'email_confirm_action' => 'Confirm Email', + 'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.', + 'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.', + 'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.', + 'email_confirm_thanks' => 'Thanks for confirming!', + 'email_confirm_thanks_desc' => 'Please wait a moment while your confirmation is handled. If you are not redirected after 3 seconds press the "Continue" link below to proceed.', + + 'email_not_confirmed' => 'Email Address Not Confirmed', + 'email_not_confirmed_text' => 'Your email address has not yet been confirmed.', + 'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.', + 'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.', + 'email_not_confirmed_resend_button' => 'Resend Confirmation Email', + + // User Invite + 'user_invite_email_subject' => 'You have been invited to join :appName!', + 'user_invite_email_greeting' => 'An account has been created for you on :appName.', + 'user_invite_email_text' => 'Click the button below to set an account password and gain access:', + 'user_invite_email_action' => 'Set Account Password', + 'user_invite_page_welcome' => 'Welcome to :appName!', + 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', + 'user_invite_page_confirm_button' => 'Confirm Password', + 'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!', + + // Multi-factor Authentication + 'mfa_setup' => 'Setup Multi-Factor Authentication', + 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'mfa_setup_configured' => 'Already configured', + 'mfa_setup_reconfigure' => 'Reconfigure', + 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', + 'mfa_setup_action' => 'Setup', + 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', + 'mfa_option_totp_title' => 'Mobile App', + 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Backup Codes', + 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', + 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', + 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', + 'mfa_gen_backup_codes_download' => 'Download Codes', + 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', + 'mfa_gen_totp_title' => 'Mobile App Setup', + 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', + 'mfa_gen_totp_verify_setup' => 'Verify Setup', + 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', + 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', + 'mfa_verify_access' => 'Verify Access', + 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', + 'mfa_verify_no_methods' => 'No Methods Configured', + 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', + 'mfa_verify_use_totp' => 'Verify using a mobile app', + 'mfa_verify_use_backup_codes' => 'Verify using a backup code', + 'mfa_verify_backup_code' => 'Backup Code', + 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', + 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', + 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', + 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', +]; diff --git a/lang/bn/common.php b/lang/bn/common.php new file mode 100644 index 00000000000..8f425abee93 --- /dev/null +++ b/lang/bn/common.php @@ -0,0 +1,113 @@ + 'প্রত্যাহার করুন', + 'close' => 'বন্ধ করুন', + 'confirm' => 'নিশ্চিত করুন', + 'back' => 'প্রত্যাবর্তন করুন', + 'save' => 'সংরক্ষণ করুন', + 'continue' => 'অগ্রসর হউন', + 'select' => 'নির্বাচন করুন', + 'toggle_all' => 'সবগুলোকে টগল করুন', + 'more' => 'বিস্তারিত', + + // Form Labels + 'name' => 'নাম', + 'description' => 'বিবরণ', + 'role' => 'রোল', + 'cover_image' => 'প্রচ্ছদ ছবি', + 'cover_image_description' => 'এই চিত্রটি আনুমানিক 440x250px হওয়া বাঞ্চনীয়। ক্ষেত্রবিশেষে ও ব্যবহারকারীর ইন্টারফেসের সাথে মানানসই করে উপস্থাপন করার জন্যে প্রয়োজনে এর আকার পরিবর্তন করে প্রদর্শন করা হবে, যা প্রকৃত মাত্রা হতে ভিন্ন হবে৷', + + // Actions + 'actions' => 'কার্যক্রম', + 'view' => 'দেখুন', + 'view_all' => 'সব দেখুন', + 'new' => 'নতুন', + 'create' => 'তৈরী করুন', + 'update' => 'হালনাগাদ করুন', + 'edit' => 'সম্পাদন করুন', + 'sort' => 'ক্রমান্বয় করুন', + 'move' => 'স্থানান্তর করুন', + 'copy' => 'অনুলিপি করুন', + 'reply' => 'প্রত্যুত্তর করুন', + 'delete' => 'মুছে ফেলুন', + 'delete_confirm' => 'মুছে ফেলা নিশ্চিত করুন', + 'search' => 'অনুসন্ধান করুন', + 'search_clear' => 'অনুসন্ধান পুনঃসূচনা করুন', + 'reset' => 'পুনঃসূচনা করুন', + 'remove' => 'অপসারণ করুন', + 'add' => 'যোগ করুন', + 'configure' => 'সংস্থাপন করুন', + 'manage' => 'ব্যবস্থাপনা করুন', + 'fullscreen' => 'ফুলস্ক্রিন', + 'favourite' => 'প্রিয় তালিকায় যুক্ত করুন', + 'unfavourite' => 'প্রিয় তালিকা হতে অপসারণ করুন', + 'next' => 'পরবর্তী', + 'previous' => 'পূর্ববর্তী', + 'filter_active' => 'Active Filter:', + 'filter_clear' => 'Clear Filter', + 'download' => 'Download', + 'open_in_tab' => 'Open in Tab', + 'open' => 'Open', + + // Sort Options + 'sort_options' => 'Sort Options', + 'sort_direction_toggle' => 'Sort Direction Toggle', + 'sort_ascending' => 'Sort Ascending', + 'sort_descending' => 'Sort Descending', + 'sort_name' => 'Name', + 'sort_default' => 'Default', + 'sort_created_at' => 'Created Date', + 'sort_updated_at' => 'Updated Date', + + // Misc + 'deleted_user' => 'Deleted User', + 'no_activity' => 'No activity to show', + 'no_items' => 'No items available', + 'back_to_top' => 'Back to top', + 'skip_to_main_content' => 'Skip to main content', + 'toggle_details' => 'Toggle Details', + 'toggle_thumbnails' => 'Toggle Thumbnails', + 'details' => 'Details', + 'grid_view' => 'Grid View', + 'list_view' => 'List View', + 'default' => 'Default', + 'breadcrumb' => 'Breadcrumb', + 'status' => 'অবস্থা', + 'status_active' => 'Active', + 'status_inactive' => 'নিষ্ক্রিয়', + 'never' => 'অভূতপূর্ব', + 'none' => 'None', + + // Header + 'homepage' => 'নীড়পাতা', + 'header_menu_expand' => 'হেডার মেন্যু প্রসারিত করুন', + 'profile_menu' => 'প্রোফাইল মেন্যু', + 'view_profile' => 'প্রোফাইল দেখুন', + 'edit_profile' => 'প্রোফাইল সম্পাদনা করুন', + 'dark_mode' => 'নৈশরূপ', + 'light_mode' => 'দিবারূপ', + 'global_search' => 'সকল স্থানে অনুসন্ধান', + + // Layout tabs + 'tab_info' => 'তথ্য', + 'tab_info_label' => 'ট্যাব: গৌণ তথ্য', + 'tab_content' => 'কনটেন্ট', + 'tab_content_label' => 'ট্যাব: মূখ্য তথ্য', + + // Email Content + 'email_action_help' => 'আপনার যদি ":actionText"-এ ক্লিক করতে সমস্যা হয়, তবে নিচের লিংকটি কপি করে আপনার ওয়েব ব্রাউজারে পেস্ট করুন:', + 'email_rights' => 'সর্বস্বত্ব সংরক্ষিত', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'গোপনীয়তা নীতি', + 'terms_of_service' => 'পরিষেবার শর্তাবলী', + + // OpenSearch + 'opensearch_description' => 'অনুসন্ধান :appName', +]; diff --git a/lang/bn/components.php b/lang/bn/components.php new file mode 100644 index 00000000000..c33b1d0b791 --- /dev/null +++ b/lang/bn/components.php @@ -0,0 +1,46 @@ + 'Image Select', + 'image_list' => 'Image List', + 'image_details' => 'Image Details', + 'image_upload' => 'Upload Image', + 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', + 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_all' => 'All', + 'image_all_title' => 'View all images', + 'image_book_title' => 'View images uploaded to this book', + 'image_page_title' => 'View images uploaded to this page', + 'image_search_hint' => 'Search by image name', + 'image_uploaded' => 'Uploaded :uploadedDate', + 'image_uploaded_by' => 'Uploaded by :userName', + 'image_uploaded_to' => 'Uploaded to :pageLink', + 'image_updated' => 'Updated :updateDate', + 'image_load_more' => 'Load More', + 'image_image_name' => 'Image Name', + 'image_delete_used' => 'This image is used in the pages below.', + 'image_delete_confirm_text' => 'Are you sure you want to delete this image?', + 'image_select_image' => 'Select Image', + 'image_dropzone' => 'Drop images or click here to upload', + 'image_dropzone_drop' => 'Drop images here to upload', + 'images_deleted' => 'Images Deleted', + 'image_preview' => 'Image Preview', + 'image_upload_success' => 'Image uploaded successfully', + 'image_update_success' => 'Image details successfully updated', + 'image_delete_success' => 'Image successfully deleted', + 'image_replace' => 'Replace Image', + 'image_replace_success' => 'Image file successfully updated', + 'image_rebuild_thumbs' => 'Regenerate Size Variations', + 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + + // Code Editor + 'code_editor' => 'Edit Code', + 'code_language' => 'Code Language', + 'code_content' => 'Code Content', + 'code_session_history' => 'Session History', + 'code_save' => 'Save Code', +]; diff --git a/lang/bn/editor.php b/lang/bn/editor.php new file mode 100644 index 00000000000..8bcef5f23ce --- /dev/null +++ b/lang/bn/editor.php @@ -0,0 +1,179 @@ + 'সাধারণ', + 'advanced' => 'উন্নত', + 'none' => 'অপ্রযোজ্য', + 'cancel' => 'প্রত্যাহার করুন', + 'save' => 'সংরক্ষণ করুন', + 'close' => 'বন্ধ করুন', + 'undo' => 'প্রত্যাহার করুন', + 'redo' => 'পুনর্বহাল রাখুন', + 'left' => 'বাম', + 'center' => 'মধ্য', + 'right' => 'ডান', + 'top' => 'উপর', + 'middle' => 'মধ্য', + 'bottom' => 'নিচে', + 'width' => 'প্রস্থ', + 'height' => 'উচ্চতা', + 'More' => 'বিস্তারিত', + 'select' => 'নির্বাচন করুন...', + + // Toolbar + 'formats' => 'প্রকরণ', + 'header_large' => 'বড় হেডার', + 'header_medium' => 'মাঝারি হেডার', + 'header_small' => 'ছোট হেডার', + 'header_tiny' => 'ক্ষুদ্র হেডার', + 'paragraph' => 'প্যারাগ্রাফ', + 'blockquote' => 'ব্লককোট', + 'inline_code' => 'ইনলাইন কোড', + 'callouts' => 'কলআউট', + 'callout_information' => 'তথ্যমূলক', + 'callout_success' => 'সফলজনক', + 'callout_warning' => 'সতর্কতামূলক', + 'callout_danger' => 'বিপদজনক', + 'bold' => 'বোল্ড', + 'italic' => 'ইটালিক', + 'underline' => 'আন্ডারলাইন', + 'strikethrough' => 'স্ট্রাইকথ্রু', + 'superscript' => 'Superscript', + 'subscript' => 'Subscript', + 'text_color' => 'Text color', + 'custom_color' => 'Custom color', + 'remove_color' => 'Remove color', + 'background_color' => 'Background color', + 'align_left' => 'Align left', + 'align_center' => 'Align center', + 'align_right' => 'Align right', + 'align_justify' => 'Justify', + 'list_bullet' => 'Bullet list', + 'list_numbered' => 'Numbered list', + 'list_task' => 'Task list', + 'indent_increase' => 'Increase indent', + 'indent_decrease' => 'Decrease indent', + 'table' => 'Table', + 'insert_image' => 'Insert image', + 'insert_image_title' => 'Insert/Edit Image', + 'insert_link' => 'Insert/edit link', + 'insert_link_title' => 'Insert/Edit Link', + 'insert_horizontal_line' => 'Insert horizontal line', + 'insert_code_block' => 'Insert code block', + 'edit_code_block' => 'Edit code block', + 'insert_drawing' => 'Insert/edit drawing', + 'drawing_manager' => 'Drawing manager', + 'insert_media' => 'Insert/edit media', + 'insert_media_title' => 'Insert/Edit Media', + 'clear_formatting' => 'Clear formatting', + 'source_code' => 'Source code', + 'source_code_title' => 'Source Code', + 'fullscreen' => 'Fullscreen', + 'image_options' => 'Image options', + + // Tables + 'table_properties' => 'Table properties', + 'table_properties_title' => 'Table Properties', + 'delete_table' => 'Delete table', + 'table_clear_formatting' => 'Clear table formatting', + 'resize_to_contents' => 'Resize to contents', + 'row_header' => 'Row header', + 'insert_row_before' => 'Insert row before', + 'insert_row_after' => 'Insert row after', + 'delete_row' => 'Delete row', + 'insert_column_before' => 'Insert column before', + 'insert_column_after' => 'Insert column after', + 'delete_column' => 'Delete column', + 'table_cell' => 'Cell', + 'table_row' => 'Row', + 'table_column' => 'Column', + 'cell_properties' => 'Cell properties', + 'cell_properties_title' => 'Cell Properties', + 'cell_type' => 'Cell type', + 'cell_type_cell' => 'Cell', + 'cell_scope' => 'Scope', + 'cell_type_header' => 'Header cell', + 'merge_cells' => 'Merge cells', + 'split_cell' => 'Split cell', + 'table_row_group' => 'Row Group', + 'table_column_group' => 'Column Group', + 'horizontal_align' => 'Horizontal align', + 'vertical_align' => 'Vertical align', + 'border_width' => 'Border width', + 'border_style' => 'Border style', + 'border_color' => 'Border color', + 'row_properties' => 'Row properties', + 'row_properties_title' => 'Row Properties', + 'cut_row' => 'Cut row', + 'copy_row' => 'Copy row', + 'paste_row_before' => 'Paste row before', + 'paste_row_after' => 'Paste row after', + 'row_type' => 'Row type', + 'row_type_header' => 'Header', + 'row_type_body' => 'Body', + 'row_type_footer' => 'Footer', + 'alignment' => 'Alignment', + 'cut_column' => 'Cut column', + 'copy_column' => 'Copy column', + 'paste_column_before' => 'Paste column before', + 'paste_column_after' => 'Paste column after', + 'cell_padding' => 'Cell padding', + 'cell_spacing' => 'Cell spacing', + 'caption' => 'Caption', + 'show_caption' => 'Show caption', + 'constrain' => 'Constrain proportions', + 'cell_border_solid' => 'Solid', + 'cell_border_dotted' => 'Dotted', + 'cell_border_dashed' => 'Dashed', + 'cell_border_double' => 'Double', + 'cell_border_groove' => 'Groove', + 'cell_border_ridge' => 'Ridge', + 'cell_border_inset' => 'Inset', + 'cell_border_outset' => 'Outset', + 'cell_border_none' => 'None', + 'cell_border_hidden' => 'Hidden', + + // Images, links, details/summary & embed + 'source' => 'Source', + 'alt_desc' => 'Alternative description', + 'embed' => 'Embed', + 'paste_embed' => 'Paste your embed code below:', + 'url' => 'URL', + 'text_to_display' => 'Text to display', + 'title' => 'Title', + 'open_link' => 'Open link', + 'open_link_in' => 'Open link in...', + 'open_link_current' => 'Current window', + 'open_link_new' => 'New window', + 'remove_link' => 'Remove link', + 'insert_collapsible' => 'Insert collapsible block', + 'collapsible_unwrap' => 'Unwrap', + 'edit_label' => 'Edit label', + 'toggle_open_closed' => 'Toggle open/closed', + 'collapsible_edit' => 'Edit collapsible block', + 'toggle_label' => 'Toggle label', + + // About view + 'about' => 'About the editor', + 'about_title' => 'About the WYSIWYG Editor', + 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', + 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', + 'save_continue' => 'Save Page & Continue', + 'callouts_cycle' => '(Keep pressing to toggle through types)', + 'link_selector' => 'Link to content', + 'shortcuts' => 'Shortcuts', + 'shortcut' => 'Shortcut', + 'shortcuts_intro' => 'The following shortcuts are available in the editor:', + 'windows_linux' => '(Windows/Linux)', + 'mac' => '(Mac)', + 'description' => 'Description', +]; diff --git a/lang/bn/entities.php b/lang/bn/entities.php new file mode 100644 index 00000000000..26a563a7eb5 --- /dev/null +++ b/lang/bn/entities.php @@ -0,0 +1,460 @@ + 'Recently Created', + 'recently_created_pages' => 'Recently Created Pages', + 'recently_updated_pages' => 'Recently Updated Pages', + 'recently_created_chapters' => 'Recently Created Chapters', + 'recently_created_books' => 'Recently Created Books', + 'recently_created_shelves' => 'Recently Created Shelves', + 'recently_update' => 'Recently Updated', + 'recently_viewed' => 'Recently Viewed', + 'recent_activity' => 'Recent Activity', + 'create_now' => 'Create one now', + 'revisions' => 'Revisions', + 'meta_revision' => 'Revision #:revisionCount', + 'meta_created' => 'Created :timeLength', + 'meta_created_name' => 'Created :timeLength by :user', + 'meta_updated' => 'Updated :timeLength', + 'meta_updated_name' => 'Updated :timeLength by :user', + 'meta_owned_name' => 'Owned by :user', + 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'entity_select' => 'Entity Select', + 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', + 'images' => 'Images', + 'my_recent_drafts' => 'My Recent Drafts', + 'my_recently_viewed' => 'My Recently Viewed', + 'my_most_viewed_favourites' => 'My Most Viewed Favourites', + 'my_favourites' => 'My Favourites', + 'no_pages_viewed' => 'You have not viewed any pages', + 'no_pages_recently_created' => 'No pages have been recently created', + 'no_pages_recently_updated' => 'No pages have been recently updated', + 'export' => 'Export', + 'export_html' => 'Contained Web File', + 'export_pdf' => 'PDF File', + 'export_text' => 'Plain Text File', + 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', + 'default_template' => 'Default Page Template', + 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', + 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + + // Permissions and restrictions + 'permissions' => 'Permissions', + 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', + 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', + 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', + 'permissions_save' => 'Save Permissions', + 'permissions_owner' => 'Owner', + 'permissions_role_everyone_else' => 'Everyone Else', + 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', + 'permissions_role_override' => 'Override permissions for role', + 'permissions_inherit_defaults' => 'Inherit defaults', + + // Search + 'search_results' => 'Search Results', + 'search_total_results_found' => ':count result found|:count total results found', + 'search_clear' => 'Clear Search', + 'search_no_pages' => 'No pages matched this search', + 'search_for_term' => 'Search for :term', + 'search_more' => 'More Results', + 'search_advanced' => 'Advanced Search', + 'search_terms' => 'Search Terms', + 'search_content_type' => 'Content Type', + 'search_exact_matches' => 'Exact Matches', + 'search_tags' => 'Tag Searches', + 'search_options' => 'Options', + 'search_viewed_by_me' => 'Viewed by me', + 'search_not_viewed_by_me' => 'Not viewed by me', + 'search_permissions_set' => 'Permissions set', + 'search_created_by_me' => 'Created by me', + 'search_updated_by_me' => 'Updated by me', + 'search_owned_by_me' => 'Owned by me', + 'search_date_options' => 'Date Options', + 'search_updated_before' => 'Updated before', + 'search_updated_after' => 'Updated after', + 'search_created_before' => 'Created before', + 'search_created_after' => 'Created after', + 'search_set_date' => 'Set Date', + 'search_update' => 'Update Search', + + // Shelves + 'shelf' => 'Shelf', + 'shelves' => 'Shelves', + 'x_shelves' => ':count Shelf|:count Shelves', + 'shelves_empty' => 'No shelves have been created', + 'shelves_create' => 'Create New Shelf', + 'shelves_popular' => 'Popular Shelves', + 'shelves_new' => 'New Shelves', + 'shelves_new_action' => 'New Shelf', + 'shelves_popular_empty' => 'The most popular shelves will appear here.', + 'shelves_new_empty' => 'The most recently created shelves will appear here.', + 'shelves_save' => 'Save Shelf', + 'shelves_books' => 'Books on this shelf', + 'shelves_add_books' => 'Add books to this shelf', + 'shelves_drag_books' => 'Drag books below to add them to this shelf', + 'shelves_empty_contents' => 'This shelf has no books assigned to it', + 'shelves_edit_and_assign' => 'Edit shelf to assign books', + 'shelves_edit_named' => 'Edit Shelf :name', + 'shelves_edit' => 'Edit Shelf', + 'shelves_delete' => 'Delete Shelf', + 'shelves_delete_named' => 'Delete Shelf :name', + 'shelves_delete_explain' => "This will delete the shelf with the name ':name'. Contained books will not be deleted.", + 'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?', + 'shelves_permissions' => 'Shelf Permissions', + 'shelves_permissions_updated' => 'Shelf Permissions Updated', + 'shelves_permissions_active' => 'Shelf Permissions Active', + 'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', + 'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.', + 'shelves_copy_permissions_to_books' => 'Copy Permissions to Books', + 'shelves_copy_permissions' => 'Copy Permissions', + 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', + 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + + // Books + 'book' => 'Book', + 'books' => 'Books', + 'x_books' => ':count Book|:count Books', + 'books_empty' => 'No books have been created', + 'books_popular' => 'Popular Books', + 'books_recent' => 'Recent Books', + 'books_new' => 'New Books', + 'books_new_action' => 'New Book', + 'books_popular_empty' => 'The most popular books will appear here.', + 'books_new_empty' => 'The most recently created books will appear here.', + 'books_create' => 'Create New Book', + 'books_delete' => 'Delete Book', + 'books_delete_named' => 'Delete Book :bookName', + 'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.', + 'books_delete_confirmation' => 'Are you sure you want to delete this book?', + 'books_edit' => 'Edit Book', + 'books_edit_named' => 'Edit Book :bookName', + 'books_form_book_name' => 'Book Name', + 'books_save' => 'Save Book', + 'books_permissions' => 'Book Permissions', + 'books_permissions_updated' => 'Book Permissions Updated', + 'books_empty_contents' => 'No pages or chapters have been created for this book.', + 'books_empty_create_page' => 'Create a new page', + 'books_empty_sort_current_book' => 'Sort the current book', + 'books_empty_add_chapter' => 'Add a chapter', + 'books_permissions_active' => 'Book Permissions Active', + 'books_search_this' => 'Search this book', + 'books_navigation' => 'Book Navigation', + 'books_sort' => 'Sort Book Contents', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_named' => 'Sort Book :bookName', + 'books_sort_name' => 'Sort by Name', + 'books_sort_created' => 'Sort by Created Date', + 'books_sort_updated' => 'Sort by Updated Date', + 'books_sort_chapters_first' => 'Chapters First', + 'books_sort_chapters_last' => 'Chapters Last', + 'books_sort_show_other' => 'Show Other Books', + 'books_sort_save' => 'Save New Order', + 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', + 'books_sort_move_up' => 'Move Up', + 'books_sort_move_down' => 'Move Down', + 'books_sort_move_prev_book' => 'Move to Previous Book', + 'books_sort_move_next_book' => 'Move to Next Book', + 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', + 'books_sort_move_next_chapter' => 'Move Into Next Chapter', + 'books_sort_move_book_start' => 'Move to Start of Book', + 'books_sort_move_book_end' => 'Move to End of Book', + 'books_sort_move_before_chapter' => 'Move to Before Chapter', + 'books_sort_move_after_chapter' => 'Move to After Chapter', + 'books_copy' => 'Copy Book', + 'books_copy_success' => 'Book successfully copied', + + // Chapters + 'chapter' => 'Chapter', + 'chapters' => 'Chapters', + 'x_chapters' => ':count Chapter|:count Chapters', + 'chapters_popular' => 'Popular Chapters', + 'chapters_new' => 'New Chapter', + 'chapters_create' => 'Create New Chapter', + 'chapters_delete' => 'Delete Chapter', + 'chapters_delete_named' => 'Delete Chapter :chapterName', + 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.', + 'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?', + 'chapters_edit' => 'Edit Chapter', + 'chapters_edit_named' => 'Edit Chapter :chapterName', + 'chapters_save' => 'Save Chapter', + 'chapters_move' => 'Move Chapter', + 'chapters_move_named' => 'Move Chapter :chapterName', + 'chapters_copy' => 'Copy Chapter', + 'chapters_copy_success' => 'Chapter successfully copied', + 'chapters_permissions' => 'Chapter Permissions', + 'chapters_empty' => 'No pages are currently in this chapter.', + 'chapters_permissions_active' => 'Chapter Permissions Active', + 'chapters_permissions_success' => 'Chapter Permissions Updated', + 'chapters_search_this' => 'Search this chapter', + 'chapter_sort_book' => 'Sort Book', + + // Pages + 'page' => 'Page', + 'pages' => 'Pages', + 'x_pages' => ':count Page|:count Pages', + 'pages_popular' => 'Popular Pages', + 'pages_new' => 'New Page', + 'pages_attachments' => 'Attachments', + 'pages_navigation' => 'Page Navigation', + 'pages_delete' => 'Delete Page', + 'pages_delete_named' => 'Delete Page :pageName', + 'pages_delete_draft_named' => 'Delete Draft Page :pageName', + 'pages_delete_draft' => 'Delete Draft Page', + 'pages_delete_success' => 'Page deleted', + 'pages_delete_draft_success' => 'Draft page deleted', + 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_confirm' => 'Are you sure you want to delete this page?', + 'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?', + 'pages_editing_named' => 'Editing Page :pageName', + 'pages_edit_draft_options' => 'Draft Options', + 'pages_edit_save_draft' => 'Save Draft', + 'pages_edit_draft' => 'Edit Page Draft', + 'pages_editing_draft' => 'Editing Draft', + 'pages_editing_page' => 'Editing Page', + 'pages_edit_draft_save_at' => 'Draft saved at ', + 'pages_edit_delete_draft' => 'Delete Draft', + 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.', + 'pages_edit_discard_draft' => 'Discard Draft', + 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', + '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', + 'pages_editor_switch_title' => 'Switch Editor', + 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', + 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', + 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', + 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', + 'pages_save' => 'Save Page', + 'pages_title' => 'Page Title', + 'pages_name' => 'Page Name', + 'pages_md_editor' => 'Editor', + 'pages_md_preview' => 'Preview', + 'pages_md_insert_image' => 'Insert Image', + 'pages_md_insert_link' => 'Insert Entity Link', + 'pages_md_insert_drawing' => 'Insert Drawing', + 'pages_md_show_preview' => 'Show preview', + 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_drawing_unsaved' => 'Unsaved Drawing Found', + 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', + 'pages_not_in_chapter' => 'Page is not in a chapter', + 'pages_move' => 'Move Page', + 'pages_copy' => 'Copy Page', + 'pages_copy_desination' => 'Copy Destination', + 'pages_copy_success' => 'Page successfully copied', + 'pages_permissions' => 'Page Permissions', + 'pages_permissions_success' => 'Page permissions updated', + 'pages_revision' => 'Revision', + 'pages_revisions' => 'Page Revisions', + 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', + 'pages_revisions_named' => 'Page Revisions for :pageName', + 'pages_revision_named' => 'Page Revision for :pageName', + 'pages_revision_restored_from' => 'Restored from #:id; :summary', + 'pages_revisions_created_by' => 'Created By', + 'pages_revisions_date' => 'Revision Date', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'Revision Number', + 'pages_revisions_numbered' => 'Revision #:id', + 'pages_revisions_numbered_changes' => 'Revision #:id Changes', + 'pages_revisions_editor' => 'Editor Type', + 'pages_revisions_changelog' => 'Changelog', + 'pages_revisions_changes' => 'Changes', + 'pages_revisions_current' => 'Current Version', + 'pages_revisions_preview' => 'Preview', + 'pages_revisions_restore' => 'Restore', + 'pages_revisions_none' => 'This page has no revisions', + 'pages_copy_link' => 'Copy Link', + 'pages_edit_content_link' => 'Jump to section in editor', + 'pages_pointer_enter_mode' => 'Enter section select mode', + 'pages_pointer_label' => 'Page Section Options', + 'pages_pointer_permalink' => 'Page Section Permalink', + 'pages_pointer_include_tag' => 'Page Section Include Tag', + 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', + 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_permissions_active' => 'Page Permissions Active', + 'pages_initial_revision' => 'Initial publish', + 'pages_references_update_revision' => 'System auto-update of internal links', + 'pages_initial_name' => 'New Page', + 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', + 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', + 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count users have started editing this page', + 'start_b' => ':userName has started editing this page', + 'time_a' => 'since the page was last updated', + 'time_b' => 'in the last :minCount minutes', + 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + ], + 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', + 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', + 'pages_specific' => 'Specific Page', + 'pages_is_template' => 'Page Template', + + // Editor Sidebar + 'toggle_sidebar' => 'Toggle Sidebar', + 'page_tags' => 'Page Tags', + 'chapter_tags' => 'Chapter Tags', + 'book_tags' => 'Book Tags', + 'shelf_tags' => 'Shelf Tags', + 'tag' => 'Tag', + 'tags' => 'Tags', + 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', + 'tag_name' => 'Tag Name', + 'tag_value' => 'Tag Value (Optional)', + 'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.", + 'tags_add' => 'Add another tag', + 'tags_remove' => 'Remove this tag', + 'tags_usages' => 'Total tag usages', + 'tags_assigned_pages' => 'Assigned to Pages', + 'tags_assigned_chapters' => 'Assigned to Chapters', + 'tags_assigned_books' => 'Assigned to Books', + 'tags_assigned_shelves' => 'Assigned to Shelves', + 'tags_x_unique_values' => ':count unique values', + 'tags_all_values' => 'All values', + 'tags_view_tags' => 'View Tags', + 'tags_view_existing_tags' => 'View existing tags', + 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', + 'attachments' => 'Attachments', + 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', + 'attachments_explain_instant_save' => 'Changes here are saved instantly.', + 'attachments_upload' => 'Upload File', + 'attachments_link' => 'Attach Link', + 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', + 'attachments_set_link' => 'Set Link', + 'attachments_delete' => 'Are you sure you want to delete this attachment?', + 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_no_files' => 'No files have been uploaded', + 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', + 'attachments_link_name' => 'Link Name', + 'attachment_link' => 'Attachment link', + 'attachments_link_url' => 'Link to file', + 'attachments_link_url_hint' => 'Url of site or file', + 'attach' => 'Attach', + 'attachments_insert_link' => 'Add Attachment Link to Page', + 'attachments_edit_file' => 'Edit File', + 'attachments_edit_file_name' => 'File Name', + 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', + 'attachments_order_updated' => 'Attachment order updated', + 'attachments_updated_success' => 'Attachment details updated', + 'attachments_deleted' => 'Attachment deleted', + 'attachments_file_uploaded' => 'File successfully uploaded', + 'attachments_file_updated' => 'File successfully updated', + 'attachments_link_attached' => 'Link successfully attached to page', + 'templates' => 'Templates', + 'templates_set_as_template' => 'Page is a template', + 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', + 'templates_replace_content' => 'Replace page content', + 'templates_append_content' => 'Append to page content', + 'templates_prepend_content' => 'Prepend to page content', + + // Profile View + 'profile_user_for_x' => 'User for :time', + 'profile_created_content' => 'Created Content', + 'profile_not_created_pages' => ':userName has not created any pages', + 'profile_not_created_chapters' => ':userName has not created any chapters', + 'profile_not_created_books' => ':userName has not created any books', + 'profile_not_created_shelves' => ':userName has not created any shelves', + + // Comments + 'comment' => 'Comment', + 'comments' => 'Comments', + 'comment_add' => 'Add Comment', + 'comment_placeholder' => 'Leave a comment here', + 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', + 'comment_save' => 'Save Comment', + 'comment_new' => 'New Comment', + 'comment_created' => 'commented :createDiff', + 'comment_updated' => 'Updated :updateDiff by :username', + 'comment_updated_indicator' => 'Updated', + 'comment_deleted_success' => 'Comment deleted', + 'comment_created_success' => 'Comment added', + 'comment_updated_success' => 'Comment updated', + 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', + 'comment_in_reply_to' => 'In reply to :commentId', + 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', + + // Revision + 'revision_delete_confirm' => 'Are you sure you want to delete this revision?', + 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', + 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + + // Conversions + 'convert_to_shelf' => 'Convert to Shelf', + 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', + 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', + 'convert_book' => 'Convert Book', + 'convert_book_confirm' => 'Are you sure you want to convert this book?', + 'convert_undo_warning' => 'This cannot be as easily undone.', + 'convert_to_book' => 'Convert to Book', + 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', + 'convert_chapter' => 'Convert Chapter', + 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + + // References + 'references' => 'References', + 'references_none' => 'There are no tracked references to this item.', + 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + + // Watch Options + 'watch' => 'Watch', + 'watch_title_default' => 'Default Preferences', + 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_title_ignore' => 'Ignore', + 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_title_new' => 'New Pages', + 'watch_desc_new' => 'Notify when any new page is created within this item.', + 'watch_title_updates' => 'All Page Updates', + 'watch_desc_updates' => 'Notify upon all new pages and page changes.', + 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', + 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', + 'watch_change_default' => 'Change default notification preferences', + 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_detail_new' => 'Watching for new pages', + 'watch_detail_updates' => 'Watching new pages and updates', + 'watch_detail_comments' => 'Watching new pages, updates & comments', + 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', + 'watch_detail_parent_chapter' => 'Watching via parent chapter', + 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', +]; diff --git a/lang/bn/errors.php b/lang/bn/errors.php new file mode 100644 index 00000000000..ee2fbfa2100 --- /dev/null +++ b/lang/bn/errors.php @@ -0,0 +1,133 @@ + 'অনুরোধকৃত পৃষ্ঠাটিতে আপনার ব্যবহারাধিকারের অনুমতি নেই।', + 'permissionJson' => 'You do not have permission to perform the requested action.', + + // Auth + 'error_user_exists_different_creds' => 'A user with the email :email already exists but with different credentials.', + 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', + 'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.', + 'email_confirmation_expired' => 'The confirmation token has expired, A new confirmation email has been sent.', + 'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed', + 'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind', + 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', + 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', + 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', + 'saml_already_logged_in' => 'Already logged in', + 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', + 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'social_no_action_defined' => 'No action defined', + 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", + 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', + 'social_account_email_in_use' => 'The email :email is already in use. If you already have an account you can connect your :socialAccount account from your profile settings.', + 'social_account_existing' => 'This :socialAccount is already attached to your profile.', + 'social_account_already_used_existing' => 'This :socialAccount account is already used by another user.', + 'social_account_not_used' => 'This :socialAccount account is not linked to any users. Please attach it in your profile settings. ', + 'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.', + 'social_driver_not_found' => 'Social driver not found', + 'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.', + 'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.', + 'login_user_not_found' => 'A user for this action could not be found.', + + // System + 'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.', + 'cannot_get_image_from_url' => 'Cannot get image from :url', + 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', + 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', + 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', + + // Drawing & Images + 'image_upload_error' => 'An error occurred uploading the image', + 'image_upload_type_error' => 'The image type being uploaded is invalid', + 'image_upload_replace_type' => 'Image file replacements must be of the same type', + 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', + 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', + 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', + 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', + + // Attachments + 'attachment_not_found' => 'Attachment not found', + 'attachment_upload_error' => 'An error occurred uploading the attachment file', + + // Pages + 'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', + 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', + 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage', + + // Entities + 'entity_not_found' => 'Entity not found', + 'bookshelf_not_found' => 'Shelf not found', + 'book_not_found' => 'Book not found', + 'page_not_found' => 'Page not found', + 'chapter_not_found' => 'Chapter not found', + 'selected_book_not_found' => 'The selected book was not found', + 'selected_book_chapter_not_found' => 'The selected Book or Chapter was not found', + 'guests_cannot_save_drafts' => 'Guests cannot save drafts', + + // Users + 'users_cannot_delete_only_admin' => 'You cannot delete the only admin', + 'users_cannot_delete_guest' => 'You cannot delete the guest user', + 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + + // Roles + 'role_cannot_be_edited' => 'This role cannot be edited', + 'role_system_cannot_be_deleted' => 'This role is a system role and cannot be deleted', + 'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role', + 'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.', + + // Comments + 'comment_list' => 'An error occurred while fetching the comments.', + 'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.', + 'comment_add' => 'An error occurred while adding / updating the comment.', + 'comment_delete' => 'An error occurred while deleting the comment.', + 'empty_comment' => 'Cannot add an empty comment.', + + // Error pages + '404_page_not_found' => 'Page Not Found', + 'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.', + 'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.', + 'image_not_found' => 'Image Not Found', + 'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.', + 'image_not_found_details' => 'If you expected this image to exist it might have been deleted.', + 'return_home' => 'Return to home', + 'error_occurred' => 'An Error Occurred', + 'app_down' => ':appName is down right now', + 'back_soon' => 'It will be back up soon.', + + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + + // API errors + 'api_no_authorization_found' => 'No authorization token found on the request', + 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', + 'api_user_token_not_found' => 'No matching API token was found for the provided authorization token', + 'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect', + 'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls', + 'api_user_token_expired' => 'The authorization token used has expired', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Error thrown when sending a test email:', + + // HTTP errors + 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', +]; diff --git a/lang/bn/notifications.php b/lang/bn/notifications.php new file mode 100644 index 00000000000..1afd23f1dc4 --- /dev/null +++ b/lang/bn/notifications.php @@ -0,0 +1,27 @@ + 'New comment on page: :pageName', + 'new_comment_intro' => 'A user has commented on a page in :appName:', + 'new_page_subject' => 'New page: :pageName', + 'new_page_intro' => 'A new page has been created in :appName:', + 'updated_page_subject' => 'Updated page: :pageName', + 'updated_page_intro' => 'A page has been updated in :appName:', + 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + + 'detail_page_name' => 'Page Name:', + 'detail_page_path' => 'Page Path:', + 'detail_commenter' => 'Commenter:', + 'detail_comment' => 'Comment:', + 'detail_created_by' => 'Created By:', + 'detail_updated_by' => 'Updated By:', + + 'action_view_comment' => 'View Comment', + 'action_view_page' => 'View Page', + + 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', + 'footer_reason_link' => 'your notification preferences', +]; diff --git a/lang/bn/pagination.php b/lang/bn/pagination.php new file mode 100644 index 00000000000..2cf360a0fb6 --- /dev/null +++ b/lang/bn/pagination.php @@ -0,0 +1,12 @@ + '« পূর্ববর্তী', + 'next' => 'পরবর্তী »', + +]; diff --git a/lang/bn/passwords.php b/lang/bn/passwords.php new file mode 100644 index 00000000000..da318b1acc0 --- /dev/null +++ b/lang/bn/passwords.php @@ -0,0 +1,15 @@ + 'পাসওয়ার্ড কমপক্ষে আট অক্ষরের হতে হবে এবং পাসওয়ার্ড নিশ্চিতকরণের ঘরে প্রদত্ত পাসওয়ার্ডের সাথে মিলতে হবে।', + 'user' => "প্রদত্ত ইমেইল ঠিকানার স্বাপেক্ষে কোন ব্যবহারকারী খুঁজে পাওয়া যায়নি।", + 'token' => 'প্রদত্ত পাসওয়ার্ড রিসেট টোকেন অত্র ইমেল ঠিকানার জন্য বৈধ নয়৷', + 'sent' => 'আপনার পাসওয়ার্ড রিসেট লিঙ্কটি ই-মেইলের মাধ্যমে প্রেরণ করা হয়েছে!', + 'reset' => 'আপনার পাসওয়ার্ডটি রিসেট করা হয়েছে!', + +]; diff --git a/lang/bn/preferences.php b/lang/bn/preferences.php new file mode 100644 index 00000000000..2e47604e471 --- /dev/null +++ b/lang/bn/preferences.php @@ -0,0 +1,51 @@ + 'আমার অ্যাকাউন্ট', + + 'shortcuts' => 'Shortcuts', + 'shortcuts_interface' => 'UI Shortcut Preferences', + 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', + 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', + 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', + 'shortcuts_section_navigation' => 'Navigation', + 'shortcuts_section_actions' => 'Common Actions', + 'shortcuts_save' => 'Save Shortcuts', + 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', + 'shortcuts_update_success' => 'Shortcut preferences have been updated!', + 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + + 'notifications' => 'Notification Preferences', + 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', + 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', + 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', + 'notifications_save' => 'Save Preferences', + 'notifications_update_success' => 'Notification preferences have been updated!', + 'notifications_watched' => 'Watched & Ignored Items', + 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + + 'auth' => 'Access & Security', + 'auth_change_password' => 'Change Password', + 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', + 'auth_change_password_success' => 'Password has been updated!', + + 'profile' => 'Profile Details', + 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', + 'profile_view_public' => 'View Public Profile', + 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', + 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', + 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', + 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', + 'profile_admin_options' => 'Administrator Options', + 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + + 'delete_account' => 'Delete Account', + 'delete_my_account' => 'Delete My Account', + 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', + 'delete_my_account_warning' => 'Are you sure you want to delete your account?', +]; diff --git a/lang/bn/settings.php b/lang/bn/settings.php new file mode 100644 index 00000000000..e66e32284d6 --- /dev/null +++ b/lang/bn/settings.php @@ -0,0 +1,339 @@ + 'সেটিংস', + 'settings_save' => 'সেটিংস সংরক্ষণ করুন', + 'system_version' => 'সিস্টেম ভার্সন', + 'categories' => 'শ্রেণীবিভাগ সমূহ', + + // App Settings + 'app_customization' => 'নিজস্বীকরণ', + 'app_features_security' => 'ফিচারসমূহ ও নিরাপত্তা', + 'app_name' => 'এপ্লিকেশনের নাম', + 'app_name_desc' => 'এই নামটি হেডারে এবং যেকোন সিস্টেম-প্রেরিত ইমেলে দেখানো হয়।', + 'app_name_header' => 'হেডারে নাম দেখান', + 'app_public_access' => 'পাবলিক এক্সেস', + 'app_public_access_desc' => 'উক্ত অপশনটি সক্রিয় করলে আপনার বুকস্ট্যাক ওয়েবসাইটের সকল তথ্য, যে কেউ লগ ইন করা ছাড়াই, দেখতে বা পড়তে অথবা এক্সেস করতে পারবেন।', + 'app_public_access_desc_guest' => '"Guest" ব্যবহারকারীর মাধ্যমে ওয়েবসাইট ভিসিটরদের পঠনধিকার নিয়ন্ত্রণ করা যেতে পারে।', + 'app_public_access_toggle' => 'পাবলিক অ্যাক্সেসের অনুমতি দিন', + 'app_public_viewing' => 'সকলের জন্যে উন্মুক্ত করতে চান?', + 'app_secure_images' => 'Higher Security Image Uploads', + 'app_secure_images_toggle' => 'Enable higher security image uploads', + 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', + 'app_default_editor' => 'Default Page Editor', + 'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.', + 'app_custom_html' => 'Custom HTML Head Content', + 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', + 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', + 'app_logo' => 'অ্যাপ্লিকেশনের লোগো', + 'app_logo_desc' => 'এটি অ্যাপ্লিকেশনের হেডার বার-এ দেখানো হবে। উক্ত ছবিটির উচ্চতা সর্বোচ্চ ৮৬ পিক্সেলের হতে হবে। অধিকতর উচ্চতার ছবিকে স্কেল ডাউন করা হবে। ', + 'app_icon' => 'অ্যাপ্লিকেশনের আইকন', + 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', + 'app_homepage' => 'অ্যাপ্লিকেশনের নীড়পাতা', + 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage_select' => 'একটি পৃষ্ঠা নির্বাচন করুন', + 'app_footer_links' => 'ফুটার লিঙ্কসমূহ', + 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', + 'app_footer_links_label' => 'Link Label', + 'app_footer_links_url' => 'Link URL', + 'app_footer_links_add' => 'Add Footer Link', + 'app_disable_comments' => 'Disable Comments', + 'app_disable_comments_toggle' => 'Disable comments', + 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
                                Existing comments are not shown.', + + // Color settings + 'color_scheme' => 'Application Color Scheme', + 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', + 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', + 'app_color' => 'Primary Color', + 'link_color' => 'Default Link Color', + 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', + 'bookshelf_color' => 'Shelf Color', + 'book_color' => 'Book Color', + 'chapter_color' => 'Chapter Color', + 'page_color' => 'Page Color', + 'page_draft_color' => 'Page Draft Color', + + // Registration Settings + 'reg_settings' => 'Registration', + 'reg_enable' => 'Enable Registration', + 'reg_enable_toggle' => 'Enable registration', + 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', + 'reg_default_role' => 'Default user role after registration', + 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', + 'reg_email_confirmation' => 'Email Confirmation', + 'reg_email_confirmation_toggle' => 'Require email confirmation', + 'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.', + 'reg_confirm_restrict_domain' => 'Domain Restriction', + 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
                                Note that users will be able to change their email addresses after successful registration.', + 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + + // Maintenance settings + 'maint' => 'Maintenance', + 'maint_image_cleanup' => 'Cleanup Images', + 'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.', + 'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions', + 'maint_image_cleanup_run' => 'Run Cleanup', + 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', + 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', + 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', + 'maint_send_test_email' => 'Send a Test Email', + 'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.', + 'maint_send_test_email_run' => 'Send test email', + 'maint_send_test_email_success' => 'Email sent to :address', + 'maint_send_test_email_mail_subject' => 'Test Email', + 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', + 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', + 'maint_recycle_bin_open' => 'Open Recycle Bin', + 'maint_regen_references' => 'Regenerate References', + 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', + 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + + // Recycle Bin + 'recycle_bin' => 'Recycle Bin', + 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'recycle_bin_deleted_item' => 'Deleted Item', + 'recycle_bin_deleted_parent' => 'Parent', + 'recycle_bin_deleted_by' => 'Deleted By', + 'recycle_bin_deleted_at' => 'Deletion Time', + 'recycle_bin_permanently_delete' => 'Permanently Delete', + 'recycle_bin_restore' => 'Restore', + 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', + 'recycle_bin_empty' => 'Empty Recycle Bin', + 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', + 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_list' => 'Items to be Destroyed', + 'recycle_bin_restore_list' => 'Items to be Restored', + 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', + 'recycle_bin_restore_parent' => 'Restore Parent', + 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + + // Audit Log + 'audit' => 'Audit Log', + 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_event_filter' => 'Event Filter', + 'audit_event_filter_no_filter' => 'No Filter', + 'audit_deleted_item' => 'Deleted Item', + 'audit_deleted_item_name' => 'Name: :name', + 'audit_table_user' => 'User', + 'audit_table_event' => 'Event', + 'audit_table_related' => 'Related Item or Detail', + 'audit_table_ip' => 'IP Address', + 'audit_table_date' => 'Activity Date', + 'audit_date_from' => 'Date Range From', + 'audit_date_to' => 'Date Range To', + + // Role Settings + 'roles' => 'Roles', + 'role_user_roles' => 'User Roles', + 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', + 'roles_x_users_assigned' => ':count user assigned|:count users assigned', + 'roles_x_permissions_provided' => ':count permission|:count permissions', + 'roles_assigned_users' => 'Assigned Users', + 'roles_permissions_provided' => 'Provided Permissions', + 'role_create' => 'Create New Role', + 'role_delete' => 'Delete Role', + 'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.', + 'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', + 'role_delete_no_migration' => "Don't migrate users", + 'role_delete_sure' => 'Are you sure you want to delete this role?', + 'role_edit' => 'Edit Role', + 'role_details' => 'Role Details', + 'role_name' => 'Role Name', + 'role_desc' => 'Short Description of Role', + 'role_mfa_enforced' => 'Requires Multi-Factor Authentication', + 'role_external_auth_id' => 'External Authentication IDs', + 'role_system' => 'System Permissions', + 'role_manage_users' => 'Manage users', + 'role_manage_roles' => 'Manage roles & role permissions', + 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', + 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', + 'role_manage_page_templates' => 'Manage page templates', + 'role_access_api' => 'Access system API', + 'role_manage_settings' => 'Manage app settings', + 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', + 'role_editor_change' => 'Change page editor', + 'role_notifications' => 'Receive & manage notifications', + 'role_asset' => 'Asset Permissions', + 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', + 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', + 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', + 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_all' => 'All', + 'role_own' => 'Own', + 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_save' => 'Save Role', + 'role_users' => 'Users in this role', + 'role_users_none' => 'No users are currently assigned to this role', + + // Users + 'users' => 'Users', + 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', + 'user_profile' => 'User Profile', + 'users_add_new' => 'Add New User', + 'users_search' => 'Search Users', + 'users_latest_activity' => 'Latest Activity', + 'users_details' => 'User Details', + 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', + 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', + 'users_role' => 'User Roles', + 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', + 'users_password' => 'User Password', + 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.', + 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', + 'users_send_invite_option' => 'Send user invite email', + 'users_external_auth_id' => 'External Authentication ID', + 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', + 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', + 'users_delete' => 'Delete User', + 'users_delete_named' => 'Delete user :userName', + 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', + 'users_delete_confirm' => 'Are you sure you want to delete this user?', + 'users_migrate_ownership' => 'Migrate Ownership', + 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', + 'users_none_selected' => 'No user selected', + 'users_edit' => 'Edit User', + 'users_edit_profile' => 'Edit Profile', + 'users_avatar' => 'User Avatar', + 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', + 'users_preferred_language' => 'Preferred Language', + 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', + 'users_social_accounts' => 'Social Accounts', + 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Connect Account', + 'users_social_disconnect' => 'Disconnect Account', + 'users_social_status_connected' => 'Connected', + 'users_social_status_disconnected' => 'Disconnected', + 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', + 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', + 'users_api_tokens' => 'API Tokens', + 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_none' => 'No API tokens have been created for this user', + 'users_api_tokens_create' => 'Create Token', + 'users_api_tokens_expires' => 'Expires', + 'users_api_tokens_docs' => 'API Documentation', + 'users_mfa' => 'Multi-Factor Authentication', + 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_configure' => 'Configure Methods', + + // API Tokens + 'user_api_token_create' => 'Create API Token', + 'user_api_token_name' => 'Name', + 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', + 'user_api_token_expiry' => 'Expiry Date', + 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', + 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', + 'user_api_token' => 'API Token', + 'user_api_token_id' => 'Token ID', + 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', + 'user_api_token_secret' => 'Token Secret', + 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', + 'user_api_token_created' => 'Token created :timeAgo', + 'user_api_token_updated' => 'Token updated :timeAgo', + 'user_api_token_delete' => 'Delete Token', + 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', + 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', + 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_create' => 'Create New Webhook', + 'webhooks_none_created' => 'No webhooks have yet been created.', + 'webhooks_edit' => 'Edit Webhook', + 'webhooks_save' => 'Save Webhook', + 'webhooks_details' => 'Webhook Details', + 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', + 'webhooks_events' => 'Webhook Events', + 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', + 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_events_all' => 'All system events', + 'webhooks_name' => 'Webhook Name', + 'webhooks_timeout' => 'Webhook Request Timeout (Seconds)', + 'webhooks_endpoint' => 'Webhook Endpoint', + 'webhooks_active' => 'Webhook Active', + 'webhook_events_table_header' => 'Events', + 'webhooks_delete' => 'Delete Webhook', + 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', + 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', + 'webhooks_format_example' => 'Webhook Format Example', + 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + 'webhooks_status' => 'Webhook Status', + 'webhooks_last_called' => 'Last Called:', + 'webhooks_last_errored' => 'Last Errored:', + 'webhooks_last_error_message' => 'Last Error Message:', + + // Licensing + 'licenses' => 'Licenses', + 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', + 'licenses_bookstack' => 'BookStack License', + 'licenses_php' => 'PHP Library Licenses', + 'licenses_js' => 'JavaScript Library Licenses', + 'licenses_other' => 'Other Licenses', + 'license_details' => 'License Details', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'cy' => 'Cymraeg', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'el' => 'ελληνικά', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'et' => 'Eesti keel', + 'eu' => 'Euskara', + 'fa' => 'فارسی', + 'fi' => 'Suomi', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nb' => 'Norsk (Bokmål)', + 'nn' => 'Nynorsk', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ro' => 'Română', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'uz' => 'O‘zbekcha', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ], + //!//////////////////////////////// +]; diff --git a/lang/bn/validation.php b/lang/bn/validation.php new file mode 100644 index 00000000000..d9b982d1e23 --- /dev/null +++ b/lang/bn/validation.php @@ -0,0 +1,122 @@ + 'The :attribute must be accepted.', + 'active_url' => 'The :attribute is not a valid URL.', + 'after' => 'The :attribute must be a date after :date.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'array' => 'The :attribute must be an array.', + 'backup_codes' => 'The provided code is not valid or has already been used.', + 'before' => 'The :attribute must be a date before :date.', + 'between' => [ + 'numeric' => 'The :attribute must be between :min and :max.', + 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'string' => 'The :attribute must be between :min and :max characters.', + 'array' => 'The :attribute must have between :min and :max items.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'email' => 'The :attribute must be a valid email address.', + 'ends_with' => 'The :attribute must end with one of the following: :values', + 'file' => 'The :attribute must be provided as a valid file.', + 'filled' => 'The :attribute field is required.', + 'gt' => [ + 'numeric' => 'The :attribute must be greater than :value.', + 'file' => 'The :attribute must be greater than :value kilobytes.', + 'string' => 'The :attribute must be greater than :value characters.', + 'array' => 'The :attribute must have more than :value items.', + ], + 'gte' => [ + 'numeric' => 'The :attribute must be greater than or equal :value.', + 'file' => 'The :attribute must be greater than or equal :value kilobytes.', + 'string' => 'The :attribute must be greater than or equal :value characters.', + 'array' => 'The :attribute must have :value items or more.', + ], + 'exists' => 'The selected :attribute is invalid.', + 'image' => 'The :attribute must be an image.', + 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'in' => 'The selected :attribute is invalid.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'ipv4' => 'The :attribute must be a valid IPv4 address.', + 'ipv6' => 'The :attribute must be a valid IPv6 address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'lt' => [ + 'numeric' => 'The :attribute must be less than :value.', + 'file' => 'The :attribute must be less than :value kilobytes.', + 'string' => 'The :attribute must be less than :value characters.', + 'array' => 'The :attribute must have less than :value items.', + ], + 'lte' => [ + 'numeric' => 'The :attribute must be less than or equal :value.', + 'file' => 'The :attribute must be less than or equal :value kilobytes.', + 'string' => 'The :attribute must be less than or equal :value characters.', + 'array' => 'The :attribute must not have more than :value items.', + ], + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute format is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values is present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'safe_url' => 'The provided link may not be safe.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'totp' => 'The provided code is not valid or has expired.', + 'unique' => 'The :attribute has already been taken.', + 'url' => 'The :attribute format is invalid.', + 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Password confirmation required', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/lang/bs/activities.php b/lang/bs/activities.php index ee538c23a47..fc63058ae12 100644 --- a/lang/bs/activities.php +++ b/lang/bs/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/bs/editor.php b/lang/bs/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/bs/editor.php +++ b/lang/bs/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/bs/entities.php b/lang/bs/entities.php index 1b59ecac875..fc6e19ddd8a 100644 --- a/lang/bs/entities.php +++ b/lang/bs/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF fajl', 'export_text' => 'Plain Text fajl', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Dozvole', diff --git a/lang/bs/errors.php b/lang/bs/errors.php index be633b5894b..f60f92f0782 100644 --- a/lang/bs/errors.php +++ b/lang/bs/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName trenutno nije u funkciji', 'back_soon' => 'Biti će uskoro u funkciji.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Na zahtjevu nije pronađen token za autorizaciju', 'api_bad_authorization_format' => 'Token za autorizaciju je pronađen u zahtjevu ali je format neispravan', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/lang/bs/validation.php b/lang/bs/validation.php index b07df9178fc..4b026afd2dc 100644 --- a/lang/bs/validation.php +++ b/lang/bs/validation.php @@ -105,6 +105,11 @@ 'url' => 'Format :attribute je neispravan.', 'uploaded' => 'Fajl nije učitan. Server ne prihvata fajlove ove veličine.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ca/activities.php b/lang/ca/activities.php index c288384826c..8835676644b 100644 --- a/lang/ca/activities.php +++ b/lang/ca/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'ha suprimit el webhook', 'webhook_delete_notification' => 'S’ha suprimit el webhook', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'ha creat l’usuari', 'user_create_notification' => 'S’ha creat l’usuari', diff --git a/lang/ca/editor.php b/lang/ca/editor.php index 7a09986a2f2..cdd80c245d2 100644 --- a/lang/ca/editor.php +++ b/lang/ca/editor.php @@ -163,6 +163,8 @@ 'about' => 'Quant a l’Editor', 'about_title' => 'Quant a l’Editor WYSIWYG', 'editor_license' => 'Llicència i copyright de l’Editor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Aquest editor s’ha creat amb :tinyLink que es proporciona amb la llicència MIT.', 'editor_tiny_license_link' => 'Detalls de la llicència i el copyright de TinyMCE.', 'save_continue' => 'Desa la pàgina i continua', diff --git a/lang/ca/entities.php b/lang/ca/entities.php index 0f241d9f24d..ef2a4c2a8c4 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Fitxer PDF', 'export_text' => 'Fitxer de text sense format', 'export_md' => 'Fitxer Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/ca/errors.php b/lang/ca/errors.php index 03a07bb335b..76fb293dab6 100644 --- a/lang/ca/errors.php +++ b/lang/ca/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName està fora de servei.', 'back_soon' => 'Aviat ho arreglarem.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No s’ha trobat cap testimoni d’autorització en aquesta sol·licitud.', 'api_bad_authorization_format' => 'S’ha trobat un testimoni d’autorització en aquesta sol·licitud però no tenia el format correcte.', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index 46f92571e95..2390a7962b8 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Accés a l’API del sistema', 'role_manage_settings' => 'Gestió de la configuració de l’aplicació', 'role_export_content' => 'Exportació de contingut', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Canvi de l’editor de pàgina', 'role_notifications' => 'Recepció i gestió de notificacions', 'role_asset' => 'Permisos de recursos', diff --git a/lang/ca/validation.php b/lang/ca/validation.php index dc9b02d5024..e33ae336f20 100644 --- a/lang/ca/validation.php +++ b/lang/ca/validation.php @@ -105,6 +105,11 @@ 'url' => 'El format :attribute no és vàlid.', 'uploaded' => 'No s’ha pogut pujar el fitxer. És possible que el servidor no admeti fitxers d’aquesta mida.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/cs/activities.php b/lang/cs/activities.php index 412bf9d3be3..3b4a05213ba 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'odstranil/a webhook', 'webhook_delete_notification' => 'Webhook byl úspěšně odstraněn', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'vytvořil uživatele', 'user_create_notification' => 'Uživatel byl úspěšně vytvořen', diff --git a/lang/cs/editor.php b/lang/cs/editor.php index f4c442af8b7..8e7001c081c 100644 --- a/lang/cs/editor.php +++ b/lang/cs/editor.php @@ -163,6 +163,8 @@ 'about' => 'O editoru', 'about_title' => 'O WYSIWYG editoru', 'editor_license' => 'Editor licence a autorská práva', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Tento editor je vytvořen pomocí :tinyLink, který je poskytován pod licencí MIT.', 'editor_tiny_license_link' => 'Podrobnosti o autorských právech a licenci TinyMCE naleznete zde.', 'save_continue' => 'Uložit stránku a pokračovat', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index 77fb8a3fb95..a87ba49d044 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF dokument', 'export_text' => 'Textový soubor', 'export_md' => 'Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Výchozí šablona stránky', 'default_template_explain' => 'Přiřadit šablonu stránky, která bude použita jako výchozí obsah pro všechny nové stránky v této knize. Mějte na paměti, že šablona bude použita pouze v případě, že tvůrce stránek bude mít přístup k těmto vybraným stránkám šablony.', 'default_template_select' => 'Vyberte šablonu stránky', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Oprávnění', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index e23f1032b7d..c740ee193e7 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName je momentálně vypnutá', 'back_soon' => 'Brzy bude opět v provozu.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'V požadavku nebyl nalezen žádný autorizační token', 'api_bad_authorization_format' => 'V požadavku byl nalezen autorizační token, ale jeho formát se zdá být chybný', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index 01ea3a1db5a..67d7d825a5e 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Přístup k systémovému API', 'role_manage_settings' => 'Správa nastavení aplikace', 'role_export_content' => 'Exportovat obsah', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Změnit editor stránek', 'role_notifications' => 'Přijímat a spravovat oznámení', 'role_asset' => 'Obsahová oprávnění', diff --git a/lang/cs/validation.php b/lang/cs/validation.php index 83929cc887b..f2ee59e5be0 100644 --- a/lang/cs/validation.php +++ b/lang/cs/validation.php @@ -105,6 +105,11 @@ 'url' => 'Formát :attribute je neplatný.', 'uploaded' => 'Nahrávání :attribute se nezdařilo.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/cy/activities.php b/lang/cy/activities.php index b7f1cadf323..e5dcddc56e1 100644 --- a/lang/cy/activities.php +++ b/lang/cy/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhook wedi\'i dileu', 'webhook_delete_notification' => 'Webhook wedi\'i dileu\'n llwyddiannus', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'creodd ddefnyddiwr', 'user_create_notification' => 'Defnyddiwr wedi\'i greu\'n llwyddiannus', diff --git a/lang/cy/editor.php b/lang/cy/editor.php index c99a9bb00ea..a10fc7d0be4 100644 --- a/lang/cy/editor.php +++ b/lang/cy/editor.php @@ -163,6 +163,8 @@ 'about' => 'Ynglŷn â\'r golygydd', 'about_title' => 'Ynglŷn â\'r Golygydd WYSIWYG', 'editor_license' => 'Trwydded a Hawlfraint Golygydd', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Mae\'r golygydd hwn wedi\'i adeiladu gan ddefnyddio :tinyLink sy\'n cael ei ddarparu o dan y drwydded MIT.', 'editor_tiny_license_link' => 'Gellir dod o hyd i fanylion hawlfraint a thrwydded TinyMCE yma.', 'save_continue' => 'Cadw Tudalen a Pharhau', diff --git a/lang/cy/entities.php b/lang/cy/entities.php index 1293231d0fb..d153ba453bf 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Ffeil PDF', 'export_text' => 'Ffeil Testun Plaen', 'export_md' => 'Ffeil Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Templed Tudalen Diofyn', 'default_template_explain' => 'Clustnodwch dempled tudalen a fydd yn cael ei ddefnyddio fel y cynnwys diofyn ar gyfer pob tudalen a grëwyd yn yr eitem hon. Cofiwch y bydd hwn ond yn cael ei ddefnyddio os yw’r sawl a grëodd y dudalen â mynediad gweld i’r dudalen dempled a ddewiswyd.', 'default_template_select' => 'Dewiswch dudalen templed', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Caniatâd', diff --git a/lang/cy/errors.php b/lang/cy/errors.php index b6868c5cefe..4fe90a0c1dc 100644 --- a/lang/cy/errors.php +++ b/lang/cy/errors.php @@ -105,6 +105,18 @@ 'app_down' => 'Mae :appName i lawr ar hyn o bryd', 'back_soon' => 'Bydd yn ôl i fyny yn fuan.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Ni chanfuwyd tocyn awdurdodi ar y cais', 'api_bad_authorization_format' => 'Canfuwyd tocyn awdurdodi ar y cais ond roedd yn ymddangos bod y fformat yn anghywir', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index 3b0fa5ed604..92b2d65d968 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Mynediad i Ryngwyneb Rhaglennu Cymwysiadau (API) system', 'role_manage_settings' => 'Rheoli gosodiadau apiau', 'role_export_content' => 'Cynnwys allforio', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Newid golygydd tudalen', 'role_notifications' => 'Derbyn a rheoli hysbysiadau', 'role_asset' => 'Caniatâd Asedau', diff --git a/lang/cy/validation.php b/lang/cy/validation.php index 00664184174..fd3483ddc4c 100644 --- a/lang/cy/validation.php +++ b/lang/cy/validation.php @@ -105,6 +105,11 @@ 'url' => 'Mae’r fformat :attribute yn annilys.', 'uploaded' => 'Nid oedd modd uwchlwytho’r ffeil. Efallai na fydd y gweinydd yn derbyn ffeiliau o\'r maint hwn.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/da/activities.php b/lang/da/activities.php index e92d1c165a5..f6053318f27 100644 --- a/lang/da/activities.php +++ b/lang/da/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'slettede webhooken', 'webhook_delete_notification' => 'Webhooken blev slettet', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'opret bruger', 'user_create_notification' => 'Bruger oprettet korrekt', diff --git a/lang/da/editor.php b/lang/da/editor.php index 973c8a90266..054c4262794 100644 --- a/lang/da/editor.php +++ b/lang/da/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Gem og fortsæt', diff --git a/lang/da/entities.php b/lang/da/entities.php index 47d2f1568d5..eca5f1f0296 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF-fil', 'export_text' => 'Almindelig tekstfil', 'export_md' => 'Markdown Fil', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Rettigheder', diff --git a/lang/da/errors.php b/lang/da/errors.php index afa7b5824d7..f12dbab57c8 100644 --- a/lang/da/errors.php +++ b/lang/da/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName er nede lige nu', 'back_soon' => 'Den er oppe igen snart.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Der blev ikke fundet nogen autorisationstoken på anmodningen', 'api_bad_authorization_format' => 'En autorisationstoken blev fundet på anmodningen, men formatet var forkert', diff --git a/lang/da/settings.php b/lang/da/settings.php index fa802af16f0..36ab18d3082 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Tilgå system-API', 'role_manage_settings' => 'Administrer app-indstillinger', 'role_export_content' => 'Eksporter indhold', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Skift side editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Tilladelser for medier og "assets"', diff --git a/lang/da/validation.php b/lang/da/validation.php index 022839dc693..b09b621b23b 100644 --- a/lang/da/validation.php +++ b/lang/da/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute-formatet er ugyldigt.', 'uploaded' => 'Filen kunne ikke oploades. Serveren accepterer muligvis ikke filer af denne størrelse.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/de/activities.php b/lang/de/activities.php index 5099209475e..79e05fbaf3d 100644 --- a/lang/de/activities.php +++ b/lang/de/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'löschte Webhook', 'webhook_delete_notification' => 'Webhook erfolgreich gelöscht', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'hat Benutzer erzeugt:', 'user_create_notification' => 'Benutzer erfolgreich erstellt', diff --git a/lang/de/editor.php b/lang/de/editor.php index e6b2400692a..ab19d167e62 100644 --- a/lang/de/editor.php +++ b/lang/de/editor.php @@ -163,6 +163,8 @@ 'about' => 'Über den Editor', 'about_title' => 'Über den WYSIWYG-Editor', 'editor_license' => 'Editorlizenz & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Dieser Editor wurde mithilfe von :tinyLink erstellt, der unter der MIT-Lizenz bereitgestellt wird.', 'editor_tiny_license_link' => 'Die Copyright- und Lizenzdetails von TinyMCE finden Sie hier.', 'save_continue' => 'Speichern & Fortfahren', diff --git a/lang/de/entities.php b/lang/de/entities.php index 6c67d9830dc..b3e1b38f9fe 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF-Datei', 'export_text' => 'Textdatei', 'export_md' => 'Markdown-Datei', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Standard Seitenvorlage', 'default_template_explain' => 'Zuweisen einer Seitenvorlage, die als Standardinhalt für alle Seiten verwendet wird, die innerhalb dieses Elements erstellt wurden. Beachten Sie, dass dies nur dann verwendet wird, wenn der Ersteller der Seite Zugriff auf die ausgewählte Vorlagen-Seite hat.', 'default_template_select' => 'Wählen Sie eine Seitenvorlage', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Berechtigungen', diff --git a/lang/de/errors.php b/lang/de/errors.php index 7d6e09935aa..f940f67436b 100644 --- a/lang/de/errors.php +++ b/lang/de/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName befindet sich aktuell im Wartungsmodus', 'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Kein Autorisierungstoken für die Anfrage gefunden', 'api_bad_authorization_format' => 'Ein Autorisierungstoken wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein', diff --git a/lang/de/settings.php b/lang/de/settings.php index 60fc2591469..c1e4bcdd92f 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -163,6 +163,7 @@ 'role_access_api' => 'Systemzugriffs-API', 'role_manage_settings' => 'Globaleinstellungen verwalten', 'role_export_content' => 'Inhalt exportieren', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Seiten-Editor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', 'role_asset' => 'Berechtigungen', diff --git a/lang/de/validation.php b/lang/de/validation.php index 1ca1f458e18..072bf79d1ec 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute ist kein valides Format.', 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/de_informal/activities.php b/lang/de_informal/activities.php index 6783b42a5f1..94a0b2b1d5c 100644 --- a/lang/de_informal/activities.php +++ b/lang/de_informal/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'gelöschter Webhook', 'webhook_delete_notification' => 'Webhook erfolgreich gelöscht', + // Imports + 'import_create' => 'erstellter Import', + 'import_create_notification' => 'Import erfolgreich hochgeladen', + 'import_run' => 'aktualisierter Import', + 'import_run_notification' => 'Inhalt erfolgreich importiert', + 'import_delete' => 'gelöschter Import', + 'import_delete_notification' => 'Import erfolgreich gelöscht', + // Users 'user_create' => 'hat Benutzer erzeugt:', 'user_create_notification' => 'Benutzer erfolgreich erstellt', diff --git a/lang/de_informal/common.php b/lang/de_informal/common.php index e033b63d22e..bc33b8535a6 100644 --- a/lang/de_informal/common.php +++ b/lang/de_informal/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Allgemeine Geschäftsbedingungen', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Suche :appName', ]; diff --git a/lang/de_informal/editor.php b/lang/de_informal/editor.php index 1956715d189..81f5eab9e64 100644 --- a/lang/de_informal/editor.php +++ b/lang/de_informal/editor.php @@ -163,6 +163,8 @@ 'about' => 'Über den Editor', 'about_title' => 'Über den WYSIWYG Editor', 'editor_license' => 'Editorlizenz & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Dieser Editor wurde mit :tinyLink erstellt, das unter der MIT-Lizenz zur Verfügung gestellt wird.', 'editor_tiny_license_link' => 'Die Copyright- und Lizenzdetails von TinyMCE findest du hier.', 'save_continue' => 'Seite speichern & fortfahren', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index e8a69167243..4283fb8cb0d 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF-Datei', 'export_text' => 'Textdatei', 'export_md' => 'Markdown-Datei', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Standard Seitenvorlage', 'default_template_explain' => 'Zuweisen einer Seitenvorlage, die als Standardinhalt für alle Seiten verwendet wird, die innerhalb dieses Elements erstellt wurden. Beachten Sie, dass dies nur dann verwendet wird, wenn der Ersteller der Seite Zugriff auf die ausgewählte Vorlagen-Seite hat.', 'default_template_select' => 'Wähle eine Seitenvorlage', + 'import' => 'Importieren', + 'import_validate' => 'Import validieren', + 'import_desc' => 'Importiere Bücher, Kapitel & Seiten mit einem ZIP-Export von der gleichen oder einer anderen Instanz. Wähle eine ZIP-Datei, um fortzufahren. Nachdem die Datei hochgeladen und bestätigt wurde, kannst Du den Import in der nächsten Ansicht konfigurieren und bestätigen.', + 'import_zip_select' => 'ZIP-Datei zum Hochladen auswählen', + 'import_zip_validation_errors' => 'Fehler bei der Validierung der angegebenen ZIP-Datei:', + 'import_pending' => 'Ausstehende Importe', + 'import_pending_none' => 'Es wurden keine Importe gestartet.', + 'import_continue' => 'Import fortsetzen', + 'import_continue_desc' => 'Überprüfe den Inhalt, der aus der hochgeladenen ZIP-Datei importiert werden soll. Führe den Import aus, um dessen Inhalt zu diesem System hinzuzufügen. Die hochgeladene ZIP-Importdatei wird bei erfolgreichem Import automatisch gelöscht.', + 'import_details' => 'Importdetails', + 'import_run' => 'Import ausführen', + 'import_size' => 'Größe des importierten ZIP: :size', + 'import_uploaded_at' => 'Hochgeladen :relativeTime', + 'import_uploaded_by' => 'Hochgeladen von', + 'import_location' => 'Import Zielort', + 'import_location_desc' => 'Wähle einen Zielort für deinen importierten Inhalt. Du benötigst die entsprechenden Berechtigungen, um innerhalb des gewünschten Zielortes Inhalte zu erstellen.', + 'import_delete_confirm' => 'Bist Du sicher, dass Du diesen Import löschen möchtest?', + 'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.', + 'import_errors' => 'Importfehler', + 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', // Permissions and restrictions 'permissions' => 'Berechtigungen', @@ -224,8 +245,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Sauberer Inhalt)', 'pages_edit_switch_to_markdown_stable' => '(Stabiler Inhalt)', 'pages_edit_switch_to_wysiwyg' => 'Zum WYSIWYG Editor wechseln', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Wechsel zum neuen WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testphase)', 'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen', 'pages_edit_enter_changelog_desc' => 'Bitte gib eine kurze Zusammenfassung deiner Änderungen ein', 'pages_edit_enter_changelog' => 'Änderungsprotokoll eingeben', diff --git a/lang/de_informal/errors.php b/lang/de_informal/errors.php index dcfa6a2ffaf..856f02b48c7 100644 --- a/lang/de_informal/errors.php +++ b/lang/de_informal/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Du kannst den einzigen Administrator nicht löschen.', 'users_cannot_delete_guest' => 'Du kannst den Gast-Benutzer nicht löschen', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Benutzer konnte nicht erstellt werden, da die Einladungs-E-Mail nicht gesendet werden konnte', // Roles 'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden.', @@ -105,6 +105,18 @@ 'app_down' => ':appName befindet sich aktuell im Wartungsmodus.', 'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.', + // Import + 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', + 'import_zip_cant_decode_data' => 'Konnte Inhalt der data.json im ZIP nicht finden und dekodieren.', + 'import_zip_no_data' => 'ZIP-Datei hat kein erwartetes Buch, Kapitel oder Seiteninhalt.', + 'import_validation_failed' => 'ZIP Import konnte aufgrund folgender Fehler nicht validiert werden:', + 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', + 'import_perms_books' => 'Dir fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', + 'import_perms_chapters' => 'Dir fehlt die erforderliche Berechtigung, um Kapitel zu erstellen.', + 'import_perms_pages' => 'Dir fehlt die erforderliche Berechtigung, um Seiten zu erstellen.', + 'import_perms_images' => 'Dir fehlt die erforderliche Berechtigung, um Bilder zu erstellen.', + 'import_perms_attachments' => 'Dir fehlt die erforderliche Berechtigung, um Anhänge zu erstellen.', + // API errors 'api_no_authorization_found' => 'Kein Autorisierungs-Token für die Anfrage gefunden', 'api_bad_authorization_format' => 'Ein Autorisierungs-Token wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 2eddefd4ad5..549aba11442 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -163,6 +163,7 @@ 'role_access_api' => 'Systemzugriffs-API', 'role_manage_settings' => 'Globaleinstellungen verwalten', 'role_export_content' => 'Inhalt exportieren', + 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiteneditor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', 'role_asset' => 'Berechtigungen', diff --git a/lang/de_informal/validation.php b/lang/de_informal/validation.php index 61cd7409b4e..0d26dfd298a 100644 --- a/lang/de_informal/validation.php +++ b/lang/de_informal/validation.php @@ -75,7 +75,7 @@ ], 'mimes' => ':attribute muss eine Datei vom Typ: :values sein.', 'min' => [ - 'numeric' => ':attribute muss mindestens :min sein', + 'numeric' => ':attribute muss mindestens :min sein.', 'file' => ':attribute muss mindestens :min Kilobyte groß sein.', 'string' => ':attribute muss mindestens :min Zeichen lang sein.', 'array' => ':attribute muss mindesten :min Elemente enthalten.', @@ -105,6 +105,11 @@ 'url' => ':attribute ist kein valides Format.', 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', + 'zip_file' => ':attribute muss auf eine Datei innerhalb des ZIP verweisen.', + 'zip_file_mime' => ':attribute muss auf eine Datei des Typs :validType verweisen, gefunden :foundType.', + 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', + 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/el/activities.php b/lang/el/activities.php index 50bd7bb2ebe..6eb85746402 100644 --- a/lang/el/activities.php +++ b/lang/el/activities.php @@ -14,7 +14,7 @@ 'page_delete_notification' => 'Η σελίδα διαγράφηκε επιτυχώς', 'page_restore' => 'αποκατεστημένη σελίδα', 'page_restore_notification' => 'Η σελίδα αποκαταστάθηκε με επιτυχία', - 'page_move' => 'Η σελίδα μετακινήθηκε', + 'page_move' => 'σελίδα που μετακινήθηκε', 'page_move_notification' => 'Η σελίδα μετακινήθηκε με επιτυχία', // Chapters @@ -59,7 +59,7 @@ 'favourite_remove_notification' => '":name" προστέθηκε στα αγαπημένα σας', // Watching - 'watch_update_level_notification' => 'Watch preferences successfully updated', + 'watch_update_level_notification' => 'Οι προτιμήσεις παρακολούθησης ενημερώθηκαν επιτυχώς', // Auth 'auth_login' => 'συνδεδεμένος', @@ -84,6 +84,14 @@ 'webhook_delete' => 'διαγραμμένο webhook', 'webhook_delete_notification' => 'Το Webhook διαγράφηκε επιτυχώς', + // Imports + 'import_create' => 'δημιουργημένη εισαγωγή', + 'import_create_notification' => 'Το εισαγόμενο αρχείο μεταφορτώθηκε επιτυχώς', + 'import_run' => 'ενημερωμένη εισαγωγή', + 'import_run_notification' => 'Το περιεχόμενο εισήχθη επιτυχώς', + 'import_delete' => 'διαγραμμένη εισαγωγή', + 'import_delete_notification' => 'Η εισαγωγή διαγράφηκε επιτυχώς', + // Users 'user_create' => 'δημιουργημένος χρήστης', 'user_create_notification' => 'Ο χρήστης δημιουργήθηκε με επιτυχία', @@ -105,19 +113,19 @@ 'role_create_notification' => 'Ο Ρόλος δημιουργήθηκε με επιτυχία', 'role_update' => 'Ενημέρωση ρόλου', 'role_update_notification' => 'Ο Ρόλος ενημερώθηκε με επιτυχία', - 'role_delete' => 'deleted role', + 'role_delete' => 'διαγραμμένος ρόλος', 'role_delete_notification' => 'Ο Ρόλος διαγράφηκε επιτυχώς', // Recycle Bin - 'recycle_bin_empty' => 'emptied recycle bin', - 'recycle_bin_restore' => 'restored from recycle bin', - 'recycle_bin_destroy' => 'removed from recycle bin', + 'recycle_bin_empty' => 'αδειασμένος κάδος ανακύκλωσης', + 'recycle_bin_restore' => 'αποκαταστάθηκε από τον κάδο ανακύκλωσης', + 'recycle_bin_destroy' => 'αφαιρέθηκε από τον κάδο ανακύκλωσης', // Comments 'commented_on' => 'σχολίασε', - 'comment_create' => 'added comment', - 'comment_update' => 'updated comment', - 'comment_delete' => 'deleted comment', + 'comment_create' => 'προστέθηκε σχόλιο', + 'comment_update' => 'ενημερώθηκε σχόλιο', + 'comment_delete' => 'διαγράφηκε σχόλιο', // Other 'permissions_update' => 'ενημερωμένα δικαιώματα', diff --git a/lang/el/auth.php b/lang/el/auth.php index 183baab7a02..0b94ef859dd 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -91,7 +91,7 @@ 'mfa_option_totp_title' => 'Εφαρμογή για κινητό', 'mfa_option_totp_desc' => 'Για να χρησιμοποιήσετε τον έλεγχο ταυτότητας πολλαπλών παραγόντων, θα χρειαστείτε μια εφαρμογή για κινητά που υποστηρίζει TOTP, όπως το Google Authenticator, το Authy ή το Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Εφεδρικοί κωδικοί', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Δημιουργεί ένα σύνολο εφεδρικών κωδικών μίας χρήσης τους οποίους θα εισάγετε κατά τη σύνδεση σας, για να πιστοποιήσετε την ταυτότητά σας. Φροντίστε να τους αποθηκεύσετε σε ασφαλές σημείο.', 'mfa_gen_confirm_and_enable' => 'Επιβεβαίωση και ενεργοποίηση', 'mfa_gen_backup_codes_title' => 'Ρύθμιση εφεδρικών κωδικών', 'mfa_gen_backup_codes_desc' => 'Αποθηκεύστε την παρακάτω λίστα κωδικών σε ασφαλές μέρος. Κατά την πρόσβαση στο σύστημα, θα μπορείτε να χρησιμοποιήσετε έναν από τους κωδικούς ως δεύτερο μηχανισμό ελέγχου ταυτότητας.', diff --git a/lang/el/common.php b/lang/el/common.php index d1b6bd9f790..112133883f3 100644 --- a/lang/el/common.php +++ b/lang/el/common.php @@ -6,7 +6,7 @@ // Buttons 'cancel' => 'Ακύρωση', - 'close' => 'Close', + 'close' => 'Κλείσιμο', 'confirm' => 'Οκ', 'back' => 'Πίσω', 'save' => 'Αποθήκευση', @@ -20,7 +20,7 @@ 'description' => 'Περιγραφή', 'role' => 'Ρόλος', 'cover_image' => 'Εικόνα εξώφυλλου', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'Αυτή η εικόνα θα πρέπει να είναι περίπου 440x250px, αν και θα κλιμακωθεί και θα περικοπεί με ευελιξία ώστε να ταιριάζει στη διεπαφή χρήστη σε διαφορετικά σενάρια όπως απαιτείται, επομένως οι πραγματικές διαστάσεις για την εμφάνιση θα διαφέρουν.', // Actions 'actions' => 'Ενέργειες', @@ -42,7 +42,7 @@ 'remove' => 'Αφαίρεση', 'add' => 'Προσθήκη', 'configure' => 'Διαμόρφωση', - 'manage' => 'Manage', + 'manage' => 'Διαχείριση', 'fullscreen' => 'Πλήρης οθόνη', 'favourite' => 'Αγαπημένα', 'unfavourite' => 'Αφαίρεση από Αγαπημένα', @@ -52,7 +52,7 @@ 'filter_clear' => 'Διαγραφή φίλτρου', 'download' => 'Λήψη', 'open_in_tab' => 'Άνοιγμα σε Καρτέλα', - 'open' => 'Open', + 'open' => 'Άνοιγμα', // Sort Options 'sort_options' => 'Επιλογές ταξινόμησης', @@ -109,5 +109,5 @@ 'terms_of_service' => 'Όροι χρήσης', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Αναζήτηση :appName', ]; diff --git a/lang/el/components.php b/lang/el/components.php index b54a48601e3..9c9d3e8f839 100644 --- a/lang/el/components.php +++ b/lang/el/components.php @@ -6,36 +6,36 @@ // Image Manager 'image_select' => 'Επιλογή εικόνας', - 'image_list' => 'Image List', - 'image_details' => 'Image Details', - 'image_upload' => 'Upload Image', - 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', - 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_list' => 'Κατάλογος εικόνων', + 'image_details' => 'Λεπτομέρειες εικόνας', + 'image_upload' => 'Μεταφόρτωση εικόνας', + 'image_intro' => 'Εδώ μπορείτε να επιλέξετε και να διαχειριστείτε εικόνες που έχουν προηγουμένως μεταφορτωθεί στο σύστημα.', + 'image_intro_upload' => 'Μεταφορτώστε μια νέα εικόνα σύροντας ένα αρχείο εικόνας σε αυτό το παράθυρο ή χρησιμοποιώντας το κουμπί "Μεταφόρτωση εικόνας" παραπάνω.', 'image_all' => 'Όλες', 'image_all_title' => 'Δείτε όλες τις εικόνες που υπάρχουν στο Server', 'image_book_title' => 'Προβολή εικόνων που έχουν μεταφορτωθεί σε αυτό το βιβλίο', 'image_page_title' => 'Προβολή εικόνων που έχουν δημοσιευτεί σε αυτήν τη σελίδα', 'image_search_hint' => 'Αναζήτηση με όνομα εικόνας', 'image_uploaded' => 'Μεταφορτώθηκε :uploadedDate', - 'image_uploaded_by' => 'Uploaded by :userName', - 'image_uploaded_to' => 'Uploaded to :pageLink', - 'image_updated' => 'Updated :updateDate', + 'image_uploaded_by' => 'Μεταφορτώθηκε από :userName', + 'image_uploaded_to' => 'Μεταφορτώθηκε στο :pageLink', + 'image_updated' => 'Ενημερώθηκε :updateDate', 'image_load_more' => 'Φόρτωσε περισσότερα', 'image_image_name' => 'Όνομα εικόνας', 'image_delete_used' => 'Αυτή η εικόνα χρησιμοποιείται στις παρακάτω σελίδες.', 'image_delete_confirm_text' => 'Είστε σίγουροι ότι θέλετε να διαγράψετε αυτήν την εικόνα;', 'image_select_image' => 'Επιλέξτε Εικόνα', 'image_dropzone' => 'Σύρτε ή κάντε κλικ εδώ για μεταφόρτωση εικόνων', - 'image_dropzone_drop' => 'Drop images here to upload', + 'image_dropzone_drop' => 'Απόθεση εικόνων εδώ για μεταφόρτωση', 'images_deleted' => 'Οι εικόνες διαγράφηκαν', 'image_preview' => 'Προεπισκόπηση εικόνας', 'image_upload_success' => 'Η εικόνα μεταφορτώθηκε με επιτυχία', 'image_update_success' => 'Τα στοιχεία της εικόνας ενημερώθηκαν με επιτυχία', 'image_delete_success' => 'Η εικόνα διαγράφηκε επιτυχώς', - 'image_replace' => 'Replace Image', - 'image_replace_success' => 'Image file successfully updated', - 'image_rebuild_thumbs' => 'Regenerate Size Variations', - 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + 'image_replace' => 'Αντικατάσταση εικόνας', + 'image_replace_success' => 'Το αρχείο εικόνας ενημερώθηκε επιτυχώς', + 'image_rebuild_thumbs' => 'Επαναδημιουργία παραλλαγών μεγέθους', + 'image_rebuild_thumbs_success' => 'Οι παραλλαγές μεγέθους εικόνας ανακατασκευάστηκαν επιτυχώς!', // Code Editor 'code_editor' => 'Επεξεργασία κώδικα', diff --git a/lang/el/editor.php b/lang/el/editor.php index 0d801e82182..f6c67b879e6 100644 --- a/lang/el/editor.php +++ b/lang/el/editor.php @@ -33,7 +33,7 @@ 'header_small' => 'Μικρή κεφαλίδα', 'header_tiny' => 'Μικροσκοπική κεφαλίδα', 'paragraph' => 'Παράγραφος', - 'blockquote' => 'Blockquote', + 'blockquote' => 'Μπλοκ κειμένου παράθεσης', 'inline_code' => 'Ενσωματωμένος κωδικός', 'callouts' => 'Επεξηγήσεις', 'callout_information' => 'Πληροφορίες', @@ -57,7 +57,7 @@ 'list_bullet' => 'Λίστα με κουκκίδες', 'list_numbered' => 'Λίστα με αρίθμηση', 'list_task' => 'Λίστα εργασιών', - 'indent_increase' => 'Αύξηση εσοχήςt', + 'indent_increase' => 'Αύξηση Εσοχής', 'indent_decrease' => 'Μείωση εσοχής', 'table' => 'Πίνακας', 'insert_image' => 'Εισαγωγή εικόνας', @@ -81,9 +81,9 @@ 'table_properties' => 'Ιδιότητες πίνακα', 'table_properties_title' => 'Ιδιότητες πίνακα', 'delete_table' => 'Διαγραφή πίνακα', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => 'Εκκαθάριση μορφοποίησης πίνακα', + 'resize_to_contents' => 'Αλλαγή μεγέθους σε περιεχόμενο', + 'row_header' => 'Κεφαλίδα γραμμής', 'insert_row_before' => 'Εισαγωγή γραμμής πάνω', 'insert_row_after' => 'Εισαγωγή γραμμής κάτω', 'delete_row' => 'Διαγραφή γραμμής', @@ -163,6 +163,8 @@ 'about' => 'Σχετικά', 'about_title' => 'Σχετικά με τον επεξεργαστή WYSIWYG', 'editor_license' => 'Άδεια εκδότη και πνευματικά δικαιώματα', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Αυτός ο επεξεργαστής έχει δημιουργηθεί χρησιμοποιώντας :tinyLink που παρέχεται με την άδεια MIT.', 'editor_tiny_license_link' => 'Τα πνευματικά δικαιώματα και τα στοιχεία άδειας χρήσης του TinyMCE μπορείτε να τα βρείτε εδώ.', 'save_continue' => 'Αποθήκευση σελίδας & Συνέχεια', diff --git a/lang/el/entities.php b/lang/el/entities.php index d127a77c222..d74c7de0598 100644 --- a/lang/el/entities.php +++ b/lang/el/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Αρχείο PDF', 'export_text' => 'Αρχείο Απλού κειμένου', 'export_md' => 'Αρχείο Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Δικαιώματα', diff --git a/lang/el/errors.php b/lang/el/errors.php index 1d57fe4a174..cdfa2155f13 100644 --- a/lang/el/errors.php +++ b/lang/el/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName είναι προσωρινά μη διαθέσιμη', 'back_soon' => 'Θα υπάρξει σύντομα υποστήριξη.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Δεν βρέθηκε διακριτικό εξουσιοδότησης κατόπιν αιτήματος', 'api_bad_authorization_format' => 'Ένα διακριτικό εξουσιοδότησης βρέθηκε κατόπιν αιτήματος, αλλά η μορφή εμφανίστηκε εσφαλμένη', diff --git a/lang/el/notifications.php b/lang/el/notifications.php index 1afd23f1dc4..3924cab3b5c 100644 --- a/lang/el/notifications.php +++ b/lang/el/notifications.php @@ -4,24 +4,24 @@ */ return [ - 'new_comment_subject' => 'New comment on page: :pageName', - 'new_comment_intro' => 'A user has commented on a page in :appName:', - 'new_page_subject' => 'New page: :pageName', - 'new_page_intro' => 'A new page has been created in :appName:', - 'updated_page_subject' => 'Updated page: :pageName', - 'updated_page_intro' => 'A page has been updated in :appName:', - 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'new_comment_subject' => 'Νέο σχόλιο στη σελίδα: :pageName', + 'new_comment_intro' => 'Ένας χρήστης έχει σχολιάσει σε μια σελίδα στο :appName:', + 'new_page_subject' => 'Νέα σελίδα: :pageName', + 'new_page_intro' => 'Μια νέα σελίδα έχει δημιουργηθεί στο :appName:', + 'updated_page_subject' => 'Ενημερωμένη σελίδα: :pageName', + 'updated_page_intro' => 'Μια σελίδα έχει ενημερωθεί στο :appName:', + 'updated_page_debounce' => 'Για να αποτρέψετε μαζικές ειδοποιήσεις, για κάποιο διάστημα δε θα σας αποστέλλονται ειδοποιήσεις για περαιτέρω αλλαγές σε αυτήν τη σελίδα από τον ίδιο συντάκτη.', - 'detail_page_name' => 'Page Name:', - 'detail_page_path' => 'Page Path:', - 'detail_commenter' => 'Commenter:', - 'detail_comment' => 'Comment:', - 'detail_created_by' => 'Created By:', - 'detail_updated_by' => 'Updated By:', + 'detail_page_name' => 'Όνομα σελίδας:', + 'detail_page_path' => 'Διαδρομή σελίδας:', + 'detail_commenter' => 'Σχολιαστής:', + 'detail_comment' => 'Σχόλιο:', + 'detail_created_by' => 'Δημιουργήθηκε από:', + 'detail_updated_by' => 'Ενημερώθηκε από:', - 'action_view_comment' => 'View Comment', - 'action_view_page' => 'View Page', + 'action_view_comment' => 'Προβολή σχολίου', + 'action_view_page' => 'Προβολή σελίδας', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', - 'footer_reason_link' => 'your notification preferences', + 'footer_reason' => 'Αυτή η ειδοποίηση εστάλη σε εσάς επειδή το :link καλύπτει αυτόν τον τύπο δραστηριότητας για αυτό το αντικείμενο.', + 'footer_reason_link' => 'προτιμήσεις σας για ειδοποιήσεις', ]; diff --git a/lang/el/settings.php b/lang/el/settings.php index 8c34fccba5d..243d1c2886c 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -109,7 +109,7 @@ 'recycle_bin_contents_empty' => 'Ο κάδος ανακύκλωσης είναι επί του παρόντος άδειος', 'recycle_bin_empty' => 'Αδειάστε τον Κάδο Ανακύκλωσης', 'recycle_bin_empty_confirm' => 'Αυτό θα καταστρέψει οριστικά όλα τα αντικείμενα στον κάδο ανακύκλωσης, συμπεριλαμβανομένου του περιεχομένου που περιέχεται σε κάθε αντικείμενο. Είστε βέβαιοι ότι θέλετε να αδειάσετε τον κάδο ανακύκλωσης;', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Αυτή η ενέργεια θα διαγράψει οριστικά από το σύστημα αυτό το στοιχείο μαζί με τυχόν θυγατρικά, που αναφέρονται παρακάτω. Μετά την επιβεβαίωση της διαγραφής δε θα μπορείτε να επαναφέρετε αυτό το περιεχόμενο. Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτό το στοιχείο;', 'recycle_bin_destroy_list' => 'Αντικείμενα για καταστροφή', 'recycle_bin_restore_list' => 'Αντικείμενα για επαναφορά', 'recycle_bin_restore_confirm' => 'Αυτή η ενέργεια θα επαναφέρει το διαγραμμένο στοιχείο, συμπεριλαμβανομένων τυχόν θυγατρικών στοιχείων, στην αρχική τους θέση. Εάν η αρχική τοποθεσία έχει από τότε διαγραφεί και βρίσκεται τώρα στον κάδο ανακύκλωσης, θα πρέπει επίσης να αποκατασταθεί και το γονικό στοιχείο.', @@ -162,8 +162,9 @@ 'role_access_api' => 'Πρόσβαση στο API του συστήματος', 'role_manage_settings' => 'Διαχειριστείτε τις ρυθμίσεις του ΑΡΙ', 'role_export_content' => 'Εξαγωγή περιεχομένου', + 'role_import_content' => 'Εισαγωγή περιεχομένου', 'role_editor_change' => 'Αλλαγή προγράμματος επεξεργασίας σελίδας', - 'role_notifications' => 'Receive & manage notifications', + 'role_notifications' => 'Λήψη & διαχείριση ειδοποιήσεων', 'role_asset' => 'Δικαιώματα Συστήματος', 'roles_system_warning' => 'Λάβετε υπόψη ότι η πρόσβαση σε οποιοδήποτε από τις τρεις παραπάνω άδειες (δικαιώματα) μπορεί να επιτρέψει σε έναν χρήστη να αλλάξει τα δικά του προνόμια ή τα προνόμια άλλων στο σύστημα. Εκχωρήστε ρόλους με αυτά τα δικαιώματα μόνο σε αξιόπιστους χρήστες.', 'role_asset_desc' => 'Αυτά τα δικαιώματα ελέγχουν την προεπιλεγμένη πρόσβαση στα στοιχεία (άδειες) εντός του συστήματος. Τα δικαιώματα σε Βιβλία, Κεφάλαια και Σελίδες θα παρακάμψουν αυτές τις άδειες.', @@ -193,8 +194,8 @@ 'users_send_invite_text' => 'Μπορείτε να επιλέξετε να στείλετε σε αυτόν τον χρήστη ένα email πρόσκλησης που του επιτρέπει να ορίσει τον δικό του κωδικό πρόσβασης. Σε διαφορετική περίπτωση μπορείτε να ορίσετε τον κωδικό πρόσβασής του εσείς.', 'users_send_invite_option' => 'Αποστολή email πρόσκλησης σε χρήστη', 'users_external_auth_id' => 'Εξωτερικός έλεγχος ταυτότητας', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_external_auth_id_desc' => 'Όταν χρησιμοποιείται ένα εξωτερικό σύστημα ελέγχου ταυτότητας (όπως SAML2, OIDC ή LDAP) αυτό είναι το αναγνωριστικό που συνδέει αυτόν τον χρήστη BookStack με τον λογαριασμό συστήματος ελέγχου ταυτότητας. Μπορείτε να αγνοήσετε αυτό το πεδίο αν χρησιμοποιείτε τον προεπιλεγμένο έλεγχο ταυτότητας μέσω email.', + 'users_password_warning' => 'Συμπληρώστε τα παρακάτω μόνο αν θέλετε να αλλάξετε τον κωδικό πρόσβασης για αυτόν το χρήστη.', 'users_system_public' => 'Αυτός ο χρήστης αντιπροσωπεύει οποιονδήποτε επισκέπτη που επισκέπτεται τη Βιβλιοθήκη σας. Δεν μπορεί να χρησιμοποιηθεί για τη σύνδεση αλλά εκχωρείται αυτόματα.', 'users_delete' => 'Διαγραφή Χρήστη', 'users_delete_named' => 'Διαγραφή χρήστη :userName', @@ -210,16 +211,16 @@ 'users_preferred_language' => 'Προτιμώμενη γλώσσα', 'users_preferred_language_desc' => 'Αυτή η επιλογή θα αλλάξει τη γλώσσα που χρησιμοποιείται για τη διεπαφή χρήστη της εφαρμογής. Αυτό δεν θα επηρεάσει οποιοδήποτε περιεχόμενο που δημιουργήθηκε από χρήστες.', 'users_social_accounts' => 'Λογαριασμοί Κοινωνικής δικτύωσης ', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_desc' => 'Δείτε την κατάσταση των συνδεδεμένων λογαριασμών κοινωνικών δικτύων για αυτόν το χρήστη. Οι λογαριασμοί κοινωνικών δικτύων, μπορούν να χρησιμοποιηθούν επιπλέον του κύριου συστήματος ελέγχου ταυτότητας, για πρόσβαση στο σύστημα.', 'users_social_accounts_info' => 'Εδώ μπορείτε να συνδέσετε τους άλλους λογαριασμούς σας για ταχύτερη και ευκολότερη σύνδεση. Η αποσύνδεση ενός λογαριασμού εδώ δεν ανακαλεί προηγουμένως εξουσιοδοτημένη πρόσβαση. Ανάκληση πρόσβασης από τις ρυθμίσεις προφίλ σας στον συνδεδεμένο κοινωνικό λογαριασμό.', 'users_social_connect' => 'Σύνδεση λογαριασμού', 'users_social_disconnect' => 'Αποσύνδεση λογαριασμού', - 'users_social_status_connected' => 'Connected', - 'users_social_status_disconnected' => 'Disconnected', + 'users_social_status_connected' => 'Συνδεδεμένο', + 'users_social_status_disconnected' => 'Αποσυνδεδεμένο', 'users_social_connected' => ':socialΛογαριασμός λογαριασμού συνδέθηκε με επιτυχία στο προφίλ σας.', 'users_social_disconnected' => ':socialΛογαριασμός αποσυνδέθηκε επιτυχώς από το προφίλ σας.', 'users_api_tokens' => 'API Tokens', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_desc' => 'Δημιουργία και διαχείριση των διακριτικών πρόσβασης (token) που χρησιμοποιούνται για τον έλεγχο ταυτότητας με το REST API του BookStack. Τα δικαιώματα για το API τα διαχειρίζεται ο χρήστης στον οποίο ανήκει το διακριτικό (token).', 'users_api_tokens_none' => 'Δεν έχουν δημιουργηθεί διακριτικά API για αυτόν το χρήστη', 'users_api_tokens_create' => 'Δημιουργία διακριτικού Api Token', 'users_api_tokens_expires' => 'Λήγει', @@ -277,13 +278,13 @@ 'webhooks_last_error_message' => 'Τελευταίο μήνυμα λάθους:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'Άδειες', + 'licenses_desc' => 'Αυτή η σελίδα αναφέρει λεπτομερώς τις πληροφορίες άδειας χρήσης για το BookStack επιπρόσθετα στα έργα και τις βιβλιοθήκες που χρησιμοποιούνται εντός του BookStack. Πολλά έργα που αναφέρονται μπορούν να χρησιμοποιηθούν μόνο σε ένα πλαίσιο ανάπτυξης.', + 'licenses_bookstack' => 'Άδεια BookStack', + 'licenses_php' => 'Άδειες Βιβλιοθήκης PHP', + 'licenses_js' => 'Άδειες Βιβλιοθήκης JavaScript', + 'licenses_other' => 'Άλλες άδειες', + 'license_details' => 'Λεπτομέρειες άδειας', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/el/validation.php b/lang/el/validation.php index be96cd0caf6..12d4919cab2 100644 --- a/lang/el/validation.php +++ b/lang/el/validation.php @@ -105,6 +105,11 @@ 'url' => 'Η μορφή του :attribute δεν είναι έγκυρη.', 'uploaded' => 'Δεν ήταν δυνατή η αποστολή του αρχείου. Ο διακομιστής ενδέχεται να μην δέχεται αρχεία αυτού του μεγέθους.', + 'zip_file' => 'Το :attribute πρέπει να παραπέμπει σε ένα αρχείο εντός του ZIP.', + 'zip_file_mime' => 'Το :attribute πρέπει να αναφέρεται σε αρχείο τύπου :validTypes, βρέθηκε :foundType.', + 'zip_model_expected' => 'Αναμενόταν αντικείμενο δεδομένων, αλλά ":type" βρέθηκε.', + 'zip_unique' => 'Το :attribute πρέπει να είναι μοναδικό για τον τύπο αντικειμένου εντός του ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/es/activities.php b/lang/es/activities.php index 9431eeb9fef..5bd3fa5f10c 100644 --- a/lang/es/activities.php +++ b/lang/es/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhook eliminado', 'webhook_delete_notification' => 'Webhook eliminado correctamente', + // Imports + 'import_create' => 'importación creada', + 'import_create_notification' => 'Importación cargada correctamente', + 'import_run' => 'importación actualizada', + 'import_run_notification' => 'Contenido importado correctamente', + 'import_delete' => 'importación borrada', + 'import_delete_notification' => 'Importación borrada correctamente', + // Users 'user_create' => 'usuario creado', 'user_create_notification' => 'Usuario creado correctamente', diff --git a/lang/es/editor.php b/lang/es/editor.php index 1f9f663fa63..3d9e90c13fb 100644 --- a/lang/es/editor.php +++ b/lang/es/editor.php @@ -163,6 +163,8 @@ 'about' => 'Acerca del editor', 'about_title' => 'Acerca del editor WYSIWYG', 'editor_license' => 'Licencia del editor y derechos de autor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor se construye usando :tinyLink que se proporciona bajo la licencia MIT.', 'editor_tiny_license_link' => 'Aquí encontrará los detalles de los derechos de autor y la licencia de TinyMCE.', 'save_continue' => 'Guardar Página y Continuar', diff --git a/lang/es/entities.php b/lang/es/entities.php index d197c20983f..733be165dc8 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Archivo PDF', 'export_text' => 'Archivo de texto', 'export_md' => 'Archivo Markdown', + 'export_zip' => 'ZIP portable', 'default_template' => 'Plantilla de página por defecto', 'default_template_explain' => 'Asigne una plantilla de página que se utilizará como contenido predeterminado para todas las páginas creadas en este elemento. Tenga en cuenta que esto sólo se utilizará si el creador de páginas tiene acceso a la plantilla de página elegida.', 'default_template_select' => 'Seleccione una página de plantilla', + 'import' => 'Importar', + 'import_validate' => 'Validar importación', + 'import_desc' => 'Importar libros, capítulos y páginas usando una exportación zip portable de la misma o distinta instancia. Seleccione un archivo ZIP para continuar. Después de que el archivo haya sido subido y validado, podrá configurar y confirmar la importación en la siguiente vista.', + 'import_zip_select' => 'Seleccione archivo ZIP a subir', + 'import_zip_validation_errors' => 'Se detectaron errores al validar el archivo ZIP proporcionado:', + 'import_pending' => 'Importaciones pendientes', + 'import_pending_none' => 'No se han iniciado importaciones.', + 'import_continue' => 'Continuar importación', + 'import_continue_desc' => 'Revise el contenido que debe importarse del archivo ZIP subido. Cuando esté listo, ejecute la importación para añadir su contenido a este sistema. El archivo de importación ZIP subido se eliminará automáticamente al terminar la importación correctamente.', + 'import_details' => 'Detalles de la Importación', + 'import_run' => 'Ejecutar Importación', + 'import_size' => ':size tamaño archivo ZIP', + 'import_uploaded_at' => 'Subido :relativeTime', + 'import_uploaded_by' => 'Subido por', + 'import_location' => 'Ubicación de Importación', + 'import_location_desc' => 'Seleccione una ubicación de destino para el contenido importado. Necesitará los permisos pertinentes para crearlo dentro de la ubicación que elija.', + 'import_delete_confirm' => '¿Está seguro de que desea eliminar esta importación?', + 'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.', + 'import_errors' => 'Errores de Importación', + 'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/es/errors.php b/lang/es/errors.php index deb6c1bc51e..98aa2acd593 100644 --- a/lang/es/errors.php +++ b/lang/es/errors.php @@ -105,6 +105,18 @@ 'app_down' => 'La aplicación :appName se encuentra caída en este momento', 'back_soon' => 'Volverá a estar operativa pronto.', + // Import + 'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.', + 'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.', + 'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.', + 'import_validation_failed' => 'Error al validar la importación del ZIP con errores:', + 'import_zip_failed_notification' => 'Error al importar archivo ZIP.', + 'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.', + 'import_perms_chapters' => 'Le faltan los permisos necesarios para crear capítulos.', + 'import_perms_pages' => 'Le faltan los permisos necesarios para crear páginas.', + 'import_perms_images' => 'Le faltan los permisos necesarios para crear imágenes.', + 'import_perms_attachments' => 'Le faltan los permisos necesarios para crear adjuntos.', + // API errors 'api_no_authorization_found' => 'No se encontró ningún token de autorización en la solicitud', 'api_bad_authorization_format' => 'Se ha encontrado un token de autorización en la solicitud pero el formato era incorrecto', diff --git a/lang/es/settings.php b/lang/es/settings.php index 62919fe4ac8..cb13cad121b 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'API de sistema de acceso', 'role_manage_settings' => 'Gestionar ajustes de la aplicación', 'role_export_content' => 'Exportar contenido', + 'role_import_content' => 'Importar contenido', 'role_editor_change' => 'Cambiar editor de página', 'role_notifications' => 'Recibir y gestionar notificaciones', 'role_asset' => 'Permisos de contenido', diff --git a/lang/es/validation.php b/lang/es/validation.php index bfcb050bea4..d5f4f8495c8 100644 --- a/lang/es/validation.php +++ b/lang/es/validation.php @@ -105,6 +105,11 @@ 'url' => 'El atributo :attribute tiene un formato inválido.', 'uploaded' => 'El archivo no ha podido subirse. Es posible que el servidor no acepte archivos de este tamaño.', + 'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.', + 'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.', + 'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".', + 'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/es_AR/activities.php b/lang/es_AR/activities.php index 62d986ceaa0..f7e9794937e 100644 --- a/lang/es_AR/activities.php +++ b/lang/es_AR/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhook eliminado', 'webhook_delete_notification' => 'Webhook eliminado correctamente', + // Imports + 'import_create' => 'importación creada', + 'import_create_notification' => 'Importación cargada correctamente', + 'import_run' => 'importación actualizada', + 'import_run_notification' => 'Contenido importado correctamente', + 'import_delete' => 'importación borrada', + 'import_delete_notification' => 'Importación borrada correctamente', + // Users 'user_create' => 'usuario creado', 'user_create_notification' => 'Usuario creado correctamente', diff --git a/lang/es_AR/editor.php b/lang/es_AR/editor.php index 3cd7d4657a7..9002dc72bac 100644 --- a/lang/es_AR/editor.php +++ b/lang/es_AR/editor.php @@ -163,6 +163,8 @@ 'about' => 'Acerca del editor', 'about_title' => 'Acerca del editor WYSIWYG', 'editor_license' => 'Licencia del editor y derechos de autor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor se construye usando :tinyLink provisto bajo la licencia MIT.', 'editor_tiny_license_link' => 'Aquí se muestran los detalles de los derechos de autor y la licencia de TinyMCE.', 'save_continue' => 'Guardar Página y Continuar', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index d35682d2fad..a118e16bed9 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Archivo PDF', 'export_text' => 'Archivo de texto plano', 'export_md' => 'Archivo Markdown', + 'export_zip' => 'ZIP portable', 'default_template' => 'Plantilla de página por defecto', 'default_template_explain' => 'Asigne una plantilla de página que se utilizará como contenido predeterminado para todas las páginas creadas en este elemento. Tenga en cuenta que esto sólo se utilizará si el creador de páginas tiene acceso a la plantilla de página elegida.', 'default_template_select' => 'Seleccione una página de plantilla', + 'import' => 'Importar', + 'import_validate' => 'Validar importación', + 'import_desc' => 'Importar libros, capítulos y páginas usando una exportación zip portable de la misma o distinta instancia. Seleccione un archivo ZIP para continuar. Después de que el archivo haya sido subido y validado, podrá configurar y confirmar la importación en la siguiente vista.', + 'import_zip_select' => 'Seleccione archivo ZIP a subir', + 'import_zip_validation_errors' => 'Se detectaron errores al validar el archivo ZIP proporcionado:', + 'import_pending' => 'Importaciones pendientes', + 'import_pending_none' => 'No se han iniciado importaciones.', + 'import_continue' => 'Continuar importación', + 'import_continue_desc' => 'Revise el contenido que debe importarse del archivo ZIP subido. Cuando esté listo, ejecute la importación para añadir su contenido a este sistema. El archivo de importación ZIP subido se eliminará automáticamente al terminar la importación correctamente.', + 'import_details' => 'Detalles de la Importación', + 'import_run' => 'Ejecutar Importación', + 'import_size' => ':size tamaño archivo ZIP', + 'import_uploaded_at' => 'Subido :relativeTime', + 'import_uploaded_by' => 'Subido por', + 'import_location' => 'Ubicación de Importación', + 'import_location_desc' => 'Seleccione una ubicación de destino para el contenido importado. Necesitará los permisos pertinentes para crearlo dentro de la ubicación que elija.', + 'import_delete_confirm' => '¿Está seguro de que desea eliminar esta importación?', + 'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.', + 'import_errors' => 'Errores de Importación', + 'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/es_AR/errors.php b/lang/es_AR/errors.php index c25c793d04f..cb1bc1202bb 100644 --- a/lang/es_AR/errors.php +++ b/lang/es_AR/errors.php @@ -105,6 +105,18 @@ 'app_down' => 'La aplicación :appName se encuentra caída en este momento', 'back_soon' => 'Volverá a estar operativa en corto tiempo.', + // Import + 'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.', + 'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.', + 'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.', + 'import_validation_failed' => 'Error al validar la importación del ZIP con errores:', + 'import_zip_failed_notification' => 'Error al importar archivo ZIP.', + 'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.', + 'import_perms_chapters' => 'Le faltan los permisos necesarios para crear capítulos.', + 'import_perms_pages' => 'Le faltan los permisos necesarios para crear páginas.', + 'import_perms_images' => 'Le faltan los permisos necesarios para crear imágenes.', + 'import_perms_attachments' => 'Le faltan los permisos necesarios para crear adjuntos.', + // API errors 'api_no_authorization_found' => 'No se encontró ningún token de autorización en la solicitud', 'api_bad_authorization_format' => 'Se ha encontrado un token de autorización en la solicitud pero el formato era incorrecto', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 84ed4769468..9a78c876042 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -163,6 +163,7 @@ 'role_access_api' => 'API de sistema de acceso', 'role_manage_settings' => 'Gestionar ajustes de activos', 'role_export_content' => 'Exportar contenido', + 'role_import_content' => 'Importar contenido', 'role_editor_change' => 'Cambiar editor de página', 'role_notifications' => 'Recibir y gestionar notificaciones', 'role_asset' => 'Permisos de activos', diff --git a/lang/es_AR/validation.php b/lang/es_AR/validation.php index 9b2b1b4134a..db16845e030 100644 --- a/lang/es_AR/validation.php +++ b/lang/es_AR/validation.php @@ -105,6 +105,11 @@ 'url' => 'El atributo :attribute tiene un formato inválido.', 'uploaded' => 'El archivo no se pudo subir. Puede ser que el servidor no acepte archivos de este tamaño.', + 'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.', + 'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.', + 'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".', + 'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/et/activities.php b/lang/et/activities.php index 46812551023..bbc9762500e 100644 --- a/lang/et/activities.php +++ b/lang/et/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'kustutas veebihaagi', 'webhook_delete_notification' => 'Veebihaak on kustutatud', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'lisas kasutaja', 'user_create_notification' => 'Kasutaja on lisatud', diff --git a/lang/et/editor.php b/lang/et/editor.php index 610be639879..77b0514569e 100644 --- a/lang/et/editor.php +++ b/lang/et/editor.php @@ -163,6 +163,8 @@ 'about' => 'Redaktori info', 'about_title' => 'Info WYSIWYG redaktori kohta', 'editor_license' => 'Redaktori litsents ja autoriõigused', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'See redaktor on loodud :tinyLink abil, mis on saadaval MIT litsentsi alusel.', 'editor_tiny_license_link' => 'TinyMCE autoriõigused ja litsents on saadaval siin.', 'save_continue' => 'Salvesta leht ja jätka', diff --git a/lang/et/entities.php b/lang/et/entities.php index dd875a6f2da..7afe7005da6 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF fail', 'export_text' => 'Tekstifail', 'export_md' => 'Markdown fail', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Vaikimisi lehe mall', 'default_template_explain' => 'Vali lehe mall, mida kasutatakse kõigi selle objekti sees loodud lehtede vaikimisi sisuna. Pea meeles, et seda kasutatakse ainult siis, kui lehe loojal on valitud malli vaatamise õigus.', 'default_template_select' => 'Vali mall', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Õigused', diff --git a/lang/et/errors.php b/lang/et/errors.php index 732c945295f..4faf1d00807 100644 --- a/lang/et/errors.php +++ b/lang/et/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName on hetkel maas', 'back_soon' => 'See on varsti tagasi.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Päringust ei leitud volitustunnust', 'api_bad_authorization_format' => 'Päringust leiti volitustunnus, aga see ei olnud korrektses formaadis', diff --git a/lang/et/settings.php b/lang/et/settings.php index aa860c05081..6f29fcba17a 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Süsteemi API ligipääs', 'role_manage_settings' => 'Rakenduse seadete haldamine', 'role_export_content' => 'Sisu eksport', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Lehe redaktori muutmine', 'role_notifications' => 'Võta vastu ja halda teavitusi', 'role_asset' => 'Sisu õigused', diff --git a/lang/et/validation.php b/lang/et/validation.php index 5f7f6f3aa39..6a6eb661ccb 100644 --- a/lang/et/validation.php +++ b/lang/et/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute on vigases formaadis.', 'uploaded' => 'Faili üleslaadimine ebaõnnestus. Server ei pruugi sellise suurusega faile vastu võtta.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/eu/activities.php b/lang/eu/activities.php index 15e11c47618..eeee084ab3c 100644 --- a/lang/eu/activities.php +++ b/lang/eu/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhook ezabatua', 'webhook_delete_notification' => 'Webhook egoki ezabatua', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/eu/editor.php b/lang/eu/editor.php index 161cb3b27a4..34c73997840 100644 --- a/lang/eu/editor.php +++ b/lang/eu/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/eu/entities.php b/lang/eu/entities.php index 7588561201c..10c5dba126e 100644 --- a/lang/eu/entities.php +++ b/lang/eu/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF fitxategia', 'export_text' => 'Testu lauko fitxategiak', 'export_md' => 'Markdown fitxategia', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Baimenak', diff --git a/lang/eu/errors.php b/lang/eu/errors.php index 33f033a176a..2a747e5f8bb 100644 --- a/lang/eu/errors.php +++ b/lang/eu/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index 1ee5843e8e7..80f1c87f439 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Sistemako APIra sarrera', 'role_manage_settings' => 'Kudeatu aplikazio ezarpenak', 'role_export_content' => 'Exportatu edukia', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Fitxategi baimenak', diff --git a/lang/eu/validation.php b/lang/eu/validation.php index cd7ba387dfa..f79dc852f3e 100644 --- a/lang/eu/validation.php +++ b/lang/eu/validation.php @@ -105,6 +105,11 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/fa/activities.php b/lang/fa/activities.php index adc38024978..c17fe698821 100644 --- a/lang/fa/activities.php +++ b/lang/fa/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'حذف وب هوک', 'webhook_delete_notification' => 'وب هوک با موفقیت حذف شد', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'کاربر ایجاد شده', 'user_create_notification' => 'کاربر با موفقیت به ایجاد شد', diff --git a/lang/fa/editor.php b/lang/fa/editor.php index 89d163de0eb..9fbb948f5ca 100644 --- a/lang/fa/editor.php +++ b/lang/fa/editor.php @@ -163,6 +163,8 @@ 'about' => 'درباره ویرایشگر', 'about_title' => 'درباره ویرایشگر WYSIWYG', 'editor_license' => 'مجوز و حق کپی رایت ویرایشگر', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'این ویرایشگر توسط :tinyLink و تحت مجوز MIT ساخته شده است.', 'editor_tiny_license_link' => 'جزئیات کپی رایت و مجوز TinyMCE را می توانید در اینجا پیدا کنید.', 'save_continue' => 'ذخیره صفحه و ادامه', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index 7d173e61e6a..7578c21c029 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'فایل PDF', 'export_text' => 'پرونده متنی ساده', 'export_md' => 'راهنما مارک‌دون', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'مجوزها', diff --git a/lang/fa/errors.php b/lang/fa/errors.php index 8a54f9d3784..c4bc6963ae6 100644 --- a/lang/fa/errors.php +++ b/lang/fa/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName در حال حاضر قطع است', 'back_soon' => 'به زودی پشتیبان خواهد شد.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'هیچ نشانه مجوزی در درخواست یافت نشد', 'api_bad_authorization_format' => 'یک نشانه مجوز در این درخواست یافت شد اما قالب نادرست به نظر می‌رسید', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index 4993c4671c5..bb18364f055 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'دسترسی به API سیستم', 'role_manage_settings' => 'تنظیمات برنامه را مدیریت کنید', 'role_export_content' => 'صادرات محتوا', + 'role_import_content' => 'Import content', 'role_editor_change' => 'تغییر ویرایشگر صفحه', 'role_notifications' => 'دریافت و مدیریت اعلان‌ها', 'role_asset' => 'مجوزهای دارایی', diff --git a/lang/fa/validation.php b/lang/fa/validation.php index 467c68702dd..10432471f64 100644 --- a/lang/fa/validation.php +++ b/lang/fa/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute معتبر نمی‌باشد.', 'uploaded' => 'بارگذاری فایل :attribute موفقیت آمیز نبود.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/fi/activities.php b/lang/fi/activities.php index 6bb267c707c..40588abcd3b 100644 --- a/lang/fi/activities.php +++ b/lang/fi/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'poisti toimintokutsun', 'webhook_delete_notification' => 'Toimintokutsu poistettiin onnistuneesti', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'loi käyttäjän', 'user_create_notification' => 'Käyttäjä luotiin onnistuneesti', diff --git a/lang/fi/editor.php b/lang/fi/editor.php index 34150e64781..c2ab2d8094f 100644 --- a/lang/fi/editor.php +++ b/lang/fi/editor.php @@ -163,6 +163,8 @@ 'about' => 'Tietoja editorista', 'about_title' => 'Tietoja WYSIWYG-editorista', 'editor_license' => 'Editorin lisenssi ja tekijänoikeus', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Tämä editori on rakennettu käyttäen sovellusta :tinyLink, joka on MIT-lisenssin alainen.', 'editor_tiny_license_link' => 'TinyMCE-editorin tekijänoikeus- ja lisenssitiedot löytyvät täältä.', 'save_continue' => 'Tallenna sivu ja jatka', diff --git a/lang/fi/entities.php b/lang/fi/entities.php index c10886fe00f..8743c972582 100644 --- a/lang/fi/entities.php +++ b/lang/fi/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF-tiedosto', 'export_text' => 'Tekstitiedosto', 'export_md' => 'Markdown-tiedosto', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Käyttöoikeudet', diff --git a/lang/fi/errors.php b/lang/fi/errors.php index 4ffb37e8a91..d948b556bf8 100644 --- a/lang/fi/errors.php +++ b/lang/fi/errors.php @@ -106,6 +106,18 @@ 'app_down' => ':appName on kaatunut', 'back_soon' => 'Se palautetaan pian.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Pyynnöstä ei löytynyt valtuutuskoodia', 'api_bad_authorization_format' => 'Pyynnöstä löytyi valtuutuskoodi, mutta sen muoto oli virheellinen', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index 3365d87bc37..5d953dc70e1 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Pääsy järjestelmän ohjelmointirajapintaan', 'role_manage_settings' => 'Hallinnoi sivuston asetuksia', 'role_export_content' => 'Vie sisältöjä', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Vaihda sivun editoria', 'role_notifications' => 'Vastaanota ja hallinnoi ilmoituksia', 'role_asset' => 'Sisältöjen oikeudet', diff --git a/lang/fi/validation.php b/lang/fi/validation.php index 35c5f1924b2..0a9330433c8 100644 --- a/lang/fi/validation.php +++ b/lang/fi/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute muoto ei ole kelvollinen.', 'uploaded' => 'Tiedostoa ei voitu ladata. Palvelin ei ehkä hyväksy tämän kokoisia tiedostoja.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/fr/activities.php b/lang/fr/activities.php index 7acf5931a28..871b886480c 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'supprimer un Webhook', 'webhook_delete_notification' => 'Webhook supprimé avec succès', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'utilisateur créé', 'user_create_notification' => 'Utilisateur créé avec succès', diff --git a/lang/fr/editor.php b/lang/fr/editor.php index 558dda4c63d..11edae10c9b 100644 --- a/lang/fr/editor.php +++ b/lang/fr/editor.php @@ -163,6 +163,8 @@ 'about' => 'À propos de l\'éditeur', 'about_title' => 'À propos de l\'éditeur WYSIWYG', 'editor_license' => 'Licence d\'éditeur et droit d\'auteur', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Cet éditeur est construit en utilisant :tinyLink qui est fourni sous la licence MIT.', 'editor_tiny_license_link' => 'Vous trouverez ici les détails sur les droits d\'auteur et les licences de TinyMCE.', 'save_continue' => 'Enregistrer et continuer', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index b92ddac0ac7..6581587d4f7 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Fichier PDF', 'export_text' => 'Document texte', 'export_md' => 'Fichiers Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Modèle de page par défaut', 'default_template_explain' => 'Sélectionnez un modèle de page qui sera utilisé comme contenu par défaut pour les nouvelles pages créées dans cet élément. Gardez à l\'esprit que le modèle ne sera utilisé que si le créateur de la page a accès au modèle sélectionné.', 'default_template_select' => 'Sélectionnez un modèle de page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Autorisations', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index 7658fd8a982..d89926dac72 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName n\'est pas en service pour le moment', 'back_soon' => 'Nous serons bientôt de retour.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Aucun jeton d\'autorisation trouvé pour la demande', 'api_bad_authorization_format' => 'Un jeton d\'autorisation a été trouvé pour la requête, mais le format semble incorrect', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index eb8ca396cb6..d113e89ab7c 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Accès à l\'API du système', 'role_manage_settings' => 'Gérer les préférences de l\'application', 'role_export_content' => 'Exporter le contenu', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Changer l\'éditeur de page', 'role_notifications' => 'Recevoir et gérer les notifications', 'role_asset' => 'Permissions des ressources', diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 702c025854a..ed16776e72f 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute a un format invalide.', 'uploaded' => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/he/activities.php b/lang/he/activities.php index 75ab9b3ea28..1954121f813 100644 --- a/lang/he/activities.php +++ b/lang/he/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'Webhook נמחק', 'webhook_delete_notification' => 'Webook נמחק בהצלחה', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'משתמש חדש נוצר', 'user_create_notification' => 'משתמש נוצר בהצלחה', diff --git a/lang/he/editor.php b/lang/he/editor.php index e37862a4bff..a670385f99d 100644 --- a/lang/he/editor.php +++ b/lang/he/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'אודות עורך הטקסט הויזואלי', 'editor_license' => 'רשיון וזכויות העורך', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'שמור עמוד והמשך', diff --git a/lang/he/entities.php b/lang/he/entities.php index 0dc6f882ea4..9ca53fa137c 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'קובץ PDF', 'export_text' => 'טקסט רגיל', 'export_md' => 'קובץ Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'הרשאות', diff --git a/lang/he/errors.php b/lang/he/errors.php index b11375dae39..75f7949ea76 100644 --- a/lang/he/errors.php +++ b/lang/he/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName כרגע אינו זמין', 'back_soon' => 'מקווים שיחזור במהרה', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/he/settings.php b/lang/he/settings.php index bf585287cf8..9e63a43cdd8 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'גש ל-API המערכת', 'role_manage_settings' => 'ניהול הגדרות יישום', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'הרשאות משאבים', diff --git a/lang/he/validation.php b/lang/he/validation.php index 85f6c698eb7..d7c12678365 100644 --- a/lang/he/validation.php +++ b/lang/he/validation.php @@ -105,6 +105,11 @@ 'url' => 'שדה :attribute בעל פורמט שאינו תקין.', 'uploaded' => 'שדה :attribute ארעה שגיאה בעת ההעלאה.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/hr/activities.php b/lang/hr/activities.php index 284121b2dd7..f5853dc65c7 100644 --- a/lang/hr/activities.php +++ b/lang/hr/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'web- dojavnik izbrisan', 'webhook_delete_notification' => 'Web-dojavnik je uspješno izbrisan', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'kreirani korisnik', 'user_create_notification' => 'Korisnik je uspješno kreiran', diff --git a/lang/hr/editor.php b/lang/hr/editor.php index f3fb21ad4e9..55b38ec2d7c 100644 --- a/lang/hr/editor.php +++ b/lang/hr/editor.php @@ -163,6 +163,8 @@ 'about' => 'O Editoru', 'about_title' => 'O WYSIWYG Editoru', 'editor_license' => 'Licenca i autorsko pravo uređivača', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ovaj uređivač je izrađen pomoću: tinyLink koji je dostupan pod MIT licencom.', 'editor_tiny_license_link' => 'Detalji o autorskim pravima i licenci za TinyMCE mogu se pronaći ovdje.', 'save_continue' => 'Spremi Stranicu i Nastavi', diff --git a/lang/hr/entities.php b/lang/hr/entities.php index 01ceb054b1b..91621ed8cd3 100644 --- a/lang/hr/entities.php +++ b/lang/hr/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF Datoteka', 'export_text' => 'Text File', 'export_md' => 'Markdown Datoteka', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Dopuštenja', diff --git a/lang/hr/errors.php b/lang/hr/errors.php index 31577863c4f..92b9de7d24e 100644 --- a/lang/hr/errors.php +++ b/lang/hr/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName trenutno nije dostupna', 'back_soon' => 'Uskoro će se vratiti.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Nije pronađena autorizacija', 'api_bad_authorization_format' => 'Pogreška prilikom autorizacije', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index e58c3a6d45b..a8b8fea637f 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'API pristup', 'role_manage_settings' => 'Upravljanje postavkama aplikacija', 'role_export_content' => 'Izvoz sadržaja', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Promijeni uređivač stranica', 'role_notifications' => 'Primanje i upravljanje obavijestima', 'role_asset' => 'Upravljanje vlasništvom', diff --git a/lang/hr/validation.php b/lang/hr/validation.php index 95263ff07bd..32b11a9bd80 100644 --- a/lang/hr/validation.php +++ b/lang/hr/validation.php @@ -105,6 +105,11 @@ 'url' => 'Format :attribute nije valjan.', 'uploaded' => 'Datoteka se ne može prenijeti. Server možda ne prihvaća datoteke te veličine.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/hu/activities.php b/lang/hu/activities.php index ea3d0cef3f2..b0dbfd45e97 100644 --- a/lang/hu/activities.php +++ b/lang/hu/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhookot törölt', 'webhook_delete_notification' => 'Webhook sikeresen törölve', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'felhasználó létrehozása', 'user_create_notification' => 'Felhasználó sikeresen létrehozva', diff --git a/lang/hu/editor.php b/lang/hu/editor.php index b71efec20f2..339ece151df 100644 --- a/lang/hu/editor.php +++ b/lang/hu/editor.php @@ -163,6 +163,8 @@ 'about' => 'A szerkesztőről', 'about_title' => 'A WYSIWYG szerkesztőről', 'editor_license' => 'Szerkesztő Licensz és Copyright információi', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ez a szerkesztő az MIT licenc alatt szolgáltatott :tinyLink segítségével készült.', 'editor_tiny_license_link' => 'A TinyMCE szerzői jogi és licencinformációi itt találhatók.', 'save_continue' => 'Mentés és Folytatás', diff --git a/lang/hu/entities.php b/lang/hu/entities.php index 174197827b1..05c863878f9 100644 --- a/lang/hu/entities.php +++ b/lang/hu/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF fájl', 'export_text' => 'Egyszerű szövegfájl', 'export_md' => 'Markdown jegyzetek', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Alapértelmezett oldalsablon', 'default_template_explain' => 'Rendeljen hozzá egy oldalsablont, amely alapértelmezett tartalomként lesz használva az ezen az elemen belül létrehozott összes oldalon. Ne feledje, hogy ezt csak akkor használja, ha az oldal készítője megtekintési hozzáféréssel rendelkezik a kiválasztott sablonoldalhoz.', 'default_template_select' => 'Válasszon ki egy oldalsablont', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Jogosultságok', diff --git a/lang/hu/errors.php b/lang/hu/errors.php index 275eb65fe3d..00f47ac40bc 100644 --- a/lang/hu/errors.php +++ b/lang/hu/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName jelenleg nem üzemel', 'back_soon' => 'Hamarosan újra elérhető lesz.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'A kérésben nem található hitelesítési vezérjel', 'api_bad_authorization_format' => 'A kérésben hitelesítési vezérjel található de a formátuma érvénytelennek tűnik', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index 7398de9e171..55471ee0f8d 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Hozzáférés a rendszer API-hoz', 'role_manage_settings' => 'Alkalmazás beállításainak kezelése', 'role_export_content' => 'Tartalom exportálása', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Oldalszerkesztő módosítása', 'role_notifications' => 'Értesítések fogadása és kezelése', 'role_asset' => 'Eszköz jogosultságok', diff --git a/lang/hu/validation.php b/lang/hu/validation.php index 863e39d530d..a215416ca5f 100644 --- a/lang/hu/validation.php +++ b/lang/hu/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute formátuma érvénytelen.', 'uploaded' => 'A fájlt nem lehet feltölteni. A kiszolgáló nem fogad el ilyen méretű fájlokat.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/id/activities.php b/lang/id/activities.php index bc20b86ffb2..8c3e800bb67 100644 --- a/lang/id/activities.php +++ b/lang/id/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'menghapus webhook', 'webhook_delete_notification' => 'Webhook berhasil dihapus', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'pengguna yang dibuat', 'user_create_notification' => 'Pengguna berhasil dibuat', diff --git a/lang/id/editor.php b/lang/id/editor.php index 73a374a1dc2..c0b72601ad6 100644 --- a/lang/id/editor.php +++ b/lang/id/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/id/entities.php b/lang/id/entities.php index cf87ab0253b..81d6763d09d 100644 --- a/lang/id/entities.php +++ b/lang/id/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Dokumen PDF', 'export_text' => 'Dokumen Teks Biasa', 'export_md' => 'File Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Izin', diff --git a/lang/id/errors.php b/lang/id/errors.php index 8a4bc658f21..b4e2c6feb11 100644 --- a/lang/id/errors.php +++ b/lang/id/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName sedang down sekarang', 'back_soon' => 'Ini akan segera kembali.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Tidak ada token otorisasi yang ditemukan pada permintaan tersebut', 'api_bad_authorization_format' => 'Token otorisasi ditemukan pada permintaan tetapi formatnya salah', diff --git a/lang/id/settings.php b/lang/id/settings.php index 21979ba60c4..abc4c86b405 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Akses Sistem API', 'role_manage_settings' => 'Kelola setelan aplikasi', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Izin Aset', diff --git a/lang/id/validation.php b/lang/id/validation.php index e1fcbc7246c..d922113d984 100644 --- a/lang/id/validation.php +++ b/lang/id/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute format tidak valid.', 'uploaded' => 'Berkas tidak dapat diunggah. Server mungkin tidak menerima berkas dengan ukuran ini.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/is/activities.php b/lang/is/activities.php new file mode 100644 index 00000000000..5c84cdb5365 --- /dev/null +++ b/lang/is/activities.php @@ -0,0 +1,132 @@ + 'stofna síðu', + 'page_create_notification' => 'Síða stofnuð', + 'page_update' => 'nafnlaus síða', + 'page_update_notification' => 'Síða uppfærð', + 'page_delete' => 'síðu eytt', + 'page_delete_notification' => 'Tókst að eyða síðu', + 'page_restore' => 'endurvirkja síðu', + 'page_restore_notification' => 'Síða endurvirkjuð', + 'page_move' => 'síða færð', + 'page_move_notification' => 'Tókst að færa síðu', + + // Chapters + 'chapter_create' => 'kafli búinn til', + 'chapter_create_notification' => 'Tókst að búa til kafla', + 'chapter_update' => 'kafli uppfærður', + 'chapter_update_notification' => 'Tókst að uppfæra kafla', + 'chapter_delete' => 'eyddur kafli', + 'chapter_delete_notification' => 'Tókst að eyða kafla', + 'chapter_move' => 'færður kafli', + 'chapter_move_notification' => 'Tókst að færa kafla', + + // Books + 'book_create' => 'stofnuð bók', + 'book_create_notification' => 'Tókst að stofna bók', + 'book_create_from_chapter' => 'kafla breytt í bók', + 'book_create_from_chapter_notification' => 'Tókst að breyta kafla í bók', + 'book_update' => 'uppfærð bók', + 'book_update_notification' => 'Tókst að uppfæra bók', + 'book_delete' => 'eydd bók', + 'book_delete_notification' => 'Tókst að eyða bók', + 'book_sort' => 'flokkuð bók', + 'book_sort_notification' => 'Tókst að endurflokka bók', + + // Bookshelves + 'bookshelf_create' => 'stofna hillu', + 'bookshelf_create_notification' => 'Tókst að stofna hillu', + 'bookshelf_create_from_book' => 'bók breytt i hillu', + 'bookshelf_create_from_book_notification' => 'Tókst að breyta bók í hillu', + 'bookshelf_update' => 'uppærð hilla', + 'bookshelf_update_notification' => 'Tókst að uppfæra hillu', + 'bookshelf_delete' => 'eydd hilla', + 'bookshelf_delete_notification' => 'Tókst að eyða hillu', + + // Revisions + 'revision_restore' => 'útgáfa bakfærð', + 'revision_delete' => 'útgáfu eytt', + 'revision_delete_notification' => 'Tókst að eyða útgáfu', + + // Favourites + 'favourite_add_notification' => 'hefur verið bætt í eftirlæti', + 'favourite_remove_notification' => 'hefur verið eytt úr eftirlæti', + + // Watching + 'watch_update_level_notification' => 'Fylgjast með hefur verið uppfært', + + // Auth + 'auth_login' => 'skráður inn', + 'auth_register' => 'skráður sem nýr notandi', + 'auth_password_reset_request' => 'bað um nýtt lykilorð', + 'auth_password_reset_update' => 'endurstilla lykilorð', + 'mfa_setup_method' => 'valin MFA aðferð', + 'mfa_setup_method_notification' => 'Fjölauðkenningar aðferð stillt', + 'mfa_remove_method' => 'fjarlægja MFA aðferð', + 'mfa_remove_method_notification' => 'Fjölauðkenningar aðferð fjarlægð', + + // Settings + 'settings_update' => 'uppfæra stillingar', + 'settings_update_notification' => 'Tókst að uppfæra stillingar', + 'maintenance_action_run' => 'keyrði uppfærslu', + + // Webhooks + 'webhook_create' => 'webhook búin til', + 'webhook_create_notification' => 'Tókst að búa til Webhook', + 'webhook_update' => 'webhook uppfærður', + 'webhook_update_notification' => 'Tókst að uppfæra Webhook', + 'webhook_delete' => 'eyða Webhook', + 'webhook_delete_notification' => 'Tókst að eyða Webhook', + + // Imports + 'import_create' => 'búa til innlestur', + 'import_create_notification' => 'Innlestur tókst', + 'import_run' => 'uppfæra innlestur', + 'import_run_notification' => 'Tókst að lesa inn', + 'import_delete' => 'innlestri eytt', + 'import_delete_notification' => 'Tókst að eyða innlestri', + + // Users + 'user_create' => 'stofnaður notandi', + 'user_create_notification' => 'Tókst að stofna notanda', + 'user_update' => 'uppfærður notandi', + 'user_update_notification' => 'Tókst að uppfæra notanda', + 'user_delete' => 'eyddur notandi', + 'user_delete_notification' => 'Tókst að eyða notanda', + + // API Tokens + 'api_token_create' => 'API token búið til', + 'api_token_create_notification' => 'Tókst að búa til API tóka', + 'api_token_update' => 'API tóki uppfærður', + 'api_token_update_notification' => 'Tókst að uppfæra API tóka', + 'api_token_delete' => 'eyddur API tóki', + 'api_token_delete_notification' => 'Tókst að eyða API tóka', + + // Roles + 'role_create' => 'stofnað hlutverk', + 'role_create_notification' => 'Tókst að stofna hlutverk', + 'role_update' => 'hlutverk uppfært', + 'role_update_notification' => 'Tókst að uppfæra hlutverk', + 'role_delete' => 'eytt hlutverk', + 'role_delete_notification' => 'Tókst að eyða hlutverki', + + // Recycle Bin + 'recycle_bin_empty' => 'tæmd ruslatunna', + 'recycle_bin_restore' => 'endurheimt úr ruslatunnu', + 'recycle_bin_destroy' => 'fjarlægt úr ruslatunnu', + + // Comments + 'commented_on' => 'athugasemd á', + 'comment_create' => 'athugasemd bætt við', + 'comment_update' => 'athugasemd uppfærð', + 'comment_delete' => 'athugasemd eytt', + + // Other + 'permissions_update' => 'uppfærðar heimildir', +]; diff --git a/lang/is/auth.php b/lang/is/auth.php new file mode 100644 index 00000000000..c5e7ce4f1d7 --- /dev/null +++ b/lang/is/auth.php @@ -0,0 +1,117 @@ + 'Þeesi auðkenning er ekki á skrá.', + 'throttle' => 'Of margar tilraunir til innskráningar. Reyndu aftur eftir :seconds sekúndur.', + + // Login & Register + 'sign_up' => 'Nýskrá', + 'log_in' => 'Innskrá', + 'log_in_with' => 'Innskrá með :socialDriver', + 'sign_up_with' => 'Búa til aðgang með :socialDriver', + 'logout' => 'Skrá út', + + 'name' => 'Nafn', + 'username' => 'Notandanafn', + 'email' => 'Netfang', + 'password' => 'Lykilorð', + 'password_confirm' => 'Staðfestu lykilorð', + 'password_hint' => 'Verður að vera minnst 8 stafir', + 'forgot_password' => 'Gleymt lykilorð?', + 'remember_me' => 'Geyma innskráningarupplýsingar', + 'ldap_email_hint' => 'Settu inn netfang til að nota þennan aðgang.', + 'create_account' => 'Stofna aðgang', + 'already_have_account' => 'Þegar með notandaaðgang?', + 'dont_have_account' => 'Ekki með aðgang?', + 'social_login' => 'Innskráning með samfélagsmiðli', + 'social_registration' => 'Skráning samfélagsmiðils', + 'social_registration_text' => 'Skráðu þig og innskrá með annari þjónustu.', + + 'register_thanks' => 'Takk fyrir að skrá þig!', + 'register_confirm' => 'Skoðaðu tölvupóstinn þinn og smelltu á staðfestingarhlekkinn :appName.', + 'registrations_disabled' => 'Skráningar eru óvirkar í augnablikinu', + 'registration_email_domain_invalid' => 'Þetta lén hefur ekki aðgang að þessu forriti', + 'register_success' => 'Takk fyrir að skrá þig, nú ertu innskráðursem notandi.', + + // Login auto-initiation + 'auto_init_starting' => 'Reyni innskráningu', + 'auto_init_starting_desc' => 'Reyni að tengjast auðkenningarþjónustu, ef ekkert gerist innan 5 sekúndna getur þú smellt á hlekkinn hér að neðan.', + 'auto_init_start_link' => 'Halda áfram með auðkenningu', + + // Password Reset + 'reset_password' => 'Endurstilla lykilorð', + 'reset_password_send_instructions' => 'Settu netfangið þitt hér að neðan og þú færð tölvupóst með endurstillingar hlekk.', + 'reset_password_send_button' => 'Senda hlekk', + 'reset_password_sent' => 'Endurstillingar hlekkur hefur verið sendur í tölvupósti :email ef netfangið er á skrá.', + 'reset_password_success' => 'Lykilorðið þitt hefur verið endurstillt.', + 'email_reset_subject' => 'Endurstilla :appName lykilorðið þitt', + 'email_reset_text' => 'Þú fékkst þennan tölvupóst því að beðið var um endurstillingu lykilorðs á þínum aðgangi.', + 'email_reset_not_requested' => 'Ef þú baðst ekki um endurstillingu lykilorðs þarftu ekki að gera neitt.', + + // Email Confirmation + 'email_confirm_subject' => 'Staðfestu netfangið þitt á :appName', + 'email_confirm_greeting' => 'Takk fyrir að skrá þig á :appName!', + 'email_confirm_text' => 'Vinsamlegast staðfestu netfangið þitt með því að smella á hnappin hér fyrir neðan:', + 'email_confirm_action' => 'Staðfesta netfang', + 'email_confirm_send_error' => 'Staðfesting netfangs er nauðsynleg en kerfið gat ekki sent póst, vinsamlegast hafið samband við kerfisstjóra.', + 'email_confirm_success' => 'Netfang þitt hefur verið staðfest, þú ættir nú að geta skráð þig inn með þessu netfangi.', + 'email_confirm_resent' => 'Staðfestingar tölvupóstur hefur verið sendur, kíktu í póshólfið þitt.', + 'email_confirm_thanks' => 'Takk fyrir að staðfesta!', + 'email_confirm_thanks_desc' => 'Hinkraðu smá á meðan staðfestingin þín er í vinnslu, ef ekkert gerist eftir 3 sekúndur, smelltu á "Halda áfram" hlekkinn hér fyrir neðan.', + + 'email_not_confirmed' => 'Netfang hefur ekki verið staðfest', + 'email_not_confirmed_text' => 'Netfangið þitt hefur ekki enn verið staðfest.', + 'email_not_confirmed_click_link' => 'Vinsamlegast smelltu á hlekkinn sem barst þér í tölvupósti eftir skráningu.', + 'email_not_confirmed_resend' => 'Ef þú finnur ekki tölvupóstinn sem var sendur á þig, getur þú fengið hann endursendann með því að fylla út formið hér að neðan.', + 'email_not_confirmed_resend_button' => 'Endursenda staðfestingarpóst', + + // User Invite + 'user_invite_email_subject' => 'Þér hefur verið boðið að tengjast :appName!', + 'user_invite_email_greeting' => 'Það hefur verið stofnaður aðgangur fyrir ig á :appName.', + 'user_invite_email_text' => 'Smelltu á hnappinn fyrir neðan til að setja upp lykilorð og fá aðgang:', + 'user_invite_email_action' => 'Settu inn lykilorð', + 'user_invite_page_welcome' => 'Velkominn á :appName!', + 'user_invite_page_text' => 'Til að ljúka við uppsetningu og fá aðgang að :appName verður þú að velja þér lykilorð.', + 'user_invite_page_confirm_button' => 'Staðfestu lykilorð', + 'user_invite_success_login' => 'Lykilorð klárt, nú ættir þú að geta skráð þig inn á :appName!', + + // Multi-factor Authentication + 'mfa_setup' => 'Setja upp tvöfalda auðkenningu', + 'mfa_setup_desc' => 'Tvöföld euðkenning er viðbótar vörn til að tryggja aðganginn þinn.', + 'mfa_setup_configured' => 'Þegar uppsett', + 'mfa_setup_reconfigure' => 'Endurstilla', + 'mfa_setup_remove_confirmation' => 'Ertu viss um að þú viljið fjarlæga þessa auðkenningarleið?', + 'mfa_setup_action' => 'Uppsetning', + 'mfa_backup_codes_usage_limit_warning' => 'Þú átt færri en 5 tilraunir eftir. Búðu til og geymdu hjá þér fleiri tilraunir svo þú læsist ekki úti.', + 'mfa_option_totp_title' => 'App', + 'mfa_option_totp_desc' => 'Til að virkja tvöfalda auðkenningu verður þú að hafa app í símanum sem styður TOPT, til dæmis Google Authenticator, Authy eða Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Varakóðar', + 'mfa_option_backup_codes_desc' => 'Býr til sett af einskiptis kóðum sem þú getur notað til að auðkenna þig með. Geymdu þessa kóða á öruggum stað.', + 'mfa_gen_confirm_and_enable' => 'Staðfesta og virkja', + 'mfa_gen_backup_codes_title' => 'Stillingar varakóða', + 'mfa_gen_backup_codes_desc' => 'Geymdu listann af kóðum á öruggum stað. Þú getur notað þessa kóða sem auka auðkenningu.', + 'mfa_gen_backup_codes_download' => 'Hala niður kóðum', + 'mfa_gen_backup_codes_usage_warning' => 'Hver kóði getur bara verið notaður einu sinni', + 'mfa_gen_totp_title' => 'Uppsetning Apps', + 'mfa_gen_totp_desc' => 'Til að virkja tvöfalda auðkenningu verður þú að hafa app í símanum sem styður TOPT, til dæmis Google Authenticator, Authy eða Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Skannaðu QR kóðann með appinu sem þú notar fyrir tvöfalda auðkenningu.', + 'mfa_gen_totp_verify_setup' => 'Staðfesta uppsetningu', + 'mfa_gen_totp_verify_setup_desc' => 'Staðfestu að allt virki með því að setja inn kóða úr síma appinu þínu hér fyrir neðan:', + 'mfa_gen_totp_provide_code_here' => 'Sláðu inn kóða úr auðkennningar appi', + 'mfa_verify_access' => 'Staðfesta aðgang', + 'mfa_verify_access_desc' => 'Aðgangurinn þinn þarf viðbótar auðkenningu, veldu auðkenningarleið.', + 'mfa_verify_no_methods' => 'Engar aðferðir stilltar', + 'mfa_verify_no_methods_desc' => 'Engin aukaauðkenningar aðferð fannst. Þú verður að setja upp minnst eina viðbótarauðkenningu til að halda áfram.', + 'mfa_verify_use_totp' => 'Staðfestu með farsíma appi', + 'mfa_verify_use_backup_codes' => 'Staðfesta með varakóða', + 'mfa_verify_backup_code' => 'Varakóði', + 'mfa_verify_backup_code_desc' => 'Settu inn einn af varakóðunum þínum hér að neðan:', + 'mfa_verify_backup_code_enter_here' => 'Sláðu inn varakóða hér', + 'mfa_verify_totp_desc' => 'Sláðu inn kóðann úr auðkenningar appinu úr símanum þínum:', + 'mfa_setup_login_notification' => 'Tvöföld auðkenning stillt. Skráðu þig nú inn með euðkenningarleiðinni.', +]; diff --git a/lang/is/common.php b/lang/is/common.php new file mode 100644 index 00000000000..721d844ed1d --- /dev/null +++ b/lang/is/common.php @@ -0,0 +1,113 @@ + 'Hætta við', + 'close' => 'Loka', + 'confirm' => 'Staðfesta', + 'back' => 'Til baka', + 'save' => 'Vista', + 'continue' => 'Halda áfram', + 'select' => 'Velja', + 'toggle_all' => 'Velja allt', + 'more' => 'Meira', + + // Form Labels + 'name' => 'Nafn', + 'description' => 'Lýsing', + 'role' => 'Hlutverk', + 'cover_image' => 'Forsíðumynd', + 'cover_image_description' => 'Myndin ætti að fera u. þ. b 440x250px þótt hún verði sköluð og kroppuð eftir þörfum, þannig að endanleg stærð mun endurspegla það.', + + // Actions + 'actions' => 'Aðgerðir', + 'view' => 'Skoða', + 'view_all' => 'Skoða allt', + 'new' => 'Ný', + 'create' => 'Búa til', + 'update' => 'Uppfæra', + 'edit' => 'Breyta', + 'sort' => 'Flokka', + 'move' => 'Færa', + 'copy' => 'Afrita', + 'reply' => 'Svara', + 'delete' => 'Eyða', + 'delete_confirm' => 'Staðfesta eyðingu', + 'search' => 'Leita', + 'search_clear' => 'Hreinsa leit', + 'reset' => 'Endurstilla', + 'remove' => 'Fjarlægja', + 'add' => 'Bæta við', + 'configure' => 'Stilla', + 'manage' => 'Stýra', + 'fullscreen' => 'Fylla skjá', + 'favourite' => 'Eftirlæti', + 'unfavourite' => 'Fjarlægja úr eftirlæti', + 'next' => 'Næst', + 'previous' => 'Fyrri', + 'filter_active' => 'Virk sía:', + 'filter_clear' => 'Hreinsa síu', + 'download' => 'Hlaða niður', + 'open_in_tab' => 'Opna í flipa', + 'open' => 'Opna', + + // Sort Options + 'sort_options' => 'Valkostir röðunar', + 'sort_direction_toggle' => 'Flokkunarátt', + 'sort_ascending' => 'Raða vaxandi', + 'sort_descending' => 'Raða minnkandi', + 'sort_name' => 'Nafn', + 'sort_default' => 'Sjálfgefið', + 'sort_created_at' => 'Stofnað þann', + 'sort_updated_at' => 'Uppfært þann', + + // Misc + 'deleted_user' => 'Eyddur notandi', + 'no_activity' => 'Engin virkni til að sýna', + 'no_items' => 'Engir hlutir tiltækir', + 'back_to_top' => 'Fara efst', + 'skip_to_main_content' => 'Fara í aðalefni', + 'toggle_details' => 'Virkja nánari sýn', + 'toggle_thumbnails' => 'Sýna smámynd', + 'details' => 'Nánari upplýsingar', + 'grid_view' => 'Grid View', + 'list_view' => 'Lista sýn', + 'default' => 'Sjálfgefið', + 'breadcrumb' => 'Brauðmolar', + 'status' => 'Staða', + 'status_active' => 'Virkt', + 'status_inactive' => 'Óvirkt', + 'never' => 'Aldrei', + 'none' => 'Engin', + + // Header + 'homepage' => 'Forsíða', + 'header_menu_expand' => 'Leiðarstjórn', + 'profile_menu' => 'Prófíll', + 'view_profile' => 'Skoða prófíl', + 'edit_profile' => 'Breyta prófíl', + 'dark_mode' => 'Dimmsnið', + 'light_mode' => 'Ljóssnið', + 'global_search' => 'Heildarleit', + + // Layout tabs + 'tab_info' => 'Upplýsingar', + 'tab_info_label' => 'Tab: Sýna fleiri upplýsingar', + 'tab_content' => 'Innihald', + 'tab_content_label' => 'Tab: Sýna aðalinnihald', + + // Email Content + 'email_action_help' => 'Ef þú átt í vandræðum með að smella á ":actionText" hnappinn, afritaðu og límdu slóðina í vefskoðarann þinn:', + 'email_rights' => 'Höfundaréttur varinn', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'Persónuverndarstefna', + 'terms_of_service' => 'Skilmálar þjónustu', + + // OpenSearch + 'opensearch_description' => 'Leita :appName', +]; diff --git a/lang/is/components.php b/lang/is/components.php new file mode 100644 index 00000000000..6e0527eeede --- /dev/null +++ b/lang/is/components.php @@ -0,0 +1,46 @@ + 'Myndaval', + 'image_list' => 'Myndalisti', + 'image_details' => 'Uplýsingar myndar', + 'image_upload' => 'Hlaða upp mynd', + 'image_intro' => 'Hér getur þú valið og stjórnað þeim myndum sem þegar hefur verið upphlaðið.', + 'image_intro_upload' => 'Hladdu upp nýrri mynd með því að draga hana inn í þennan glugga eða nota "Hlaða upp" hnappinn hér fyrir ofan.', + 'image_all' => 'Allar', + 'image_all_title' => 'Skoða allar myndir', + 'image_book_title' => 'Skoða þær myndir sem þegar hefur verið hlaðið upp í þessa bók', + 'image_page_title' => 'Skoða þær myndir sem þegar hefur verið hlaðið upp á þessa síðu', + 'image_search_hint' => 'Leita af myndum eftir nafni', + 'image_uploaded' => 'Hlaðið upp þann :uploadedDate', + 'image_uploaded_by' => 'Hlaðið upp af :userName', + 'image_uploaded_to' => 'Hlaðið upp á :pageLink', + 'image_updated' => 'Uppfært þann :updateDate', + 'image_load_more' => 'Hlaða fleirum', + 'image_image_name' => 'Nafn myndar', + 'image_delete_used' => 'Þessi mynd er notuð á eftirfarandi síðum.', + 'image_delete_confirm_text' => 'Ertu viss um að þú viljir eyða þessari mynd?', + 'image_select_image' => 'Velja mynd', + 'image_dropzone' => 'Dragðu myndir eða smelltu hér til að hlaða upp', + 'image_dropzone_drop' => 'Dragðu myndir hingað til að hlaða upp', + 'images_deleted' => 'Myndum eytt', + 'image_preview' => 'Forskoðun mynda', + 'image_upload_success' => 'Upphal myndar tókst', + 'image_update_success' => 'Upplýsingar um mynd uppfærðar', + 'image_delete_success' => 'Tókst að eyða mynd', + 'image_replace' => 'Skipta um mynd', + 'image_replace_success' => 'Tókst að skipta um skrá', + 'image_rebuild_thumbs' => 'Endurgera stærðastillingar', + 'image_rebuild_thumbs_success' => 'Tókst að endurgera stærðarstillingar!', + + // Code Editor + 'code_editor' => 'Breyta kóða', + 'code_language' => 'Tungumál kóða', + 'code_content' => 'Innihald kóða', + 'code_session_history' => 'Saga lotu', + 'code_save' => 'Vista kóða', +]; diff --git a/lang/is/editor.php b/lang/is/editor.php new file mode 100644 index 00000000000..4e0ebcb4438 --- /dev/null +++ b/lang/is/editor.php @@ -0,0 +1,179 @@ + 'Almennt', + 'advanced' => 'Ítarlegt', + 'none' => 'Engin', + 'cancel' => 'Hætta við', + 'save' => 'Vista', + 'close' => 'Loka', + 'undo' => 'Afturkalla', + 'redo' => 'Endurgera', + 'left' => 'Vinstri', + 'center' => 'Miðja', + 'right' => 'Hægri', + 'top' => 'Efst', + 'middle' => 'Miðja', + 'bottom' => 'Neðst', + 'width' => 'Breidd', + 'height' => 'Hæð', + 'More' => 'Meira', + 'select' => 'Velja...', + + // Toolbar + 'formats' => 'Snið', + 'header_large' => 'Stór fyrirsögn', + 'header_medium' => 'Miðlungs fyrirsögn', + 'header_small' => 'Lítil fyrirsögn', + 'header_tiny' => 'Örsmá fyrirsögn', + 'paragraph' => 'Málsgrein', + 'blockquote' => 'Gæsalappir', + 'inline_code' => 'Inline code', + 'callouts' => 'Vitna í', + 'callout_information' => 'Upplýsingar', + 'callout_success' => 'Árangur', + 'callout_warning' => 'Aðvörun', + 'callout_danger' => 'Hætta', + 'bold' => 'Feitletrað', + 'italic' => 'Skáletrað', + 'underline' => 'Undirstrikað', + 'strikethrough' => 'Yfirstrikað', + 'superscript' => 'Háletur', + 'subscript' => 'Lágletur', + 'text_color' => 'Litur texta', + 'custom_color' => 'Sérsniðinn litur', + 'remove_color' => 'Fjarlægja lit', + 'background_color' => 'Bakgrunnslitur', + 'align_left' => 'Jafna til vinstri', + 'align_center' => 'Miðju jafna', + 'align_right' => 'Hægrijafna', + 'align_justify' => 'Jafna', + 'list_bullet' => 'Punkta listi', + 'list_numbered' => 'Númeraður listi', + 'list_task' => 'Aðgerðar listi', + 'indent_increase' => 'Auka inndrátt', + 'indent_decrease' => 'Minnka inndrátt', + 'table' => 'Tafla', + 'insert_image' => 'Setja inn mynd', + 'insert_image_title' => 'Setja inn eða breyta mynd', + 'insert_link' => 'Setja inn eða breyta hlekk', + 'insert_link_title' => 'Setja inn eða breyta hlekk', + 'insert_horizontal_line' => 'Setja inn lárétta línu', + 'insert_code_block' => 'Setja inn kóðastubb', + 'edit_code_block' => 'Breyta kóðastubb', + 'insert_drawing' => 'Setja inn eða breyta teikningu', + 'drawing_manager' => 'Teikningastjóri', + 'insert_media' => 'Setja inn eða breyta miðlum', + 'insert_media_title' => 'Setja inn eða breyta miðlum', + 'clear_formatting' => 'Hreinsa forsnið', + 'source_code' => 'Frumkóði', + 'source_code_title' => 'Frumkóði', + 'fullscreen' => 'Fullann skjá', + 'image_options' => 'Myndastillingar', + + // Tables + 'table_properties' => 'Eiginleikar töflu', + 'table_properties_title' => 'Eiginleikar töflu', + 'delete_table' => 'Eyða töflu', + 'table_clear_formatting' => 'Hreinsa forsnið töflu', + 'resize_to_contents' => 'Endurstilla stærð innihalds', + 'row_header' => 'Titill raðar', + 'insert_row_before' => 'Líma röð á undan', + 'insert_row_after' => 'Líma röð á eftir', + 'delete_row' => 'Eyða röð', + 'insert_column_before' => 'Líma dálk á undan', + 'insert_column_after' => 'Líma dálk á eftir', + 'delete_column' => 'Eyða dálki', + 'table_cell' => 'Reitur', + 'table_row' => 'Röð', + 'table_column' => 'Dálkur', + 'cell_properties' => 'Eigindi reitar', + 'cell_properties_title' => 'Eigindi reitar', + 'cell_type' => 'Gerð reitar', + 'cell_type_cell' => 'Reitur', + 'cell_scope' => 'Svið', + 'cell_type_header' => 'For reitur', + 'merge_cells' => 'Sameina reiti', + 'split_cell' => 'Kljúfa reiti', + 'table_row_group' => 'Hópur raðar', + 'table_column_group' => 'Hópur dálks', + 'horizontal_align' => 'Jafna lárétt', + 'vertical_align' => 'Jafna lóðrétt', + 'border_width' => 'Border width', + 'border_style' => 'Útlit jaðars', + 'border_color' => 'Litur jaðars', + 'row_properties' => 'Eigindi raðar', + 'row_properties_title' => 'Eigindi raðar', + 'cut_row' => 'Klippa röð', + 'copy_row' => 'Afrita röð', + 'paste_row_before' => 'Líma röð á undan', + 'paste_row_after' => 'Líma röð á eftir', + 'row_type' => 'Gerð raðar', + 'row_type_header' => 'Síðuhaus', + 'row_type_body' => 'Meginmál', + 'row_type_footer' => 'Neðanmál', + 'alignment' => 'Jöfnun', + 'cut_column' => 'Klippa dálk', + 'copy_column' => 'Afrita dálk', + 'paste_column_before' => 'Líma dálk á undan', + 'paste_column_after' => 'Líma dálk á eftir', + 'cell_padding' => 'Cell padding', + 'cell_spacing' => 'Cell spacing', + 'caption' => 'Fyrirsögn', + 'show_caption' => 'Sýna fyrirsögn', + 'constrain' => 'Constrain proportions', + 'cell_border_solid' => 'Fyllt', + 'cell_border_dotted' => 'Punkta', + 'cell_border_dashed' => 'Strikað', + 'cell_border_double' => 'Tvöfalt', + 'cell_border_groove' => 'Groove', + 'cell_border_ridge' => 'Ridge', + 'cell_border_inset' => 'Inset', + 'cell_border_outset' => 'Outset', + 'cell_border_none' => 'Engin', + 'cell_border_hidden' => 'Falin', + + // Images, links, details/summary & embed + 'source' => 'Uppruni', + 'alt_desc' => 'Auka lýsing', + 'embed' => 'Innfellt', + 'paste_embed' => 'Límdu innfellda kóðann þinn að neðan:', + 'url' => 'Vistfang', + 'text_to_display' => 'Teksti til að sýna', + 'title' => 'Titill', + 'open_link' => 'Opna hlekk', + 'open_link_in' => 'Opna hlekk í...', + 'open_link_current' => 'Virkum glugga', + 'open_link_new' => 'Nýjum glugga', + 'remove_link' => 'Fjarlægja hlekk', + 'insert_collapsible' => 'Insert collapsible block', + 'collapsible_unwrap' => 'Unwrap', + 'edit_label' => 'Breyta miða', + 'toggle_open_closed' => 'Velja opið/lokað', + 'collapsible_edit' => 'Edit collapsible block', + 'toggle_label' => 'Sýna miða', + + // About view + 'about' => 'Um ritilinn', + 'about_title' => 'Um WYSIWYG ritilinn', + 'editor_license' => 'Leyfi og höfundaréttur ritilsins', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_tiny_license' => 'Þessi ritill er smíðaður með :tinyLink sem er undir MIT leyfi.', + 'editor_tiny_license_link' => 'Höfundarétt og leyfi TinyMCE má finna hér.', + 'save_continue' => 'Vista síðu og halda áfram', + 'callouts_cycle' => '(Keep pressing to toggle through types)', + 'link_selector' => 'Hlekkur á innihald', + 'shortcuts' => 'Flýtileiðir', + 'shortcut' => 'Flýtileið', + 'shortcuts_intro' => 'Eftirtaldar flýtileiðir eru aðgengilegar í ritlinum:', + 'windows_linux' => '(Windows/Linux)', + 'mac' => '(Mac)', + 'description' => 'Lýsing', +]; diff --git a/lang/is/entities.php b/lang/is/entities.php new file mode 100644 index 00000000000..f4ea3ac6dc5 --- /dev/null +++ b/lang/is/entities.php @@ -0,0 +1,460 @@ + 'Nýlega búið til', + 'recently_created_pages' => 'Nýlega stofnaðar síður', + 'recently_updated_pages' => 'Nýlega uppfærðar síður', + 'recently_created_chapters' => 'Nýlega stofnaðir kaflar', + 'recently_created_books' => 'Nýlega stofnaðar bækur', + 'recently_created_shelves' => 'Nýlega stofnaðar hillur', + 'recently_update' => 'Nýlega uppfært', + 'recently_viewed' => 'Nýlega skoðað', + 'recent_activity' => 'Nýleg virkni', + 'create_now' => 'Búðu til eina núna', + 'revisions' => 'Útgáfur', + 'meta_revision' => 'Úgáfa #:revisionCount', + 'meta_created' => 'Búið til :timeLength', + 'meta_created_name' => 'Búið til :timeLength af :user', + 'meta_updated' => 'Uppfært :timeLength', + 'meta_updated_name' => 'Uppfært :timeLength af :user', + 'meta_owned_name' => 'Eigandi :user', + 'meta_reference_count' => 'Vitnað í af :count item|Vitnað í af :count items', + 'entity_select' => 'Entity Val', + 'entity_select_lack_permission' => 'Þú hefur ekki nauðsynlegar aðgangsheimildir til að velja þetta', + 'images' => 'Myndir', + 'my_recent_drafts' => 'Nýlegur drögin mín', + 'my_recently_viewed' => 'Síðast skoða af mér', + 'my_most_viewed_favourites' => 'Mest skoðuðu eftirlætin', + 'my_favourites' => 'Eftirlætin mín', + 'no_pages_viewed' => 'Þú hefur ekki skoðað neinar síður', + 'no_pages_recently_created' => 'Engar síður hafa verið búnar til nýlega', + 'no_pages_recently_updated' => 'Engar síður hafa verið uppfærðar nýlega', + 'export' => 'Flytja út', + 'export_html' => 'Innifalin vefskrá', + 'export_pdf' => 'PDF skrá', + 'export_text' => 'Venjuleg textaskrá', + 'export_md' => 'Markdown skrá', + 'export_zip' => 'ZIP skrá', + 'default_template' => 'Sjálfgefið síðusnið', + 'default_template_explain' => 'Veldu síðusnið sem verður sjálgefið snið fyrir allar stofnaðar síður innan þessa hluta. Hafðu í huga að þetta verður aðeins notað ef sá sem stofnar síður er með heimild á þetta snið.', + 'default_template_select' => 'Veldu sniðsíðu', + 'import' => 'Flytja inn', + 'import_validate' => 'Staðfesta innflutning', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Veldu ZIP skrá til að hlaða upp', + 'import_zip_validation_errors' => 'Greindar voru villur við að staðreyna uppgefina ZIP skrá:', + 'import_pending' => 'Innflutningur í bið', + 'import_pending_none' => 'Ekkert hefur verið flutt inn.', + 'import_continue' => 'Halda áfram að flytja inn', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Nánari lýsing á innflutningi', + 'import_run' => 'Keyra innflutning', + 'import_size' => ':size Stærð ZIP skrár', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Upphlaðið af', + 'import_location' => 'Staðsetning innflutnings', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum innflutningi?', + 'import_delete_desc' => 'Þetta mun eyða innsendri ZIP skrá, þessa aðgerð er ekki hægt að afturkalla.', + 'import_errors' => 'Villur í innflutningi', + 'import_errors_desc' => 'Eftirfarandi villur komu upp við innflutning:', + + // Permissions and restrictions + 'permissions' => 'Réttindi', + 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', + 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', + 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', + 'permissions_save' => 'Vista réttindi', + 'permissions_owner' => 'Eigandi', + 'permissions_role_everyone_else' => 'Allir aðrir', + 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', + 'permissions_role_override' => 'Yfirskrifa réttindi fyrir hlutverk', + 'permissions_inherit_defaults' => 'Erfa sjálfgefið', + + // Search + 'search_results' => 'Leitarniðurstöður', + 'search_total_results_found' => ':count result found|:count total results found', + 'search_clear' => 'Hreinsa leit', + 'search_no_pages' => 'Engar síður passa við þessa leit', + 'search_for_term' => 'Leita að :term', + 'search_more' => 'Fleiri niðurstöður', + 'search_advanced' => 'Ítarleg leit', + 'search_terms' => 'Leitarorð', + 'search_content_type' => 'Efnistegund', + 'search_exact_matches' => 'Nákvæm samsvörun', + 'search_tags' => 'Leita í örmerkjum', + 'search_options' => 'Valkostir', + 'search_viewed_by_me' => 'Skoðað af mér', + 'search_not_viewed_by_me' => 'Ekki skoðað af mér', + 'search_permissions_set' => 'Réttindi stillt', + 'search_created_by_me' => 'Búið til af mér', + 'search_updated_by_me' => 'Uppfært af mér', + 'search_owned_by_me' => 'Í minni eigu', + 'search_date_options' => 'Dagsetningarval', + 'search_updated_before' => 'Uppfært fyrir', + 'search_updated_after' => 'Uppfært eftir', + 'search_created_before' => 'Búið til fyrir', + 'search_created_after' => 'Búið til eftir', + 'search_set_date' => 'Dagsetning', + 'search_update' => 'Uppfæra leit', + + // Shelves + 'shelf' => 'Hilla', + 'shelves' => 'Hillur', + 'x_shelves' => ':count Hilla|:count Hillur', + 'shelves_empty' => 'Engar hillur hafa verið búnar til', + 'shelves_create' => 'Búa til hillu', + 'shelves_popular' => 'Vinsælar hillur', + 'shelves_new' => 'Nýjar hillur', + 'shelves_new_action' => 'Ný hilla', + 'shelves_popular_empty' => 'Vinsælustu hillurnar munu birtast hér.', + 'shelves_new_empty' => 'Nýjustu hillurnar munu birtast hér.', + 'shelves_save' => 'Vista hillu', + 'shelves_books' => 'Bækur í þessari hillu', + 'shelves_add_books' => 'Bæta við bókum í þessa hillu', + 'shelves_drag_books' => 'Dragðu bækur hér undir til að bæta þeim í þessa hillu', + 'shelves_empty_contents' => 'Þessi hilla hefur engar bækur', + 'shelves_edit_and_assign' => 'Breyttu hillu til að setja inn bækur', + 'shelves_edit_named' => 'Breyta hillu :name', + 'shelves_edit' => 'Breyta hillu', + 'shelves_delete' => 'Eyða hillu', + 'shelves_delete_named' => 'Eyða hillu :name', + 'shelves_delete_explain' => "Þetta mun eyða hillunni ':name'. Bókum í þessari hillu verður ekki eytt.", + 'shelves_delete_confirmation' => 'Ertu viss um að þú viljir eyða hillunni?', + 'shelves_permissions' => 'Stillingar á réttindum á hillu', + 'shelves_permissions_updated' => 'Réttindi á hillu uppfærð', + 'shelves_permissions_active' => 'Réttindi á hillu virk', + 'shelves_permissions_cascade_warning' => 'Réttindi á hillum yfirfærast ekki á bækurnar sem í hillunni eru. Þetta er vegna þess að ein bók getur verið í mörgum hillum. Réttindi geta hinsvegar verið afrituð niður á bækur með því að nota valmöguleikann hér fyrir neðan.', + 'shelves_permissions_create' => 'Réttindi til að búa til hillu eru aðeins notuð til að afrita réttindi á undirliggjandi bækur með því að nota aðgerðina hér fyrir neðan. Þau stjórna ekki hvort hægt sé að búa til bækur.', + 'shelves_copy_permissions_to_books' => 'Afrita réttindi á bækur', + 'shelves_copy_permissions' => 'Afrita réttindi', + 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', + 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + + // Books + 'book' => 'Bók', + 'books' => 'Bækur', + 'x_books' => ':count Bók:count Bækur', + 'books_empty' => 'Engar bækur hafa verið búnar til', + 'books_popular' => 'Vinsælar bækur', + 'books_recent' => 'Nýlegar bækur', + 'books_new' => 'Nýjar bækur', + 'books_new_action' => 'Ný bók', + 'books_popular_empty' => 'Vinsælustu bækurnar munu birtast hér.', + 'books_new_empty' => 'The most recently created books will appear here.', + 'books_create' => 'Búa til nýja bók', + 'books_delete' => 'Eyða bók', + 'books_delete_named' => 'Eyða bók :bookName', + 'books_delete_explain' => 'Þetta mun eyða bók með nafninu \':bookName\'. Allar síður og allir kaflar verða fjarlægðir og eytt.', + 'books_delete_confirmation' => 'Ertu viss um að þú viljir eyða þessari bók?', + 'books_edit' => 'Breyta bók', + 'books_edit_named' => 'Breyta bók :bookName', + 'books_form_book_name' => 'Nafn bókar', + 'books_save' => 'Vista bók', + 'books_permissions' => 'Réttindastillingar bókar', + 'books_permissions_updated' => 'Réttindastillingar bókar uppfærðar', + 'books_empty_contents' => 'Engar síður eða kaflar hafa verið búin til fyrir þessa bók.', + 'books_empty_create_page' => 'Búa til nýja síðu', + 'books_empty_sort_current_book' => 'Raða núverandi bók', + 'books_empty_add_chapter' => 'Bæta við kafla', + 'books_permissions_active' => 'Réttindi bókar virk', + 'books_search_this' => 'Leita í þessari bók', + 'books_navigation' => 'Leiðartré bókar', + 'books_sort' => 'Raða innihaldi bókar', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_named' => 'Raða bók :bookName', + 'books_sort_name' => 'Raða eftir nafni', + 'books_sort_created' => 'Raða eftir skráningar dagsetningu', + 'books_sort_updated' => 'Raða eftir upphleðslu dagsetningu', + 'books_sort_chapters_first' => 'Kaflar fyrst', + 'books_sort_chapters_last' => 'Kaflar síðast', + 'books_sort_show_other' => 'Sýna aðrar bækur', + 'books_sort_save' => 'Vista nýja röð', + 'books_sort_show_other_desc' => 'Bæta við öðrum bókum til að bæta þeim við röðunina.', + 'books_sort_move_up' => 'Færa upp', + 'books_sort_move_down' => 'Færa niður', + 'books_sort_move_prev_book' => 'Færa í fyrri bók', + 'books_sort_move_next_book' => 'Færa í næstu bók', + 'books_sort_move_prev_chapter' => 'Færa inn í fyrri kafla', + 'books_sort_move_next_chapter' => 'Færa inn í næsta kafla', + 'books_sort_move_book_start' => 'Færa til byrjunar bókar', + 'books_sort_move_book_end' => 'Færa í enda bókar', + 'books_sort_move_before_chapter' => 'Færa í byrjun kafla', + 'books_sort_move_after_chapter' => 'Færa í lok kafla', + 'books_copy' => 'Afrita bók', + 'books_copy_success' => 'Tókst að afrita bók', + + // Chapters + 'chapter' => 'Kafli', + 'chapters' => 'Kaflar', + 'x_chapters' => ':count Kafli|:count Kaflar', + 'chapters_popular' => 'Vinsælir kaflar', + 'chapters_new' => 'Nýr kafli', + 'chapters_create' => 'Búa til nýjan kafla', + 'chapters_delete' => 'Eyða kafla', + 'chapters_delete_named' => 'Eyða kafla :chapterName', + 'chapters_delete_explain' => 'Þetta mun eyða kafla með nafninu \':chapterName\'. Öllum blaðsíðum í þessum kafla verður einnig eytt.', + 'chapters_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum kafla?', + 'chapters_edit' => 'Breyta kafla', + 'chapters_edit_named' => 'Breyta kafla chapterName', + 'chapters_save' => 'Vista kafla', + 'chapters_move' => 'Færa kafla', + 'chapters_move_named' => 'Færa kalfa :chapterName', + 'chapters_copy' => 'Afrita kafla', + 'chapters_copy_success' => 'Tókst að afrita kafla', + 'chapters_permissions' => 'Réttindi á kafla', + 'chapters_empty' => 'Engar síður eru eins og er í þessum kafla.', + 'chapters_permissions_active' => 'Réttindi á kafla eru virk', + 'chapters_permissions_success' => 'Réttindi á kafla hafa verið uppfærð', + 'chapters_search_this' => 'Leita í þessum kafla', + 'chapter_sort_book' => 'Raða bók', + + // Pages + 'page' => 'Síða', + 'pages' => 'Síður', + 'x_pages' => ':count Síða|:count Síður', + 'pages_popular' => 'Vinsælar síður', + 'pages_new' => 'Ný síða', + 'pages_attachments' => 'Viðhengi', + 'pages_navigation' => 'Síðuráp', + 'pages_delete' => 'Eyða síðu', + 'pages_delete_named' => 'Eyða síðu :pageName', + 'pages_delete_draft_named' => 'Eyða drögum :pageName', + 'pages_delete_draft' => 'Eyða uppkasti', + 'pages_delete_success' => 'Síðu eytt', + 'pages_delete_draft_success' => 'Uppkasti að síðu eytt', + 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_confirm' => 'Ertu viss um að þú viljir eyða þessari síðu?', + 'pages_delete_draft_confirm' => 'Ertu viss um að þú viljir eyða þessu uppkasti að síðu?', + 'pages_editing_named' => 'Breyta síðu :pageName', + 'pages_edit_draft_options' => 'Valkostir uppkasts', + 'pages_edit_save_draft' => 'Vista uppkast', + 'pages_edit_draft' => 'Breyta drögum', + 'pages_editing_draft' => 'Breyta uppkasti', + 'pages_editing_page' => 'Breyta síðu', + 'pages_edit_draft_save_at' => 'Vista uppkast ', + 'pages_edit_delete_draft' => 'Eyða uppkasti', + 'pages_edit_delete_draft_confirm' => 'Ertu viss um að þú viljir eyða uppkasti síðu? Allar breytingar sem gerðar hafa verið frá síðustu vistun á síðunni munu tapast.', + 'pages_edit_discard_draft' => 'Henda uppkasti', + 'pages_edit_switch_to_markdown' => 'Færa þig yfir í Markdown ritil', + 'pages_edit_switch_to_markdown_clean' => '(Hreinsa innihald)', + 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', + 'pages_edit_switch_to_wysiwyg' => 'Skipta yfir í WYSIWYG ritil', + 'pages_edit_switch_to_new_wysiwyg' => 'Skipta yfir í nýja WYSIWYG ritilinn', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Í alfa prófun)', + 'pages_edit_set_changelog' => 'Stilla breytingarskrá', + 'pages_edit_enter_changelog_desc' => 'Skrifaðu stutta lýsingu á breytingunum sem þú gerðir', + 'pages_edit_enter_changelog' => 'Færa í breytingaskrá', + 'pages_editor_switch_title' => 'Skipta um ritil', + 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', + 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', + 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', + 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', + 'pages_save' => 'Vista síðu', + 'pages_title' => 'Titill síðu', + 'pages_name' => 'Nafn síðu', + 'pages_md_editor' => 'Ritill', + 'pages_md_preview' => 'Forskoðun', + 'pages_md_insert_image' => 'Setja inn mynd', + 'pages_md_insert_link' => 'Insert Entity Link', + 'pages_md_insert_drawing' => 'Setja inn teikningu', + 'pages_md_show_preview' => 'Sýna forskoðun', + 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_drawing_unsaved' => 'Unsaved Drawing Found', + 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', + 'pages_not_in_chapter' => 'Síðan tilheyrir engum kafla', + 'pages_move' => 'Færa síðu', + 'pages_copy' => 'Afrita síðu', + 'pages_copy_desination' => 'Áfangastaður afritunar', + 'pages_copy_success' => 'Tókst að afrita síðu', + 'pages_permissions' => 'Réttindi síðu', + 'pages_permissions_success' => 'Réttindi síðu uppfærð', + 'pages_revision' => 'Útgáfa', + 'pages_revisions' => 'Útgáfur síðu', + 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', + 'pages_revisions_named' => 'Page Revisions for :pageName', + 'pages_revision_named' => 'Page Revision for :pageName', + 'pages_revision_restored_from' => 'Restored from #:id; :summary', + 'pages_revisions_created_by' => 'Búið til af', + 'pages_revisions_date' => 'Útgáfu dagsetning', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'Útgáfunúmer', + 'pages_revisions_numbered' => 'Útgáfu #:id', + 'pages_revisions_numbered_changes' => 'Útgáfu #:id breytingar', + 'pages_revisions_editor' => 'Gerð ritils', + 'pages_revisions_changelog' => 'Breytingaskrá', + 'pages_revisions_changes' => 'Breytingar', + 'pages_revisions_current' => 'Núverandi útgáfa', + 'pages_revisions_preview' => 'Forskoðun', + 'pages_revisions_restore' => 'Endurheimta', + 'pages_revisions_none' => 'Þessi síða hefur engar útgáfur', + 'pages_copy_link' => 'Afrita hlekk', + 'pages_edit_content_link' => 'Hoppa í staðsetningu í ritli', + 'pages_pointer_enter_mode' => 'Enter section select mode', + 'pages_pointer_label' => 'Page Section Options', + 'pages_pointer_permalink' => 'Page Section Permalink', + 'pages_pointer_include_tag' => 'Page Section Include Tag', + 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', + 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_permissions_active' => 'Page Permissions Active', + 'pages_initial_revision' => 'Fyrsta birting', + 'pages_references_update_revision' => 'System auto-update of internal links', + 'pages_initial_name' => 'Ný síða', + 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', + 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', + 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count users have started editing this page', + 'start_b' => ':userName has started editing this page', + 'time_a' => 'since the page was last updated', + 'time_b' => 'in the last :minCount minutes', + 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + ], + 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', + 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', + 'pages_specific' => 'Tilgreind síða', + 'pages_is_template' => 'Forsnið síðu', + + // Editor Sidebar + 'toggle_sidebar' => 'Toggle Sidebar', + 'page_tags' => 'Page Tags', + 'chapter_tags' => 'Chapter Tags', + 'book_tags' => 'Book Tags', + 'shelf_tags' => 'Shelf Tags', + 'tag' => 'Tag', + 'tags' => 'Tags', + 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', + 'tag_name' => 'Tag Name', + 'tag_value' => 'Tag Value (Optional)', + 'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.", + 'tags_add' => 'Add another tag', + 'tags_remove' => 'Remove this tag', + 'tags_usages' => 'Total tag usages', + 'tags_assigned_pages' => 'Assigned to Pages', + 'tags_assigned_chapters' => 'Assigned to Chapters', + 'tags_assigned_books' => 'Assigned to Books', + 'tags_assigned_shelves' => 'Assigned to Shelves', + 'tags_x_unique_values' => ':count unique values', + 'tags_all_values' => 'All values', + 'tags_view_tags' => 'View Tags', + 'tags_view_existing_tags' => 'View existing tags', + 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', + 'attachments' => 'Attachments', + 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', + 'attachments_explain_instant_save' => 'Changes here are saved instantly.', + 'attachments_upload' => 'Upload File', + 'attachments_link' => 'Attach Link', + 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', + 'attachments_set_link' => 'Set Link', + 'attachments_delete' => 'Are you sure you want to delete this attachment?', + 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_no_files' => 'No files have been uploaded', + 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', + 'attachments_link_name' => 'Link Name', + 'attachment_link' => 'Attachment link', + 'attachments_link_url' => 'Link to file', + 'attachments_link_url_hint' => 'Url of site or file', + 'attach' => 'Attach', + 'attachments_insert_link' => 'Add Attachment Link to Page', + 'attachments_edit_file' => 'Edit File', + 'attachments_edit_file_name' => 'File Name', + 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', + 'attachments_order_updated' => 'Attachment order updated', + 'attachments_updated_success' => 'Attachment details updated', + 'attachments_deleted' => 'Attachment deleted', + 'attachments_file_uploaded' => 'File successfully uploaded', + 'attachments_file_updated' => 'File successfully updated', + 'attachments_link_attached' => 'Link successfully attached to page', + 'templates' => 'Templates', + 'templates_set_as_template' => 'Page is a template', + 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', + 'templates_replace_content' => 'Replace page content', + 'templates_append_content' => 'Append to page content', + 'templates_prepend_content' => 'Prepend to page content', + + // Profile View + 'profile_user_for_x' => 'User for :time', + 'profile_created_content' => 'Created Content', + 'profile_not_created_pages' => ':userName has not created any pages', + 'profile_not_created_chapters' => ':userName has not created any chapters', + 'profile_not_created_books' => ':userName has not created any books', + 'profile_not_created_shelves' => ':userName has not created any shelves', + + // Comments + 'comment' => 'Comment', + 'comments' => 'Comments', + 'comment_add' => 'Add Comment', + 'comment_placeholder' => 'Leave a comment here', + 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', + 'comment_save' => 'Save Comment', + 'comment_new' => 'New Comment', + 'comment_created' => 'commented :createDiff', + 'comment_updated' => 'Updated :updateDiff by :username', + 'comment_updated_indicator' => 'Updated', + 'comment_deleted_success' => 'Comment deleted', + 'comment_created_success' => 'Comment added', + 'comment_updated_success' => 'Comment updated', + 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', + 'comment_in_reply_to' => 'In reply to :commentId', + 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', + + // Revision + 'revision_delete_confirm' => 'Are you sure you want to delete this revision?', + 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', + 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + + // Conversions + 'convert_to_shelf' => 'Convert to Shelf', + 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', + 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', + 'convert_book' => 'Convert Book', + 'convert_book_confirm' => 'Are you sure you want to convert this book?', + 'convert_undo_warning' => 'This cannot be as easily undone.', + 'convert_to_book' => 'Convert to Book', + 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', + 'convert_chapter' => 'Convert Chapter', + 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + + // References + 'references' => 'References', + 'references_none' => 'There are no tracked references to this item.', + 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + + // Watch Options + 'watch' => 'Watch', + 'watch_title_default' => 'Default Preferences', + 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_title_ignore' => 'Ignore', + 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_title_new' => 'New Pages', + 'watch_desc_new' => 'Notify when any new page is created within this item.', + 'watch_title_updates' => 'All Page Updates', + 'watch_desc_updates' => 'Notify upon all new pages and page changes.', + 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', + 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', + 'watch_change_default' => 'Change default notification preferences', + 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_detail_new' => 'Watching for new pages', + 'watch_detail_updates' => 'Watching new pages and updates', + 'watch_detail_comments' => 'Watching new pages, updates & comments', + 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', + 'watch_detail_parent_chapter' => 'Watching via parent chapter', + 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', +]; diff --git a/lang/is/errors.php b/lang/is/errors.php new file mode 100644 index 00000000000..50e30a8c57a --- /dev/null +++ b/lang/is/errors.php @@ -0,0 +1,133 @@ + 'Þú hefur ekki heimild til að skoða þessa síðu.', + 'permissionJson' => 'Þú hefur ekki heimild til að framkvæma þessa aðgerð.', + + // Auth + 'error_user_exists_different_creds' => 'Notandi með netfangið :email er nú þegar til.', + 'auth_pre_register_theme_prevention' => 'Ekki var hægt að búa til aðgang með þessum upplýsingum', + 'email_already_confirmed' => 'Netfang hefur þegar verið staðfest. Prófaðu að skrá þig inn.', + 'email_confirmation_invalid' => 'Þessi staðfestingar tóki er ekki gildur eða hefur þegar verið notaður. Reyndu að skrá þig aftur.', + 'email_confirmation_expired' => 'Staðfestingar tóki hefur runnið út. Nýr staðfestinga tölvupóstur hefur verið sendur.', + 'email_confirmation_awaiting' => 'Eftir á að staðfest þetta netfang', + 'ldap_fail_anonymous' => 'LDAP auðkenning virkaði ekki', + 'ldap_fail_authed' => 'LDAP auðkenning virkaði ekki með að nota uppgefið dn & password', + 'ldap_extension_not_installed' => 'LDAP PHP viðbót ekki uppsett', + 'ldap_cannot_connect' => 'Næ ekki að tengjast Ldap þjóni. Fyrsta tenging mistókst', + 'saml_already_logged_in' => 'Þegar innskráður', + 'saml_no_email_address' => 'Fann ekki netfang fyrir þennan notanda í auðkenningar þjónustu', + 'saml_invalid_response_id' => 'Beiðnin frá ytri auðkenningaraðila er óþekkt af kerfinu. Að fara tilbaka eftir innskráningu gæti valdið þessu vandamáli.', + 'saml_fail_authed' => 'Innskráning sem notaði :system tókst ekki. Kerfið gaf ekki út gilda auðkenningu', + 'oidc_already_logged_in' => 'Þegar skráður inn', + 'oidc_no_email_address' => 'Fann ekki netfang fyrir þennan notanda í ytri auðkenningar þjónustu', + 'oidc_fail_authed' => 'Innskráning sem notaði :system tókst ekki. Kerfið gaf ekki út gilda auðkenningu', + 'social_no_action_defined' => 'Engin aðgerð skilgreind', + 'social_login_bad_response' => "Villa kom upp við auðkenninga á :socialAccount login", + 'social_account_in_use' => 'Þessi :socialAccount er þegar í notkun. Reyndu að skrá þig inn með :socialAccount.', + 'social_account_email_in_use' => 'Netfangið :email er þegar í notkun. Ef þú ert nú þegar með aðgang getur þú tengt :socialAccount við hann í prófíl stillingum.', + 'social_account_existing' => 'Þessi :socialAccount er nú þegar tengdur við prófílinn þinn.', + 'social_account_already_used_existing' => 'Þessi :socialAccount reikningur er nú þegar í notkun hjá öðrum notanda.', + 'social_account_not_used' => 'Þessi :socialAccount er ekki tengdur neinum notanda. Þú getur tengt hann við þig í prófíl stillingar. ', + 'social_account_register_instructions' => 'Ef þú ert ekki nú þegar með aðgang, getur þú skrá þig með :socialAccount', + 'social_driver_not_found' => 'Samfélagsviðbót fannst ekki', + 'social_driver_not_configured' => 'Þínar :socialAccount er ekki rétt stilltar.', + 'invite_token_expired' => 'Þess boðshlekkur er útrunninn, Prófa að endurstilla lykilorðið þitt.', + 'login_user_not_found' => 'Enginn notandi fannst fyrir þessa aðgerð.', + + // System + 'path_not_writable' => 'Ekki var hægt að hlaða upp á slóðinni :filePath. Vertu viss um að slóðin sé skrifanleg.', + 'cannot_get_image_from_url' => 'Get ekki sótt mynd frá :url', + 'cannot_create_thumbs' => 'Netþjónninn getur ekki búið til smámyndir. Vertu viss um að þú hafir GD PHP viðbótina uppsetta.', + 'server_upload_limit' => 'Þessi netþjónn leyfir ekki uphal af þessari stærð. Prófaðu minni skrá.', + 'server_post_limit' => 'Netþjóninn getur ekki tekið á móti þessu magni gagna. Reyndu aftur með færri eða smærri gögnum.', + 'uploaded' => 'Þessi netþjónn leyfir ekki uphal af þessari stærð. Prófaðu minni skrá.', + + // Drawing & Images + 'image_upload_error' => 'Villa kom upp við að hlaða upp mynd', + 'image_upload_type_error' => 'Gerð myndar er ógild', + 'image_upload_replace_type' => 'Myndin sem á að nota við útskipti þarf að vera sömu gerðar', + 'image_upload_memory_limit' => 'Ekki var hægt að taka við upphali og eða búa til smámyndir þar sem ekki eru auðlindir til staðar.', + 'image_thumbnail_memory_limit' => 'Ekki var hægt að búa til nokkrar stærðir myndarinnar vegna skorts á auðlindum.', + 'image_gallery_thumbnail_memory_limit' => 'Ekki var hægt að búa til smámyndayfirlit vegna skorts á auðlindum.', + 'drawing_data_not_found' => 'Ekki tóks að hlaða inn teikningagögnum. Það gæti vantað skránna eða að þú hafir ekki réttindi að henni.', + + // Attachments + 'attachment_not_found' => 'Viðhengi fannst ekki', + 'attachment_upload_error' => 'Það kom upp villa við að hlaða upp viðhenginu', + + // Pages + 'page_draft_autosave_fail' => 'Gat ekki vistað uppkast. Gættu að þú hafir tengingu við internetið áður en þú vistar þessa síðu', + 'page_draft_delete_fail' => 'Ekki var hægt að eyða uppkasti og sækja fyrra innihald síðunar', + 'page_custom_home_deletion' => 'Ekki er hægt að eyða síðu á meðan hún er valin sem sjálfgefin upphafssíða', + + // Entities + 'entity_not_found' => 'Entity fannst ekki', + 'bookshelf_not_found' => 'Hilla fannst ekki', + 'book_not_found' => 'Bók fannst ekki', + 'page_not_found' => 'Síða fannst ekki', + 'chapter_not_found' => 'Kafli fannst ekki', + 'selected_book_not_found' => 'Valin bók fannst ekki', + 'selected_book_chapter_not_found' => 'Valin bók eða kafli fannst ekki', + 'guests_cannot_save_drafts' => 'Gestir geta ekki vistað drög', + + // Users + 'users_cannot_delete_only_admin' => 'Þú getur ekki eytt, bara kerfisstjóri', + 'users_cannot_delete_guest' => 'Þú getur ekki eytt gesta notanda', + 'users_could_not_send_invite' => 'Gat ekki stofnað notanda þar sem ekki tókst að senda staðfestingar tölvupóst', + + // Roles + 'role_cannot_be_edited' => 'Ekki er hægt að breyta þessu hlutverki', + 'role_system_cannot_be_deleted' => 'Þetta er kerfis hlutverk og því ekki hægt að eyða því', + 'role_registration_default_cannot_delete' => 'Ekki er hægt að eyða þessu hlutverki þar sem það er sjálfgefið kerfishlutverk við skráningu', + 'role_cannot_remove_only_admin' => 'Þessi notandi er sá eini sem er með kerfisstjóra hlutverk. Bættu hlutverkinu við annann notanda áður en þú reynir að fjarlægja það héðan.', + + // Comments + 'comment_list' => 'Villa kom upp við að sækja athugasemdir.', + 'cannot_add_comment_to_draft' => 'Þú getur ekki sett athugasemdir við drög.', + 'comment_add' => 'Villa kom upp við að bæta við eða breyta athugasemdinni.', + 'comment_delete' => 'Villa kom upp við að eyða athugasemdinni.', + 'empty_comment' => 'Get ekki bætt við tómri athugasemd.', + + // Error pages + '404_page_not_found' => 'Síða fannst ekki', + 'sorry_page_not_found' => 'Síðan sem þú varst að leita að fannst því miður ekki.', + 'sorry_page_not_found_permission_warning' => 'Ef þú átt von á að þessi síða sé til gæti verið að þú hafir ekki aðgang að henni.', + 'image_not_found' => 'Fann ekki mynd', + 'image_not_found_subtitle' => 'Myndin sem þú varst að leita að fannst því miður ekki.', + 'image_not_found_details' => 'Ef þú heldur að þessi mynda hafi verið til, þá gæti henni hafa verið eytt.', + 'return_home' => 'Fara á forsíðu', + 'error_occurred' => 'Það kom upp villa', + 'app_down' => ':appName er niðri í augnablikinu', + 'back_soon' => 'Verð komin upp aftur fljótlega.', + + // Import + 'import_zip_cant_read' => 'Gat ekki lesið ZIP skrá.', + 'import_zip_cant_decode_data' => 'Fann ekki ZIP data.json innihald.', + 'import_zip_no_data' => 'ZIP skráin inniheldur ekkert efni.', + 'import_validation_failed' => 'ZIP skráin stóðst ekki staðfestingu og skilaði villu:', + 'import_zip_failed_notification' => 'Gat ekki lesið inn ZIP skrá.', + 'import_perms_books' => 'Þú hefur ekki heimild til að búa til bækur.', + 'import_perms_chapters' => 'Þú hefur ekki heimild til að búa til kafla.', + 'import_perms_pages' => 'Þú hefur ekki heimild til að búa til síður.', + 'import_perms_images' => 'Þú hefur ekki heimild til að búa til myndir.', + 'import_perms_attachments' => 'Þú hefur ekki heimild til að búa til viðhengi.', + + // API errors + 'api_no_authorization_found' => 'Engin auðkenningar tóki fannst í aðgerðinni', + 'api_bad_authorization_format' => 'Auðkenningar tóki fannst með aðgerðinni en snið hans er rangt', + 'api_user_token_not_found' => 'Engin API tóki fannst á móti þessum auðkenningar tóka', + 'api_incorrect_token_secret' => 'Leyndarmálið sem gefið var upp fyrir API tókann er rangt', + 'api_user_no_api_permission' => 'Eigandi API tókans hefur ekki heimild til að gera API köll', + 'api_user_token_expired' => 'Auðkenningar tókin er útrunninn', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Villa kom upp viðað reyna senda prufu tölvupóst:', + + // HTTP errors + 'http_ssr_url_no_match' => 'Þetta vistfang stemmir ekki við leyfða SSR biðlara', +]; diff --git a/lang/is/notifications.php b/lang/is/notifications.php new file mode 100644 index 00000000000..b4fe01ad748 --- /dev/null +++ b/lang/is/notifications.php @@ -0,0 +1,27 @@ + 'Ný athugasemd á síðu :pageName', + 'new_comment_intro' => 'Notandi hefur sett inn athugasemd á síðu á :appName:', + 'new_page_subject' => 'Ný síða á: :pageName', + 'new_page_intro' => 'Ný síða hefur verið búin til á :appName:', + 'updated_page_subject' => 'Uppfærð síða á: :pageName', + 'updated_page_intro' => 'Síða hefur verið uppfærð á :appName:', + 'updated_page_debounce' => 'Til að fyrirbyggja fjöldatilkynningar verður þér ekki sendar tilkynningar í smá stund um uppfærslu á þessari síðu frá sama höfundi.', + + 'detail_page_name' => 'Síðunafn:', + 'detail_page_path' => 'Síðuslóð:', + 'detail_commenter' => 'Notandi:', + 'detail_comment' => 'Athugasemd:', + 'detail_created_by' => 'Búið til af:', + 'detail_updated_by' => 'Uppfært af:', + + 'action_view_comment' => 'Skoða athugasemd', + 'action_view_page' => 'Skoða síðu', + + 'footer_reason' => 'Þessi tilkynning var send til þín vegna :link nær yfir þessa virkni á þessum hlut.', + 'footer_reason_link' => 'stillingar á tilkynningum til þín', +]; diff --git a/lang/is/pagination.php b/lang/is/pagination.php new file mode 100644 index 00000000000..3eca2f26e4a --- /dev/null +++ b/lang/is/pagination.php @@ -0,0 +1,12 @@ + '« Fyrri', + 'next' => 'Næsta»', + +]; diff --git a/lang/is/passwords.php b/lang/is/passwords.php new file mode 100644 index 00000000000..b8c093b0388 --- /dev/null +++ b/lang/is/passwords.php @@ -0,0 +1,15 @@ + 'Lykilorð verður að vera að lágmarki 8 stafir og stemma saman.', + 'user' => "Enginn notandi finnst með þetta netfang.", + 'token' => 'Tókinn er ógildur fyrir þetta netfang.', + 'sent' => 'Þér hefur verið sendur hlekkur í tölvupósti!', + 'reset' => 'Lykilorðinu hefur verið breytt!', + +]; diff --git a/lang/is/preferences.php b/lang/is/preferences.php new file mode 100644 index 00000000000..b7ebc2df00a --- /dev/null +++ b/lang/is/preferences.php @@ -0,0 +1,51 @@ + 'Minn aðgangur', + + 'shortcuts' => 'Flýtileiðir', + 'shortcuts_interface' => 'UI, stillingar flýtileiða', + 'shortcuts_toggle_desc' => 'Hér getur þú virkjað eða óvirkjað flýtilykla, notað fyrir leiðarstýringu og aðgerðir.', + 'shortcuts_customize_desc' => 'Þú getur stillt alla flýtilyklana hér að neðan. Þú ýtir þá þann flýtilykil sem þú vilt nota eftir að þú hefur valið innsláttarleið.', + 'shortcuts_toggle_label' => 'Flýtilyklar virkjaðir', + 'shortcuts_section_navigation' => 'Leiðarstýring', + 'shortcuts_section_actions' => 'Algengar aðgerðir', + 'shortcuts_save' => 'Vista flýtilykla', + 'shortcuts_overlay_desc' => 'Ath: þegar flýtilyklar eru virkjaðir er hægt að fá aðstoð með því að ýta á "?" sem mun yfirstrika þær flýtileiðir sem í boði eru.', + 'shortcuts_update_success' => 'Stillingar flýtilykla hafa verið uppfærðar!', + 'shortcuts_overview_desc' => 'Stjórna þeim flýtilyklum sem í boði eru.', + + 'notifications' => 'Stillingar tilkynninga', + 'notifications_desc' => 'Stýrðu þeim tölvupóst tilkynningum sem þú færð þegar ákveðnar aðgerðir eru gerðar af kerfinu.', + 'notifications_opt_own_page_changes' => 'Láta vita þegar gerðar eru breytingar á síðum sem ég á', + 'notifications_opt_own_page_comments' => 'Láta vita þegar gerðar eru athugasmedir við síður sem ég á', + 'notifications_opt_comment_replies' => 'Láta vita þegar athugasemdum mínum er svarað', + 'notifications_save' => 'Vista stillingar', + 'notifications_update_success' => 'Stillingar á tilkynningum hafa verið uppfærðar!', + 'notifications_watched' => 'Watched & Ignored Items', + 'notifications_watched_desc' => 'Fyrir neðan eru hlutir sem hafa sérsniðna eftirfylgni stillta. Til að uppfæra stillingarnar fyrir þessa hluti skaltu skoða hann og og finna skoðastillinguna í hliðarstikunni.', + + 'auth' => 'Aðgangur og öryggi', + 'auth_change_password' => 'Breyta lykilorði', + 'auth_change_password_desc' => 'Breyta lykilorðinu sem þú notar til að skrá þig inn í hugbúnaðinn. Lykilorðið verður að vera a. m. k 8 stafa langt.', + 'auth_change_password_success' => 'Lykilorði hefur verið breytt!', + + 'profile' => 'Upplýsingar um prófíl', + 'profile_desc' => 'Stýra þeim upplýsingum um þig sem aðrir notendur sjá.', + 'profile_view_public' => 'Skoða almennan prófíl', + 'profile_name_desc' => 'Stilla þitt notendanafn sem er sýnlegt öðrum notendum.', + 'profile_email_desc' => 'Þetta netfang verður notað fyrir tilkynningar og aðgang að kerfinu hafir þú valið svo.', + 'profile_email_no_permission' => 'Þú hefur ekki heimild til að breyta netfanginu þinu. Ef þú vilt láta breyta því verður þú að hafa samband við kerfisstjóra.', + 'profile_avatar_desc' => 'Veldu mynd til að sýna öðrum notendum. Helst þarf þessi mynd að vera ferköntuð og um það bil 256px bæði á breidd og hæð.', + 'profile_admin_options' => 'Stillingar kerfisstjóra', + 'profile_admin_options_desc' => 'Viðbótar kerfistjóra stillingar, til dæmis stjórnun á hlutverkum sem finna á í "Stillingar > Notendur svæði hugbúnaðarins.', + + 'delete_account' => 'Eyða aðgangi', + 'delete_my_account' => 'Eyða reikningi mínum', + 'delete_my_account_desc' => 'Þetta mun eyða þínum aðgangi að hugbúnaðinum. Þú munt ekki geta enduheimt aðganginn. Efni sem þú hefur búið til eins og síður og þær myndir sem þú hefur sent inn munu halda sér.', + 'delete_my_account_warning' => 'Ertu viss um að þú viljir eyða aðganginum þínum?', +]; diff --git a/lang/is/settings.php b/lang/is/settings.php new file mode 100644 index 00000000000..14b0ec8e32d --- /dev/null +++ b/lang/is/settings.php @@ -0,0 +1,339 @@ + 'Stillingar', + 'settings_save' => 'Vista stillingar', + 'system_version' => 'Kerfisútgáfa', + 'categories' => 'Flokkar', + + // App Settings + 'app_customization' => 'Sérsníða', + 'app_features_security' => 'Eigindi og öryggi', + 'app_name' => 'Nafn kerfis', + 'app_name_desc' => 'Þetta nafn er sýnd í titli og í öllum tölvupóstum sem sendir eru.', + 'app_name_header' => 'Sýna nafn í titli', + 'app_public_access' => 'Almennur aðgangur', + 'app_public_access_desc' => 'Með því að virkja þennan valmöguleika munu notendur sem eru ekki skráðir inn geta skoðað innihald vefsins.', + 'app_public_access_desc_guest' => 'Hægt er að stýra almennum aðgangi í gegnum "Guest" notandann.', + 'app_public_access_toggle' => 'Leyfa almennann aðgang', + 'app_public_viewing' => 'Leyfa almenna skoðun?', + 'app_secure_images' => 'Aukið öryggi á mynda upphal', + 'app_secure_images_toggle' => 'Virkja aukið öryggi á mynda upphal', + 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', + 'app_default_editor' => 'Sjálfgefin ritill', + 'app_default_editor_desc' => 'Veldu hvaða ritil á að nota sjálfgefið þegar unnið er með nýjar síður. Hægt er að yfirskrifa þessa stillingu á hverri síðu ef viðkomandi er með réttindi.', + 'app_custom_html' => 'Custom HTML Head Content', + 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', + 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', + 'app_logo' => 'Lógó síðu', + 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', + 'app_icon' => 'Íkon síðu', + 'app_icon_desc' => 'Þetta íkon er notað í tabs í vöfrum og sem flýtivísir á síðu. Þetta ætti að vera 256px PNG mynd.', + 'app_homepage' => 'Heimasíða', + 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage_select' => 'Veldu síðu', + 'app_footer_links' => 'Neðangreins hlekkir', + 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', + 'app_footer_links_label' => 'Miði hlekks', + 'app_footer_links_url' => 'Vistfang hlekks', + 'app_footer_links_add' => 'Bæta við neðangreinshlekk', + 'app_disable_comments' => 'Óvirkja athugasemdir', + 'app_disable_comments_toggle' => 'Óvirkja athugasemdir', + 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
                                Existing comments are not shown.', + + // Color settings + 'color_scheme' => 'Litaþema hugbúnaðar', + 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', + 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', + 'app_color' => 'Aðal litur', + 'link_color' => 'Aðal litur hlekkja', + 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', + 'bookshelf_color' => 'Litur hillu', + 'book_color' => 'Litur Bóka', + 'chapter_color' => 'Litur kalfa', + 'page_color' => 'Litur síðu', + 'page_draft_color' => 'Litur draga', + + // Registration Settings + 'reg_settings' => 'Skráning', + 'reg_enable' => 'Virkja skráningar', + 'reg_enable_toggle' => 'Virkja skráningar', + 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', + 'reg_default_role' => 'Sjálfgefið hlutverk notanda eftir skráningu', + 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', + 'reg_email_confirmation' => 'Tölvupóst staðfesting', + 'reg_email_confirmation_toggle' => 'Krefast staðfestingar í tölvupósti', + 'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.', + 'reg_confirm_restrict_domain' => 'Læsingar á lén', + 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
                                Note that users will be able to change their email addresses after successful registration.', + 'reg_confirm_restrict_domain_placeholder' => 'Engin skilyrði sett', + + // Maintenance settings + 'maint' => 'Viðhald', + 'maint_image_cleanup' => 'Taka til í myndum', + 'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.', + 'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions', + 'maint_image_cleanup_run' => 'Keyra hreinsun', + 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', + 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', + 'maint_image_cleanup_nothing_found' => 'Engar ónotaðar myndir fundust, engu eytt!', + 'maint_send_test_email' => 'Senda prufu tölvupóst', + 'maint_send_test_email_desc' => 'Þessi aðgerð sendir prufu tölvupóst á netfangið sem stillt er á í prófílnum þínum.', + 'maint_send_test_email_run' => 'Senda prufu tölvupóst', + 'maint_send_test_email_success' => 'Tölvupóstur sendur á :address', + 'maint_send_test_email_mail_subject' => 'Prufupóstur', + 'maint_send_test_email_mail_greeting' => 'Það virðist virka að senda tölvupóst!', + 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', + 'maint_recycle_bin_open' => 'Opna ruslatunnu', + 'maint_regen_references' => 'Regenerate References', + 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', + 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + + // Recycle Bin + 'recycle_bin' => 'Ruslatunna', + 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'recycle_bin_deleted_item' => 'Eyddur hlutur', + 'recycle_bin_deleted_parent' => 'Foreldri', + 'recycle_bin_deleted_by' => 'Eytt af', + 'recycle_bin_deleted_at' => 'Eytt þann', + 'recycle_bin_permanently_delete' => 'Eyða varanlega', + 'recycle_bin_restore' => 'Endurheimta', + 'recycle_bin_contents_empty' => 'Ruslatunnan er tóm', + 'recycle_bin_empty' => 'Tæma ruslatunnu', + 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', + 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_list' => 'Hlutir til eyðingar', + 'recycle_bin_restore_list' => 'Hlutir til endurheimtar', + 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', + 'recycle_bin_restore_parent' => 'Endurheimta foreldri', + 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + + // Audit Log + 'audit' => 'Ferilskrá', + 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_event_filter' => 'Atburðasía', + 'audit_event_filter_no_filter' => 'Engin sía', + 'audit_deleted_item' => 'Eyddur hlutur', + 'audit_deleted_item_name' => 'Nafn :name', + 'audit_table_user' => 'Notandi', + 'audit_table_event' => 'Atburður', + 'audit_table_related' => 'Tengdur hlutur eða lýsing', + 'audit_table_ip' => 'IP tala', + 'audit_table_date' => 'Dagsetning virkni', + 'audit_date_from' => 'Dagsetning frá', + 'audit_date_to' => 'Dagsetning til', + + // Role Settings + 'roles' => 'Hlutverk', + 'role_user_roles' => 'Notanda hlutverk', + 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', + 'roles_x_users_assigned' => ':count user assigned|:count users assigned', + 'roles_x_permissions_provided' => ':count permission|:count permissions', + 'roles_assigned_users' => 'Skilgreindir notendur', + 'roles_permissions_provided' => 'Uppgefnar heimildir', + 'role_create' => 'Búa til nýtt hlutverk', + 'role_delete' => 'Eyða hlutverki', + 'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.', + 'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', + 'role_delete_no_migration' => "Don't migrate users", + 'role_delete_sure' => 'Are you sure you want to delete this role?', + 'role_edit' => 'Breyta hlutverki', + 'role_details' => 'Lýsing á hlutverki', + 'role_name' => 'Nafn hlutverks', + 'role_desc' => 'Stutt lýsing á hlutverki', + 'role_mfa_enforced' => 'Krefst tvöfaldrar auðkenningar', + 'role_external_auth_id' => 'Ytri auðkenningarnúmer', + 'role_system' => 'Réttindastillingar kerfis', + 'role_manage_users' => 'Sýsla með notendur', + 'role_manage_roles' => 'Stýra hlutverkum og réttindum hlutverka', + 'role_manage_entity_permissions' => 'Stýra öllum bóka, kafla og síðu réttindum', + 'role_manage_own_entity_permissions' => 'Stýra réttindum á eigin bókum, köflum og síðum', + 'role_manage_page_templates' => 'Stýra síðu sníðmátum', + 'role_access_api' => 'Access system API', + 'role_manage_settings' => 'Manage app settings', + 'role_export_content' => 'Flytja út efni', + 'role_import_content' => 'Flytja inn efni', + 'role_editor_change' => 'Skipta um ritil síðu', + 'role_notifications' => 'Receive & manage notifications', + 'role_asset' => 'Asset Permissions', + 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', + 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', + 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', + 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_all' => 'Allt', + 'role_own' => 'Eigin', + 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_save' => 'Vista hlutverk', + 'role_users' => 'Notendur í þessu hlutverki', + 'role_users_none' => 'Engir notendur eru eins og er í þessu hlutverki', + + // Users + 'users' => 'Notendur', + 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', + 'user_profile' => 'Prófíll notanda', + 'users_add_new' => 'Bæta við nýjum notanda', + 'users_search' => 'Leita að notendum', + 'users_latest_activity' => 'Síðasta virkni', + 'users_details' => 'Notendaupplýsingar', + 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', + 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', + 'users_role' => 'Hlutverk notenda', + 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', + 'users_password' => 'Lykilorð notanda', + 'users_password_desc' => 'Setja lykilorð sem þú notar til að skrá þig inn í hugbúnaðinn. Lykilorðið verður að vera a. m. k 8 stafa langt.', + 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', + 'users_send_invite_option' => 'Senda boð á notanda með tölvupósti', + 'users_external_auth_id' => 'Ytra auðkenningar númer', + 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', + 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', + 'users_delete' => 'Eyða notanda', + 'users_delete_named' => 'Eyða notanda :userName', + 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', + 'users_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum notanda?', + 'users_migrate_ownership' => 'Færa eignarhald', + 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', + 'users_none_selected' => 'Engin notandi valin', + 'users_edit' => 'Breyta notanda', + 'users_edit_profile' => 'Breyta prófíl', + 'users_avatar' => 'Avatar notanda', + 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', + 'users_preferred_language' => 'Valið tungumál', + 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', + 'users_social_accounts' => 'Samfélagsmiðla reikningar', + 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Tengja aðgang', + 'users_social_disconnect' => 'Aftengja aðgang', + 'users_social_status_connected' => 'Tengt', + 'users_social_status_disconnected' => 'Aftengt', + 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', + 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', + 'users_api_tokens' => 'API tókar', + 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_none' => 'No API tokens have been created for this user', + 'users_api_tokens_create' => 'Búa til tóka', + 'users_api_tokens_expires' => 'Rennur út', + 'users_api_tokens_docs' => 'API Documentation', + 'users_mfa' => 'Multi-Factor Authentication', + 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_configure' => 'Configure Methods', + + // API Tokens + 'user_api_token_create' => 'Búa til API tóka', + 'user_api_token_name' => 'Nafn', + 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', + 'user_api_token_expiry' => 'Rennur út þann', + 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', + 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', + 'user_api_token' => 'API tóki', + 'user_api_token_id' => 'Númer tóka', + 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', + 'user_api_token_secret' => 'Tóka leyndarmál', + 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', + 'user_api_token_created' => 'Tóki búinn til :timeAgo', + 'user_api_token_updated' => 'Tóki uppfærður :timeAgo', + 'user_api_token_delete' => 'Eyða tóka', + 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', + 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', + 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_create' => 'Búa til nýjann Webhook', + 'webhooks_none_created' => 'Engir Webhooks hafa verið búnir til.', + 'webhooks_edit' => 'Breyta Webhook', + 'webhooks_save' => 'Vista Webhook', + 'webhooks_details' => 'Upplýsingar um Webhook', + 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', + 'webhooks_events' => 'Webhook atburðir', + 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', + 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_events_all' => 'Allir kerfis atburðir', + 'webhooks_name' => 'Webhook nafn', + 'webhooks_timeout' => 'Webhook tímamörk (Sekúndur)', + 'webhooks_endpoint' => 'Webhook endapunktur', + 'webhooks_active' => 'Webhook virkur', + 'webhook_events_table_header' => 'Atburðir', + 'webhooks_delete' => 'Eyða Webhook', + 'webhooks_delete_warning' => 'Þessi aðgerð mun eyða þessum Webhook að fullu \':webhookName\',.', + 'webhooks_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum Webhook?', + 'webhooks_format_example' => 'Dæmi um snið á Webhook', + 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + 'webhooks_status' => 'Staða á Webhook', + 'webhooks_last_called' => 'Síðast kallað:', + 'webhooks_last_errored' => 'Síðasta villa kom upp:', + 'webhooks_last_error_message' => 'Síðustu villuskilaboð:', + + // Licensing + 'licenses' => 'Leyfi', + 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', + 'licenses_bookstack' => 'Bookstack leyfi', + 'licenses_php' => 'PHP kóðasafnsleyfi', + 'licenses_js' => 'Javascript kóðasafnsleyfi', + 'licenses_other' => 'Önnur leyfi', + 'license_details' => 'Upplýsingar um leyfi', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'cy' => 'Cymraeg', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'el' => 'ελληνικά', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'et' => 'Eesti keel', + 'eu' => 'Euskara', + 'fa' => 'فارسی', + 'fi' => 'Suomi', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nb' => 'Norsk (Bokmål)', + 'nn' => 'Nynorsk', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ro' => 'Română', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'uz' => 'O‘zbekcha', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ], + //!//////////////////////////////// +]; diff --git a/lang/is/validation.php b/lang/is/validation.php new file mode 100644 index 00000000000..9183d27cf53 --- /dev/null +++ b/lang/is/validation.php @@ -0,0 +1,122 @@ + 'Það verður að samþykkja :attribute.', + 'active_url' => ':attribute Er ekki gilt vistfang.', + 'after' => ':attribute verður að vera dagsetning eftir :date.', + 'alpha' => ':attribute má eingöngu innihalda stafi.', + 'alpha_dash' => ':attribute má eingöngu innihalda stafi, tölustafi, strik og undirstrik.', + 'alpha_num' => ':attribute má eingöngu innihalda stafi og tölustafi.', + 'array' => 'The :attribute must be an array.', + 'backup_codes' => 'Kóðinn sem þú gafst upp er ekki gildur eða hefur þegar verið notaður.', + 'before' => ':attribute verður að vera dagsetning á undan :date.', + 'between' => [ + 'numeric' => ':attribute verður að vera á milli :min og :max.', + 'file' => ':attribute verður að vera á milli :min og :max kílóbæti.', + 'string' => ':attribute verður að vera á milli :min og :max stafir.', + 'array' => ':attribute verður að vera á milli :min og :max fjöldi.', + ], + 'boolean' => ':attribute gildið verður að vera true og false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'email' => 'The :attribute must be a valid email address.', + 'ends_with' => 'The :attribute must end with one of the following: :values', + 'file' => 'The :attribute must be provided as a valid file.', + 'filled' => 'The :attribute field is required.', + 'gt' => [ + 'numeric' => 'The :attribute must be greater than :value.', + 'file' => 'The :attribute must be greater than :value kilobytes.', + 'string' => 'The :attribute must be greater than :value characters.', + 'array' => 'The :attribute must have more than :value items.', + ], + 'gte' => [ + 'numeric' => 'The :attribute must be greater than or equal :value.', + 'file' => 'The :attribute must be greater than or equal :value kilobytes.', + 'string' => 'The :attribute must be greater than or equal :value characters.', + 'array' => 'The :attribute must have :value items or more.', + ], + 'exists' => 'The selected :attribute is invalid.', + 'image' => 'The :attribute must be an image.', + 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'in' => 'The selected :attribute is invalid.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'ipv4' => 'The :attribute must be a valid IPv4 address.', + 'ipv6' => 'The :attribute must be a valid IPv6 address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'lt' => [ + 'numeric' => 'The :attribute must be less than :value.', + 'file' => 'The :attribute must be less than :value kilobytes.', + 'string' => 'The :attribute must be less than :value characters.', + 'array' => 'The :attribute must have less than :value items.', + ], + 'lte' => [ + 'numeric' => 'The :attribute must be less than or equal :value.', + 'file' => 'The :attribute must be less than or equal :value kilobytes.', + 'string' => 'The :attribute must be less than or equal :value characters.', + 'array' => 'The :attribute must not have more than :value items.', + ], + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute format is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values is present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'safe_url' => 'The provided link may not be safe.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'totp' => 'The provided code is not valid or has expired.', + 'unique' => 'The :attribute has already been taken.', + 'url' => 'The :attribute format is invalid.', + 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Staðfestingu á lykilorði er krafist', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/lang/it/activities.php b/lang/it/activities.php index 61a22518551..0747d7784e5 100644 --- a/lang/it/activities.php +++ b/lang/it/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'ha eliminato un webhook', 'webhook_delete_notification' => 'Webhook eliminato con successo', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'ha creato un utente', 'user_create_notification' => 'Utente creato con successo', diff --git a/lang/it/editor.php b/lang/it/editor.php index f9a6e47ae03..11b019ee382 100644 --- a/lang/it/editor.php +++ b/lang/it/editor.php @@ -163,6 +163,8 @@ 'about' => 'Informazioni sull\'editor', 'about_title' => 'Informazioni sull\'editor di WYSIWYG', 'editor_license' => 'Licenza e copyright dell\'editor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Questo editor è realizzato usando :tinyLink che è fornito sotto la licenza MIT.', 'editor_tiny_license_link' => 'I dettagli del copyright e della licenza di TinyMCE sono disponibili qui.', 'save_continue' => 'Salva pagina e continua', diff --git a/lang/it/entities.php b/lang/it/entities.php index d7542b1ba11..22c428ed3e1 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'File PDF', 'export_text' => 'File di testo', 'export_md' => 'File Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Modello di pagina predefinito', 'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che potrà essere utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.', 'default_template_select' => 'Seleziona una pagina modello', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permessi', diff --git a/lang/it/errors.php b/lang/it/errors.php index 5120a6d71e9..a68da405bb5 100644 --- a/lang/it/errors.php +++ b/lang/it/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName è offline al momento', 'back_soon' => 'Tornerà presto online.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Nessun token di autorizzazione trovato nella richiesta', 'api_bad_authorization_format' => 'Un token di autorizzazione è stato trovato nella richiesta, ma il formato sembra non corretto', diff --git a/lang/it/settings.php b/lang/it/settings.php index 5d68fd26d1f..e57baceb49e 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Accedere alle API di sistema', 'role_manage_settings' => 'Gestire impostazioni app', 'role_export_content' => 'Esportare contenuto', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Cambiare editor di pagina', 'role_notifications' => 'Ricevere e gestire le notifiche', 'role_asset' => 'Permessi entità', diff --git a/lang/it/validation.php b/lang/it/validation.php index f8f59a3ac0f..b21e8d932b4 100644 --- a/lang/it/validation.php +++ b/lang/it/validation.php @@ -105,6 +105,11 @@ 'url' => 'Il formato :attribute non è valido.', 'uploaded' => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ja/activities.php b/lang/ja/activities.php index 0e6e8d3ce83..f20f9a90d5a 100644 --- a/lang/ja/activities.php +++ b/lang/ja/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'がWebhookを削除', 'webhook_delete_notification' => 'Webhookを削除しました', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'がユーザを作成', 'user_create_notification' => 'ユーザーを作成しました', diff --git a/lang/ja/editor.php b/lang/ja/editor.php index 22162796c0b..576e08f14b2 100644 --- a/lang/ja/editor.php +++ b/lang/ja/editor.php @@ -163,6 +163,8 @@ 'about' => 'エディタについて', 'about_title' => 'WYSIWYGエディタについて', 'editor_license' => 'エディタのライセンスと著作権', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'このエディタはMITライセンスの下で提供される:tinyLinkを利用して構築されています。', 'editor_tiny_license_link' => 'TinyMCEの著作権およびライセンスの詳細は、こちらをご覧ください。', 'save_continue' => 'ページを保存して続行', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index 489032835cc..05e2f33ab8f 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF', 'export_text' => 'テキストファイル', 'export_md' => 'Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'デフォルトページテンプレート', 'default_template_explain' => 'このアイテム内に新しいページを作成する際にデフォルトコンテンツとして使用されるページテンプレートを割り当てます。これはページ作成者が選択したテンプレートページへのアクセス権を持つ場合にのみ使用されることに注意してください。', 'default_template_select' => 'テンプレートページを選択', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => '権限', diff --git a/lang/ja/errors.php b/lang/ja/errors.php index 4eba6365934..ca001d7680b 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appNameは現在停止しています', 'back_soon' => '回復までしばらくお待ちください。', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'リクエストに認証トークンが見つかりません', 'api_bad_authorization_format' => 'リクエストに認証トークンが見つかりましたが、形式が正しくないようです', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 218eecf8c2c..d73f12cb2c9 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'システムのAPIへのアクセス', 'role_manage_settings' => 'アプリケーション設定の管理', 'role_export_content' => 'コンテンツのエクスポート', + 'role_import_content' => 'Import content', 'role_editor_change' => 'ページエディタの変更', 'role_notifications' => '通知の受信と管理', 'role_asset' => 'アセット権限', diff --git a/lang/ja/validation.php b/lang/ja/validation.php index 96ae7dfff24..110bcb01213 100644 --- a/lang/ja/validation.php +++ b/lang/ja/validation.php @@ -105,6 +105,11 @@ 'url' => ':attributeのフォーマットは不正です。', 'uploaded' => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ka/activities.php b/lang/ka/activities.php index 6c7af4429f0..4cc9fbcb66a 100644 --- a/lang/ka/activities.php +++ b/lang/ka/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/ka/editor.php b/lang/ka/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/ka/editor.php +++ b/lang/ka/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/ka/entities.php b/lang/ka/entities.php index 35e6f050bb8..26a563a7eb5 100644 --- a/lang/ka/entities.php +++ b/lang/ka/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF File', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/ka/errors.php b/lang/ka/errors.php index 9c40aa9ed33..9d738379648 100644 --- a/lang/ka/errors.php +++ b/lang/ka/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/lang/ka/validation.php b/lang/ka/validation.php index 2a676c7c4cc..d9b982d1e23 100644 --- a/lang/ka/validation.php +++ b/lang/ka/validation.php @@ -105,6 +105,11 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ko/activities.php b/lang/ko/activities.php index bcf34795622..356568d1fd6 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -77,13 +77,21 @@ 'maintenance_action_run' => '유지 관리 작업 실행', // Webhooks - 'webhook_create' => '웹 훅 만들기', + 'webhook_create' => '웹 훅 생성', 'webhook_create_notification' => '웹 훅 생성함', 'webhook_update' => '웹 훅 수정하기', 'webhook_update_notification' => '웹훅 설정이 수정되었습니다.', 'webhook_delete' => '웹 훅 지우기', 'webhook_delete_notification' => '웹 훅 삭제함', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => '사용자 생성', 'user_create_notification' => '사용자 생성 성공', diff --git a/lang/ko/common.php b/lang/ko/common.php index 246a3aebc36..b19b0d5cdd2 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -71,7 +71,7 @@ 'back_to_top' => '맨 위로', 'skip_to_main_content' => '메인 항목으로', 'toggle_details' => '내용 보기', - 'toggle_thumbnails' => '섬네일 보기', + 'toggle_thumbnails' => '썸네일 보기', 'details' => '정보', 'grid_view' => '격자 보기', 'list_view' => '목록 보기', @@ -101,7 +101,7 @@ // Email Content 'email_action_help' => ':actionText를 클릭할 수 없을 때는 웹 브라우저에서 다음 링크로 접속할 수 있습니다.', - 'email_rights' => 'All rights reserved.', + 'email_rights' => '모든 권리는 보호됩니다.', // Footer Link Options // Not directly used but available for convenience to users. diff --git a/lang/ko/components.php b/lang/ko/components.php index c81497e0308..cbda4d15980 100644 --- a/lang/ko/components.php +++ b/lang/ko/components.php @@ -12,7 +12,7 @@ 'image_intro' => '여기에서 이전에 시스템에 업로드한 이미지를 선택하고 관리할 수 있습니다.', 'image_intro_upload' => '이미지 파일을 이 창으로 끌어다 놓거나 위의 \'이미지 업로드\' 버튼을 사용하여 새 이미지를 업로드합니다.', 'image_all' => '모든 이미지', - 'image_all_title' => '모든 이미지', + 'image_all_title' => '모든 이미지 보기', 'image_book_title' => '이 책에서 쓰고 있는 이미지', 'image_page_title' => '이 문서에서 쓰고 있는 이미지', 'image_search_hint' => '이미지 이름 검색', @@ -30,8 +30,8 @@ 'images_deleted' => '이미지 삭제함', 'image_preview' => '이미지 미리 보기', 'image_upload_success' => '이미지 올림', - 'image_update_success' => '이미지 정보 수정함', - 'image_delete_success' => '이미지 삭제함', + 'image_update_success' => '이미지 정보가 수정되었습니다.', + 'image_delete_success' => '이미지가 삭제되었습니다.', 'image_replace' => '이미지 교체', 'image_replace_success' => '이미지 파일 업데이트 성공', 'image_rebuild_thumbs' => '사이즈 변경 재생성하기', diff --git a/lang/ko/editor.php b/lang/ko/editor.php index 485ab79cd94..6892410169d 100644 --- a/lang/ko/editor.php +++ b/lang/ko/editor.php @@ -163,6 +163,8 @@ 'about' => '이 편집기에 대하여', 'about_title' => 'WYSIWYG 편집기에 대하여', 'editor_license' => '편집기 라이선스 & 저작권', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => '전체 라이센스 세부 사항은 여기에서 확인할 수 있습니다.', 'editor_tiny_license' => '이 편집기는 MIT 라이선스에 따라 제공되는 :tinyLink를 사용하여 제작되었습니다.', 'editor_tiny_license_link' => 'TinyMCE의 저작권 및 라이선스 세부 정보는 여기에서 확인할 수 있습니다.', 'save_continue' => '저장하고 계속하기', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index 4afd174f5f0..4fbbe376629 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF 파일', 'export_text' => '일반 텍스트 파일', 'export_md' => '마크다운 파일', + 'export_zip' => 'Portable ZIP', 'default_template' => '기본 페이지 템플릿', 'default_template_explain' => '이 항목 내에서 생성되는 모든 페이지의 기본 콘텐츠로 사용할 페이지 템플릿을 지정합니다. 페이지 작성자가 선택한 템플릿 페이지를 볼 수 있는 권한이 있는 경우에만 이 항목이 사용된다는 점을 유의하세요.', 'default_template_select' => '템플릿 페이지 선택', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => '같은 인스턴스나 다른 인스턴스에서 휴대용 zip 내보내기를 사용하여 책, 장 및 페이지를 가져옵니다. 진행하려면 ZIP 파일을 선택합니다. 파일을 업로드하고 검증한 후 다음 보기에서 가져오기를 구성하고 확인할 수 있습니다.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => '업로드된 ZIP 파일이 삭제되며, 실행 취소할 수 없습니다.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => '권한', @@ -130,7 +151,7 @@ 'books_delete' => '책 삭제하기', 'books_delete_named' => ':bookName(을)를 지웁니다.', 'books_delete_explain' => ':bookName에 있는 모든 챕터와 문서도 지웁니다.', - 'books_delete_confirmation' => '이 책을 지울 건가요?', + 'books_delete_confirmation' => '이 책을 지우시겠습니까?', 'books_edit' => '책 바꾸기', 'books_edit_named' => ':bookName(을)를 바꿉니다.', 'books_form_book_name' => '책 이름', @@ -139,7 +160,7 @@ 'books_permissions_updated' => '권한 저장함', 'books_empty_contents' => '이 책에 챕터나 문서가 없습니다.', 'books_empty_create_page' => '문서 만들기', - 'books_empty_sort_current_book' => '읽고 있는 책 정렬', + 'books_empty_sort_current_book' => '현재 책 정렬', 'books_empty_add_chapter' => '챕터 만들기', 'books_permissions_active' => '책 권한 허용함', 'books_search_this' => '이 책에서 검색', @@ -224,7 +245,7 @@ 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_wysiwyg' => 'WYSIWYG 편집기로 전환', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg' => '새 위지윅 편집기로 변경', 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', 'pages_edit_set_changelog' => '수정본 설명', 'pages_edit_enter_changelog_desc' => '수정본 설명', @@ -287,7 +308,7 @@ 'pages_initial_name' => '제목 없음', 'pages_editing_draft_notification' => ':timeDiff에 초안 문서입니다.', 'pages_draft_edited_notification' => '최근에 수정한 문서이기 때문에 초안 문서를 폐기하는 편이 좋습니다.', - 'pages_draft_page_changed_since_creation' => '최근에 수정한 문서이기 때문에 초안 문서를 폐기하는 편이 좋습니다.', + 'pages_draft_page_changed_since_creation' => '최근에 수정한 문서이기 때문에 임시 저장문서를 폐기하는 편이 좋습니다.', 'pages_draft_edit_active' => [ 'start_a' => ':count명이 이 문서를 수정하고 있습니다.', 'start_b' => ':userName이 이 문서를 수정하고 있습니다.', @@ -302,7 +323,7 @@ // Editor Sidebar 'toggle_sidebar' => '사이드바 토글', - 'page_tags' => '문서 꼬리표', + 'page_tags' => '문서 태그', 'chapter_tags' => '챕터 꼬리표', 'book_tags' => '책 꼬리표', 'shelf_tags' => '책꽂이 꼬리표', @@ -317,7 +338,7 @@ 'tags_usages' => '모든 꼬리표', 'tags_assigned_pages' => '문서에 꼬리표 지정함', 'tags_assigned_chapters' => '챕터에 꼬리표 지정함', - 'tags_assigned_books' => '책에 꼬리표 지정함', + 'tags_assigned_books' => '책에 태그 지정함', 'tags_assigned_shelves' => '책꽂이에 꼬리표 지정함', 'tags_x_unique_values' => ':count 중복 없는 값', 'tags_all_values' => '모든 값', @@ -351,7 +372,7 @@ 'attachments_file_updated' => '파일 바꿈', 'attachments_link_attached' => '링크 첨부함', 'templates' => '템플릿', - 'templates_set_as_template' => '템플릿', + 'templates_set_as_template' => '현재 페이지는 템플릿용 페이지 입니다.', 'templates_explain_set_as_template' => '템플릿은 보기 권한만 있어도 문서에 쓸 수 있습니다.', 'templates_replace_content' => '문서 대체', 'templates_append_content' => '문서 앞에 추가', @@ -433,7 +454,7 @@ 'watch_detail_updates' => '새 페이지 및 업데이트 보기', 'watch_detail_comments' => '새 페이지, 업데이트 및 댓글 보기', 'watch_detail_parent_book' => '상위 책을 통해 보기', - 'watch_detail_parent_book_ignore' => '페어런트 북을 통한 무시하기', + 'watch_detail_parent_book_ignore' => '상위 책을 통한 무시하기', 'watch_detail_parent_chapter' => '상위 챕터를 통해 보기', 'watch_detail_parent_chapter_ignore' => '상위 챕터를 통해 무시하기', ]; diff --git a/lang/ko/errors.php b/lang/ko/errors.php index 7f2d8bb6a72..f5ba13efa3b 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName에 문제가 생겼습니다.', 'back_soon' => '곧 돌아갑니다.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => '요청에서 인증 토큰을 찾을 수 없습니다.', 'api_bad_authorization_format' => '요청에서 인증 토큰을 찾았으나 형식에 문제가 있습니다.', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 3a24e21a0c0..7277891d55b 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => '시스템 접근 API', 'role_manage_settings' => '사이트 설정 관리', 'role_export_content' => '항목 내보내기', + 'role_import_content' => 'Import content', 'role_editor_change' => '페이지 편집기 변경', 'role_notifications' => '알림 수신 및 관리', 'role_asset' => '권한 항목', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index e533429ee1a..7a4d228ce10 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute(은)는 유효하지 않은 형식입니다.', 'uploaded' => '파일 크기가 서버에서 허용하는 수치를 넘습니다.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/lt/activities.php b/lang/lt/activities.php index 9a240d83cae..2c4165195ec 100644 --- a/lang/lt/activities.php +++ b/lang/lt/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/lt/editor.php b/lang/lt/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/lt/editor.php +++ b/lang/lt/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/lt/entities.php b/lang/lt/entities.php index 17601aa1687..91d4cedc435 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF failas', 'export_text' => 'Paprastas failo tekstas', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Leidimai', diff --git a/lang/lt/errors.php b/lang/lt/errors.php index 2209a00d7e0..392e99a5192 100644 --- a/lang/lt/errors.php +++ b/lang/lt/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName dabar yra apačioje', 'back_soon' => 'Tai sugrįž greitai', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Užklausoje nerastas įgaliojimo prieigos raktas', 'api_bad_authorization_format' => 'Užklausoje rastas prieigos raktas, tačiau formatas yra neteisingas', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index 3692bd2413b..ddd2a702299 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Gauti prieigą prie sistemos API', 'role_manage_settings' => 'Tvarkyti programos nustatymus', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Nuosavybės leidimai', diff --git a/lang/lt/validation.php b/lang/lt/validation.php index 08470cf2291..92de23004aa 100644 --- a/lang/lt/validation.php +++ b/lang/lt/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute formatas yra klaidingas.', 'uploaded' => 'Šis failas negali būti įkeltas. Serveris gali nepriimti tokio dydžio failų.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/lv/activities.php b/lang/lv/activities.php index f7db090d806..2f9b49f8e75 100644 --- a/lang/lv/activities.php +++ b/lang/lv/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'izdzēsa webhook', 'webhook_delete_notification' => 'Webhook veiksmīgi izdzēsts', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'izveidoja lietotāju', 'user_create_notification' => 'Lietotājs veiksmīgi izveidots', diff --git a/lang/lv/editor.php b/lang/lv/editor.php index 5443e505d66..1bdf4476dfc 100644 --- a/lang/lv/editor.php +++ b/lang/lv/editor.php @@ -163,6 +163,8 @@ 'about' => 'Par redaktoru', 'about_title' => 'Par WYSIWYG redaktoru', 'editor_license' => 'Redaktora licence un autortiesības', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.', 'editor_tiny_license_link' => 'TinyMCE autortiesības un licences detaļas var atrast šeit.', 'save_continue' => 'Saglabāt lapu un turpināt', diff --git a/lang/lv/entities.php b/lang/lv/entities.php index 771fffd4470..f5d9a2eeb54 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF fails', 'export_text' => 'Vienkāršs teksta fails', 'export_md' => 'Markdown fails', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Noklusētā lapas sagatave', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Izvēlēt sagataves lapu', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Atļaujas', diff --git a/lang/lv/errors.php b/lang/lv/errors.php index e705da6a02b..40d755e61f3 100644 --- a/lang/lv/errors.php +++ b/lang/lv/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName pagaidām nav pieejams', 'back_soon' => 'Drīz būs atkal pieejams.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Pieprasījumā nav atrasts autorizācijas žetons', 'api_bad_authorization_format' => 'Pieprasījumā atrasts autorizācijas žetons, taču tā formāts nav pareizs', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index a94b45b3e42..3fca3050aad 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Piekļūt sistēmas API', 'role_manage_settings' => 'Pārvaldīt iestatījumus', 'role_export_content' => 'Eksportēt saturu', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Mainīt lapu redaktoru', 'role_notifications' => 'Saņemt un pārvaldīt paziņojumus', 'role_asset' => 'Resursa piekļuves tiesības', diff --git a/lang/lv/validation.php b/lang/lv/validation.php index 64a0dece367..5b714af8659 100644 --- a/lang/lv/validation.php +++ b/lang/lv/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute formāts nav derīgs.', 'uploaded' => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/nb/activities.php b/lang/nb/activities.php index 04ab09c8477..3f8ee88df7c 100644 --- a/lang/nb/activities.php +++ b/lang/nb/activities.php @@ -85,6 +85,14 @@ 'webhook_delete' => 'slettet webhook', 'webhook_delete_notification' => 'Webhook ble slettet', + // Imports + 'import_create' => 'import opprettet', + 'import_create_notification' => 'Importen ble opplastet', + 'import_run' => 'oppdatert import', + 'import_run_notification' => 'Innhold importert', + 'import_delete' => 'import slettet', + 'import_delete_notification' => 'Importering ble slettet', + // Users 'user_create' => 'opprettet bruker', 'user_create_notification' => 'Bruker ble opprettet', diff --git a/lang/nb/common.php b/lang/nb/common.php index 0e9e19b6e59..1214e81c52b 100644 --- a/lang/nb/common.php +++ b/lang/nb/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => 'Bruksvilkår', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Søk :appName', ]; diff --git a/lang/nb/editor.php b/lang/nb/editor.php index 759f1803625..e128717cdcd 100644 --- a/lang/nb/editor.php +++ b/lang/nb/editor.php @@ -163,6 +163,8 @@ 'about' => 'Om tekstredigeringsprogrammet', 'about_title' => 'Om HDSEHDF-tekstredigeringsprogrammet', 'editor_license' => 'Tekstbehandlerlisens og opphavsrett', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Denne tekstredigereren er laget med :tinyLink som er lisensiert under MIT.', 'editor_tiny_license_link' => 'Informasjon om opphavsrett og lisens for TinyMCE finnes her.', 'save_continue' => 'Lagre side og fortsett', diff --git a/lang/nb/entities.php b/lang/nb/entities.php index a28f15fa660..0d2491eafdc 100644 --- a/lang/nb/entities.php +++ b/lang/nb/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF Fil', 'export_text' => 'Tekstfil', 'export_md' => 'Markdownfil', + 'export_zip' => 'Flyttbar ZIP', 'default_template' => 'Standard sidemal', 'default_template_explain' => 'Tildel en sidemal som vil bli brukt som standardinnhold for alle nye sider i denne boken. Husk dette vil kun bli brukt hvis sideskaperen har tilgang til den valgte malsiden.', 'default_template_select' => 'Velg en malside', + 'import' => 'Import', + 'import_validate' => 'Valider Import', + 'import_desc' => 'Importer bøker, kapitler & sider ved å bruke en flyttbar zip-eksport fra samme eller en annen forekomst. Velg en ZIP-fil for å fortsette. Når filen har blitt lastet opp og validert vil du kunne konfigurere & bekrefte importen i neste visning.', + 'import_zip_select' => 'Velg ZIP-filen som skal lastes opp', + 'import_zip_validation_errors' => 'Feil ble funnet under validering av den angitte ZIP-filen:', + 'import_pending' => 'Venter på import', + 'import_pending_none' => 'Ingen importer er startet.', + 'import_continue' => 'Fortsett import', + 'import_continue_desc' => 'Gjennomgå innholdet på grunn av at det importeres fra den opplastede ZIP-filen. Når klar, kjøre importen for å legge til innholdet i dette systemet. Den opplastede ZIP-importfilen vil automatisk bli fjernet ved vellykket import.', + 'import_details' => 'Importer detaljer', + 'import_run' => 'Kjør Import', + 'import_size' => ':size Import ZIP størrelse', + 'import_uploaded_at' => 'Opplastet :relativeTime', + 'import_uploaded_by' => 'Lastet opp av', + 'import_location' => 'Import posisjon', + 'import_location_desc' => 'Velg en mållokasjon for ditt importerte innhold. Du vil trenge relevante tillatelser for å opprette innenfor den posisjonen du velger.', + 'import_delete_confirm' => 'Er du sikker på at du vil slette denne importen?', + 'import_delete_desc' => 'Dette vil slette den opplastede importen av ZIP-filen og kan ikke angres.', + 'import_errors' => 'Import feil', + 'import_errors_desc' => 'Feil oppstod under importforsøket:', // Permissions and restrictions 'permissions' => 'Tilganger', @@ -224,8 +245,8 @@ 'pages_edit_switch_to_markdown_clean' => '(Renset innhold)', 'pages_edit_switch_to_markdown_stable' => '(Urørt innhold)', 'pages_edit_switch_to_wysiwyg' => 'Bytt til WYSIWYG tekstredigering', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Bytt til ny WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(I Alpha Testing)', 'pages_edit_set_changelog' => 'Angi endringslogg', 'pages_edit_enter_changelog_desc' => 'Gi en kort beskrivelse av endringene dine', 'pages_edit_enter_changelog' => 'Se endringslogg', diff --git a/lang/nb/errors.php b/lang/nb/errors.php index 379483626c0..400681b10d1 100644 --- a/lang/nb/errors.php +++ b/lang/nb/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Du kan ikke slette den eneste administratoren', 'users_cannot_delete_guest' => 'Du kan ikke slette gjestebrukeren (Du kan deaktivere offentlig visning istede)', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Kunne ikke opprette bruker fordi invitasjons e-post ikke kunne sendes', // Roles 'role_cannot_be_edited' => 'Denne rollen kan ikke endres', @@ -105,6 +105,18 @@ 'app_down' => ':appName er nede for øyeblikket', 'back_soon' => 'Den vil snart komme tilbake.', + // Import + 'import_zip_cant_read' => 'Kunne ikke lese ZIP-filen.', + 'import_zip_cant_decode_data' => 'Kunne ikke finne og dekode ZIP data.json innhold.', + 'import_zip_no_data' => 'ZIP-fildata har ingen forventet bok, kapittel eller sideinnhold.', + 'import_validation_failed' => 'Import av ZIP feilet i å validere med feil:', + 'import_zip_failed_notification' => 'Kunne ikke importere ZIP-fil.', + 'import_perms_books' => 'Du mangler nødvendige tillatelser for å lage bøker.', + 'import_perms_chapters' => 'Du mangler de nødvendige tillatelsene for å opprette kapittel.', + 'import_perms_pages' => 'Du mangler nødvendige tillatelser for å opprette sider.', + 'import_perms_images' => 'Du mangler de nødvendige tillatelsene for å opprette bilder.', + 'import_perms_attachments' => 'Du mangler nødvendig tillatelse for å opprette vedlegg.', + // API errors 'api_no_authorization_found' => 'Ingen autorisasjonstoken ble funnet på forespørselen', 'api_bad_authorization_format' => 'Det ble funnet et autorisasjonstoken på forespørselen, men formatet virket feil', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 539dc1902cd..a39f6ae1bbe 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Systemtilgang API', 'role_manage_settings' => 'Behandle applikasjonsinnstillinger', 'role_export_content' => 'Eksporter innhold', + 'role_import_content' => 'Import innhold', 'role_editor_change' => 'Endre sideredigering', 'role_notifications' => 'Motta og administrere varslinger', 'role_asset' => 'Eiendomstillatelser', diff --git a/lang/nb/validation.php b/lang/nb/validation.php index 7e3784e30c7..a156da8f705 100644 --- a/lang/nb/validation.php +++ b/lang/nb/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute format er ugyldig.', 'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.', + 'zip_file' => 'Attributtet :attribute må henvises til en fil i ZIP.', + 'zip_file_mime' => 'Attributtet :attribute må referere en fil av typen :validTypes, som ble funnet :foundType.', + 'zip_model_expected' => 'Data objekt forventet, men ":type" funnet.', + 'zip_unique' => 'Attributtet :attribute må være unikt for objekttypen i ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/nl/activities.php b/lang/nl/activities.php index 52a8e3e3762..88563ba873e 100644 --- a/lang/nl/activities.php +++ b/lang/nl/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'verwijderde webhook', 'webhook_delete_notification' => 'Webhook succesvol verwijderd', + // Imports + 'import_create' => 'maakte import', + 'import_create_notification' => 'Import succesvol geüpload', + 'import_run' => 'wijzigde import', + 'import_run_notification' => 'Inhoud succesvol geïmporteerd', + 'import_delete' => 'verwijderde import', + 'import_delete_notification' => 'Import succesvol verwijderd', + // Users 'user_create' => 'maakte gebruiker aan', 'user_create_notification' => 'Gebruiker succesvol aangemaakt', diff --git a/lang/nl/editor.php b/lang/nl/editor.php index 89b2e7bea85..d82dd86014b 100644 --- a/lang/nl/editor.php +++ b/lang/nl/editor.php @@ -163,6 +163,8 @@ 'about' => 'Over de bewerker', 'about_title' => 'Over de WYSIWYG Bewerker', 'editor_license' => 'Bewerker Licentie & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Deze editor is gemaakt met behulp van :tinyLink welke is verstrekt onder de MIT-licentie.', 'editor_tiny_license_link' => 'De copyright- en licentiegegevens van TinyMCE vindt u hier.', 'save_continue' => 'Pagina opslaan en verdergaan', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index 36955c8e1f5..57967f9f1a6 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF bestand', 'export_text' => 'Normaal tekstbestand', 'export_md' => 'Markdown bestand', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Standaard Paginasjabloon', 'default_template_explain' => 'Ken een paginasjabloon toe die zal worden gebruikt als de standaardinhoud voor alle pagina\'s die binnen dit item worden aangemaakt. Houd er rekening mee dat dit alleen zal worden gebruikt als de paginamaker leesrechten heeft voor de gekozen sjabloonpagina.', 'default_template_select' => 'Selecteer een sjabloonpagina', + 'import' => 'Import', + 'import_validate' => 'Valideer Import', + 'import_desc' => 'Importeer boeken, hoofdstukken & pagina\'s met een portable Zip-export van dezelfde, of een andere omgeving. Selecteer een Zip-bestand om door te gaan. Nadat het bestand is geüpload en gecontroleerd kunt u de import configureren en doorvoeren in de volgende weergave.', + 'import_zip_select' => 'Selecteer een Zip-bestand om te uploaden', + 'import_zip_validation_errors' => 'Er zijn fouten gevonden tijdens het controleren van het Zip-bestand:', + 'import_pending' => 'Wachtende Imports', + 'import_pending_none' => 'Er zijn geen imports gestart.', + 'import_continue' => 'Importeren Voortzetten', + 'import_continue_desc' => 'Controleer de inhoud die gaat worden geïmporteerd vanuit get geüploade Zip-bestand. Voer de import door om de inhoud toe te voegen aan dit systeem. Bij een succesvolle import zal het geüploade Zip-bestand automatisch verwijderd worden.', + 'import_details' => 'Import Details', + 'import_run' => 'Import Doorvoeren', + 'import_size' => ':size Import Zip Grootte', + 'import_uploaded_at' => 'Geüpload :relativeTime', + 'import_uploaded_by' => 'Geüpload door', + 'import_location' => 'Importlocatie', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Importeerfouten', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Machtigingen', diff --git a/lang/nl/errors.php b/lang/nl/errors.php index 0c83cb7f31f..07900fc6b4f 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is nu niet beschikbaar', 'back_soon' => 'Komt snel weer online.', + // Import + 'import_zip_cant_read' => 'Kon het Zip-bestand niet lezen.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Importeren van het Zip-bestand is mislukt.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Geen autorisatie token gevonden', 'api_bad_authorization_format' => 'Een autorisatie token is gevonden, maar het formaat schijnt onjuist te zijn', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 1d0b14b5cf5..646eb2ba995 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Ga naar systeem API', 'role_manage_settings' => 'Beheer app instellingen', 'role_export_content' => 'Exporteer inhoud', + 'role_import_content' => 'Importeer inhoud', 'role_editor_change' => 'Wijzig pagina bewerker', 'role_notifications' => 'Meldingen ontvangen & beheren', 'role_asset' => 'Asset Machtigingen', diff --git a/lang/nl/validation.php b/lang/nl/validation.php index 378ec6a9158..52b1a22651a 100644 --- a/lang/nl/validation.php +++ b/lang/nl/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute formaat is ongeldig.', 'uploaded' => 'Het bestand kon niet worden geüpload. De server accepteert mogelijk geen bestanden van deze grootte.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/nn/activities.php b/lang/nn/activities.php index 73182e0ba84..a522d10e7fe 100644 --- a/lang/nn/activities.php +++ b/lang/nn/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'sletta webhook', 'webhook_delete_notification' => 'Webhook vart sletta', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'oppretta brukar', 'user_create_notification' => 'Brukar vart oppretta', diff --git a/lang/nn/editor.php b/lang/nn/editor.php index 7998bfadbf2..6ac0a92af09 100644 --- a/lang/nn/editor.php +++ b/lang/nn/editor.php @@ -163,6 +163,8 @@ 'about' => 'Om tekstredigeringsprogrammet', 'about_title' => 'Om HDSEHDF-tekstredigeringsprogrammet', 'editor_license' => 'Tekstbehandlerlisens og opphavsrett', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Denne tekstredigereren er laget med :tinyLink som er lisensiert under MIT.', 'editor_tiny_license_link' => 'Informasjon om opphavsrett og lisens for TinyMCE finnes her.', 'save_continue' => 'Lagre side og fortsett', diff --git a/lang/nn/entities.php b/lang/nn/entities.php index 790c6ec97b8..b2ae3526917 100644 --- a/lang/nn/entities.php +++ b/lang/nn/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF-fil', 'export_text' => 'Tekstfil', 'export_md' => 'Markdownfil', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Tilgongar', diff --git a/lang/nn/errors.php b/lang/nn/errors.php index 7be55a09177..0619c4682fc 100644 --- a/lang/nn/errors.php +++ b/lang/nn/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName er nede for øyeblikket', 'back_soon' => 'Den vil snart komme tilbake.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Ingen autorisasjonstoken ble funnet på forespørselen', 'api_bad_authorization_format' => 'Det ble funnet et autorisasjonstoken på forespørselen, men formatet virket feil', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index 9eefaf3bb7e..ed9cd937a8f 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Systemtilgang API', 'role_manage_settings' => 'Behandle applikasjonsinnstillinger', 'role_export_content' => 'Eksporter innhold', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Endre sideredigering', 'role_notifications' => 'Motta og administrere varslinger', 'role_asset' => 'Eiendomstillatelser', diff --git a/lang/nn/validation.php b/lang/nn/validation.php index 7e3784e30c7..a24ebd17189 100644 --- a/lang/nn/validation.php +++ b/lang/nn/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute format er ugyldig.', 'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/pl/activities.php b/lang/pl/activities.php index 25f9e23c669..4e209c99cf1 100644 --- a/lang/pl/activities.php +++ b/lang/pl/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'usunął webhook', 'webhook_delete_notification' => 'Webhook usunięty pomyślnie', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'utworzył użytkownika', 'user_create_notification' => 'Użytkownik utworzony pomyślnie', diff --git a/lang/pl/editor.php b/lang/pl/editor.php index f4ed4b5f2b5..ac7d57a5d61 100644 --- a/lang/pl/editor.php +++ b/lang/pl/editor.php @@ -163,6 +163,8 @@ 'about' => 'O edytorze', 'about_title' => 'O edytorze WYSIWYG', 'editor_license' => 'Licencja edytora i prawa autorskie', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ten edytor jest zbudowany przy użyciu :tinyLink, który jest udostępniany na licencji MIT.', 'editor_tiny_license_link' => 'Szczegóły dotyczące praw autorskich i licencji TinyMCE można znaleźć tutaj.', 'save_continue' => 'Zapisz stronę i kontynuuj', diff --git a/lang/pl/entities.php b/lang/pl/entities.php index f0ff5a251f5..537b15d38c3 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Plik PDF', 'export_text' => 'Plik tekstowy', 'export_md' => 'Pliki Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Domyślny szablon strony', 'default_template_explain' => 'Przypisz szablon strony, który będzie używany jako domyślna zawartość dla wszystkich stron utworzonych w tym elemencie. Pamiętaj, że będzie to używane tylko wtedy, gdy twórca strony ma dostęp do wybranej strony szablonu.', 'default_template_select' => 'Wybierz stronę szablonu', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Uprawnienia', diff --git a/lang/pl/errors.php b/lang/pl/errors.php index 71f592ad683..e6ad2093f1f 100644 --- a/lang/pl/errors.php +++ b/lang/pl/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName jest aktualnie wyłączona', 'back_soon' => 'Niedługo zostanie uruchomiona ponownie.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Nie znaleziono tokenu autoryzacji dla żądania', 'api_bad_authorization_format' => 'Token autoryzacji został znaleziony w żądaniu, ale format okazał się nieprawidłowy', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 77c25ffa93a..811398ec463 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Dostęp do systemowego API', 'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji', 'role_export_content' => 'Eksportuj zawartość', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Zmień edytor strony', 'role_notifications' => 'Odbieranie i zarządzanie powiadomieniami', 'role_asset' => 'Zarządzanie zasobami', diff --git a/lang/pl/validation.php b/lang/pl/validation.php index 935c634df29..f20a66dfb6c 100644 --- a/lang/pl/validation.php +++ b/lang/pl/validation.php @@ -105,6 +105,11 @@ 'url' => 'Format :attribute jest nieprawidłowy.', 'uploaded' => 'Plik nie może zostać wysłany. Serwer nie akceptuje plików o takim rozmiarze.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/pt/activities.php b/lang/pt/activities.php index 089613b8f59..96d950554b5 100644 --- a/lang/pt/activities.php +++ b/lang/pt/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'eliminar webhook', 'webhook_delete_notification' => 'Webhook criado com sucesso', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'utilizador criado', 'user_create_notification' => 'Utilizador criado com sucesso', diff --git a/lang/pt/editor.php b/lang/pt/editor.php index c3c4975a7ee..b25b319f0b6 100644 --- a/lang/pt/editor.php +++ b/lang/pt/editor.php @@ -163,6 +163,8 @@ 'about' => 'Sobre o editor', 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Editor da licença de direitos autorais', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor foi criado com :tinyLink que é fornecido sob a licença MIT.', 'editor_tiny_license_link' => 'Os dados relativos aos direitos de autor e à licença do TinyMCE podem ser encontrados aqui.', 'save_continue' => 'Salvar página e continuar', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index 792f80056d0..93d1a38cfab 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Arquivo PDF', 'export_text' => 'Arquivo Texto', 'export_md' => 'Ficheiro Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissões', diff --git a/lang/pt/errors.php b/lang/pt/errors.php index bed2bf5b779..b337005e14f 100644 --- a/lang/pt/errors.php +++ b/lang/pt/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName está fora do ar de momento', 'back_soon' => 'Voltaremos em breve.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Nenhum token de autorização encontrado na requisição', 'api_bad_authorization_format' => 'Um token de autorização foi encontrado na requisição, mas o formato parece incorreto', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index 8664884c0d5..935fd30ce93 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Aceder à API do sistema', 'role_manage_settings' => 'Gerir as configurações da aplicação', 'role_export_content' => 'Exportar conteúdo', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Alterar editor de página', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Permissões de Ativos', diff --git a/lang/pt/validation.php b/lang/pt/validation.php index d98f9857eb2..df414c992e2 100644 --- a/lang/pt/validation.php +++ b/lang/pt/validation.php @@ -105,6 +105,11 @@ 'url' => 'O formato da URL :attribute é inválido.', 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/pt_BR/activities.php b/lang/pt_BR/activities.php index d2b963244da..845b41cc305 100644 --- a/lang/pt_BR/activities.php +++ b/lang/pt_BR/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhook excluído', 'webhook_delete_notification' => 'Webhook excluido com sucesso', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'usuário criado', 'user_create_notification' => 'Usuário criado com sucesso', diff --git a/lang/pt_BR/editor.php b/lang/pt_BR/editor.php index 42e20af87df..e251ba96636 100644 --- a/lang/pt_BR/editor.php +++ b/lang/pt_BR/editor.php @@ -163,6 +163,8 @@ 'about' => 'Sobre o editor', 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Licença do Editor e Direitos Autorais', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor é construído usando :tinyLink que é fornecido sob a licença MIT.', 'editor_tiny_license_link' => 'Os dados relativos aos direitos de autor e à licença do TinyMCE podem ser encontrados aqui.', 'save_continue' => 'Salvar Página e Continuar', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index d23f677d9f5..b3e794a2d76 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Arquivo PDF', 'export_text' => 'Arquivo de texto simples', 'export_md' => 'Arquivo de redução', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Modelo padrão de página', 'default_template_explain' => 'Atribuir o modelo de página que será usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.', 'default_template_select' => 'Selecione uma página de modelo', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissões', @@ -85,7 +106,7 @@ // Shelves 'shelf' => 'Estante', 'shelves' => 'Estantes', - 'x_shelves' => ': count Estante|: count Estantes', + 'x_shelves' => ':count Estante|:count Estantes', 'shelves_empty' => 'Nenhuma estante foi criada', 'shelves_create' => 'Criar Prateleira', 'shelves_popular' => 'Estantes Populares', diff --git a/lang/pt_BR/errors.php b/lang/pt_BR/errors.php index 3fa7d44c7de..15464c95ac3 100644 --- a/lang/pt_BR/errors.php +++ b/lang/pt_BR/errors.php @@ -106,6 +106,18 @@ 'app_down' => 'Agora está baixo', 'back_soon' => 'Vai estar de volta em breve.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Nenhum código de autorização encontrado na requisição', 'api_bad_authorization_format' => 'Um código de autorização foi encontrado na requisição, mas o formato parece incorreto', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index 881604d4d71..a5b68c7e4ec 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Acessar API do sistema', 'role_manage_settings' => 'Gerenciar configurações da aplicação', 'role_export_content' => 'Exportar conteúdo', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Alterar página de edição', 'role_notifications' => 'Receber e gerenciar notificações', 'role_asset' => 'Permissões de Ativos', diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php index 68b85029843..b3a9a729438 100644 --- a/lang/pt_BR/validation.php +++ b/lang/pt_BR/validation.php @@ -105,6 +105,11 @@ 'url' => 'O formato da URL :attribute é inválido.', 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ro/activities.php b/lang/ro/activities.php index d076b91e82a..951ca2347e9 100644 --- a/lang/ro/activities.php +++ b/lang/ro/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'a șters webhook', 'webhook_delete_notification' => 'Webhook șters cu succes', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'utilizator creat', 'user_create_notification' => 'Utilizator creat cu succes', diff --git a/lang/ro/editor.php b/lang/ro/editor.php index 65208957b37..a22e239aa92 100644 --- a/lang/ro/editor.php +++ b/lang/ro/editor.php @@ -163,6 +163,8 @@ 'about' => 'Despre editor', 'about_title' => 'Despre editorul WYSIWYG', 'editor_license' => 'Editor licență și drepturi de autor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Acest editor este construit folosind :tinyLink care este furnizat sub licența MIT.', 'editor_tiny_license_link' => 'Detaliile privind drepturile de autor şi licența TinyMCE pot fi consultate aici.', 'save_continue' => 'Salvează pagina și continuă', diff --git a/lang/ro/entities.php b/lang/ro/entities.php index 676562f465c..f6bec4cb9a6 100644 --- a/lang/ro/entities.php +++ b/lang/ro/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Fișier PDF', 'export_text' => 'Fișier text simplu', 'export_md' => 'Fișier Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permisiuni', diff --git a/lang/ro/errors.php b/lang/ro/errors.php index 0989f797c01..07c295b0785 100644 --- a/lang/ro/errors.php +++ b/lang/ro/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName nu funcționează acum', 'back_soon' => 'Va reveni în curând.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Nu s-a găsit niciun token de autorizare la cerere', 'api_bad_authorization_format' => 'A fost găsit un token de autorizare, dar formatul este incorect', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index 338f4920036..ee402085ac9 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Accesează API sistem', 'role_manage_settings' => 'Gestionează setările aplicației', 'role_export_content' => 'Exportă conținut', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Schimbă editorul de pagină', 'role_notifications' => 'Primire și gestionare notificări', 'role_asset' => 'Permisiuni active', diff --git a/lang/ro/validation.php b/lang/ro/validation.php index 52394385098..56a3e2e05d1 100644 --- a/lang/ro/validation.php +++ b/lang/ro/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute nu este valid.', 'uploaded' => 'Fişierul nu a putut fi încărcat. Serverul nu poate accepta fişiere de această dimensiune.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ru/activities.php b/lang/ru/activities.php index df6ae9062c2..3fe46181de9 100644 --- a/lang/ru/activities.php +++ b/lang/ru/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'удалил вебхук', 'webhook_delete_notification' => 'Вебхук успешно удален', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'создал пользователя', 'user_create_notification' => 'Пользователь успешно создан', diff --git a/lang/ru/editor.php b/lang/ru/editor.php index b93bd8840a5..7e12acfff80 100644 --- a/lang/ru/editor.php +++ b/lang/ru/editor.php @@ -163,6 +163,8 @@ 'about' => 'О редакторе', 'about_title' => 'О редакторе WYSIWYG', 'editor_license' => 'Лицензия редактора и авторские права', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Этот редактор собран с помощью :tinyLink, который предоставляется под MIT лицензией.', 'editor_tiny_license_link' => 'Авторские права и подробности лицензии TinyMCE вы можете найти здесь.', 'save_continue' => 'Сохранить страницу и продолжить', diff --git a/lang/ru/entities.php b/lang/ru/entities.php index 5de25a50deb..795c287cbd2 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF файл', 'export_text' => 'Текстовый файл', 'export_md' => 'Файл Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Шаблон страницы по умолчанию', 'default_template_explain' => 'Назначить шаблон страницы, который будет использоваться в качестве содержимого по умолчанию для всех страниц, созданных в этом элементе. Имейте в виду, что это будет работать, только если создатель страницы имеет доступ к выбранной странице шаблона.', 'default_template_select' => 'Выберите страницу шаблона', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Разрешения', diff --git a/lang/ru/errors.php b/lang/ru/errors.php index 033a64840b8..ceae383c676 100644 --- a/lang/ru/errors.php +++ b/lang/ru/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName в данный момент не доступно', 'back_soon' => 'Скоро восстановится.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Отсутствует токен авторизации в запросе', 'api_bad_authorization_format' => 'Токен авторизации найден, но формат запроса неверен', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 4357cbddf38..32b5367c60a 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Доступ к системному API', 'role_manage_settings' => 'Управление настройками приложения', 'role_export_content' => 'Экспорт контента', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Изменение редактора страниц', 'role_notifications' => 'Получение и управление уведомлениями', 'role_asset' => 'Права доступа к материалам', diff --git a/lang/ru/validation.php b/lang/ru/validation.php index 88bd9a41f97..156a05fe678 100644 --- a/lang/ru/validation.php +++ b/lang/ru/validation.php @@ -105,6 +105,11 @@ 'url' => 'Формат :attribute некорректен.', 'uploaded' => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sk/activities.php b/lang/sk/activities.php index 6dad803e003..a1ef597ca24 100644 --- a/lang/sk/activities.php +++ b/lang/sk/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'odstránil(a) si webhook', 'webhook_delete_notification' => 'Webhook úspešne odstránený', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'užívateľ vytvorený', 'user_create_notification' => 'User successfully created', diff --git a/lang/sk/editor.php b/lang/sk/editor.php index ec7f24d657e..5b7ee2f49b3 100644 --- a/lang/sk/editor.php +++ b/lang/sk/editor.php @@ -163,6 +163,8 @@ 'about' => 'O editore', 'about_title' => 'O WYSIWYG Editore', 'editor_license' => 'Licencia editora a autorské práva', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Tento editor je vytvorený pomocou :tinyLink, ktorý je poskytovaný pod licenciou MIT.', 'editor_tiny_license_link' => 'Podrobnosti o autorských právach a licenciách TinyMCE nájdete tu.', 'save_continue' => 'Uložiť a pokračovať', diff --git a/lang/sk/entities.php b/lang/sk/entities.php index b296f4f6778..882ac533dfe 100644 --- a/lang/sk/entities.php +++ b/lang/sk/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF súbor', 'export_text' => 'Súbor s čistým textom', 'export_md' => 'Súbor Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Oprávnenia', diff --git a/lang/sk/errors.php b/lang/sk/errors.php index 036f2b2d415..143259f3ad4 100644 --- a/lang/sk/errors.php +++ b/lang/sk/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName je momentálne nedostupná', 'back_soon' => 'Čoskoro bude opäť dostupná.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'V žiadosti sa nenašiel žiadny autorizačný token', 'api_bad_authorization_format' => 'V žiadosti sa našiel autorizačný token, ale formát sa zdal nesprávny', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index 40b256229a5..cb8fb9fa938 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'API prístupového systému', 'role_manage_settings' => 'Spravovať nastavenia aplikácie', 'role_export_content' => 'Exportovať obsah', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Zmeniť editor stránky', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Oprávnenia majetku', diff --git a/lang/sk/validation.php b/lang/sk/validation.php index dfc821b7c9c..415f7cf05b9 100644 --- a/lang/sk/validation.php +++ b/lang/sk/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute formát je neplatný.', 'uploaded' => 'Súbor sa nepodarilo nahrať. Server nemusí akceptovať súbory tejto veľkosti.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sl/activities.php b/lang/sl/activities.php index 027c17f872a..11627a89ad9 100644 --- a/lang/sl/activities.php +++ b/lang/sl/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'izbrisan webhook', 'webhook_delete_notification' => 'Webhook uspešno izbrisan', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'ustvarjen uporabnik', 'user_create_notification' => 'Uporabnik uspešno ustvarjen', diff --git a/lang/sl/editor.php b/lang/sl/editor.php index b6f462eb596..09b311018a4 100644 --- a/lang/sl/editor.php +++ b/lang/sl/editor.php @@ -163,6 +163,8 @@ 'about' => 'O urejevalniku', 'about_title' => 'O Urejevalniku WYSIWYG', 'editor_license' => 'Licenca in avtorske pravice Urejevalnika', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Urejevalnik je ustvarjen z uporabo :tinyLink pod pogoji licence MIT.', 'editor_tiny_license_link' => 'Podrobnosti o avtorskih pravicah in licenci za TinyMCE lahko preberete tukaj.', 'save_continue' => 'Shrani stran in Nadaljuj', diff --git a/lang/sl/entities.php b/lang/sl/entities.php index be58595cfe8..e118bfaaccf 100644 --- a/lang/sl/entities.php +++ b/lang/sl/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF datoteka (.pdf)', 'export_text' => 'Navadna besedilna datoteka', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Dovoljenja', diff --git a/lang/sl/errors.php b/lang/sl/errors.php index 7269b755531..9a8759684c7 100644 --- a/lang/sl/errors.php +++ b/lang/sl/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName trenutno ni dosegljiva', 'back_soon' => 'Kmalu bo ponovno dosegljiva.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Avtorizacija ni bila najdena', 'api_bad_authorization_format' => 'Avtorizacija je bila najdena, vendar je v napačni obliki', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 7de5f24e7a8..77af9dac4b9 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'API za dostop do sistema', 'role_manage_settings' => 'Nastavitve za upravljanje', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Sistemska dovoljenja', diff --git a/lang/sl/validation.php b/lang/sl/validation.php index 012bfe4a704..0d9b56c1019 100644 --- a/lang/sl/validation.php +++ b/lang/sl/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute oblika ni veljavna.', 'uploaded' => 'Datoteke ni bilo mogoče naložiti. Strežnik morda ne sprejema datotek te velikosti.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sq/activities.php b/lang/sq/activities.php index b3590c9bf83..b7ca7d02d8d 100644 --- a/lang/sq/activities.php +++ b/lang/sq/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'fshiu uebhook', 'webhook_delete_notification' => 'Uebhook-u u fshi me sukses', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'krijoi përdorues', 'user_create_notification' => 'Përdoruesi u krijua me sukses', diff --git a/lang/sq/editor.php b/lang/sq/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/sq/editor.php +++ b/lang/sq/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/sq/entities.php b/lang/sq/entities.php index 35e6f050bb8..26a563a7eb5 100644 --- a/lang/sq/entities.php +++ b/lang/sq/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF File', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/sq/errors.php b/lang/sq/errors.php index 9c40aa9ed33..9d738379648 100644 --- a/lang/sq/errors.php +++ b/lang/sq/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/lang/sq/validation.php b/lang/sq/validation.php index 2a676c7c4cc..d9b982d1e23 100644 --- a/lang/sq/validation.php +++ b/lang/sq/validation.php @@ -105,6 +105,11 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sr/activities.php b/lang/sr/activities.php index 4077c07454d..9a72cfc5e3c 100644 --- a/lang/sr/activities.php +++ b/lang/sr/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'обрисан вебхоок', 'webhook_delete_notification' => 'Вебхоок је успешно обрисан', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'креирао корисника', 'user_create_notification' => 'Корисник је успешно креиран', diff --git a/lang/sr/editor.php b/lang/sr/editor.php index b376e542cf9..8b7690e2c34 100644 --- a/lang/sr/editor.php +++ b/lang/sr/editor.php @@ -163,6 +163,8 @@ 'about' => 'О уређивачу', 'about_title' => 'О уређивачу WYSIWYG', 'editor_license' => 'Уредничка лиценца и ауторска права', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Овај уређивач је направљен помоћу :tinyLink који је обезбеђен под МИТ лиценцом.', 'editor_tiny_license_link' => 'Детаље о ауторским правима и лиценци за ТиниМЦЕ можете пронаћи овде.', 'save_continue' => 'Сачувај страницу и настави', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index a6964a1032b..0c27ddf59db 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF датотека', 'export_text' => 'Датотеке чистог текста', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Подразумевани шаблон странице', 'default_template_explain' => 'Доделите шаблон странице који ће се користити као подразумевани садржај за све странице креиране у оквиру ове ставке. Имајте на уму да ће се ово користити само ако креатор странице има приступ за преглед изабране странице шаблона.', 'default_template_select' => 'Изаберите страницу са шаблоном', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Дозволе', diff --git a/lang/sr/errors.php b/lang/sr/errors.php index acf0d5f3650..ee8443461c3 100644 --- a/lang/sr/errors.php +++ b/lang/sr/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/sr/settings.php b/lang/sr/settings.php index e2460191383..6bd5f525237 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/lang/sr/validation.php b/lang/sr/validation.php index 2a676c7c4cc..d9b982d1e23 100644 --- a/lang/sr/validation.php +++ b/lang/sr/validation.php @@ -105,6 +105,11 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sv/activities.php b/lang/sv/activities.php index abe53c856d1..5244b224372 100644 --- a/lang/sv/activities.php +++ b/lang/sv/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'raderade webhook', 'webhook_delete_notification' => 'Webhook har tagits bort', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'skapade användare', 'user_create_notification' => 'Användare skapades', diff --git a/lang/sv/editor.php b/lang/sv/editor.php index 25e443511c1..4badcc0e343 100644 --- a/lang/sv/editor.php +++ b/lang/sv/editor.php @@ -163,6 +163,8 @@ 'about' => 'Om redigeraren', 'about_title' => 'Om WYSIWYG-redigeraren', 'editor_license' => 'Licens och upphovsrätt för redigerare', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Denna redigerare är byggd med :tinyLink som tillhandahålls under MIT licensen.', 'editor_tiny_license_link' => 'Upphovsrätten och licensuppgifterna för TinyMCE hittar du här.', 'save_continue' => 'Spara sida & fortsätt', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index d01de6380c4..5df1f43dcf0 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF-fil', 'export_text' => 'Textfil', 'export_md' => 'Markdown-fil', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Förvald sidmall', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Rättigheter', diff --git a/lang/sv/errors.php b/lang/sv/errors.php index d6049b20cb4..86ea215d02f 100644 --- a/lang/sv/errors.php +++ b/lang/sv/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName är nere just nu', 'back_soon' => 'Vi är snart tillbaka.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Ingen auktoriseringstoken hittades på denna begäran', 'api_bad_authorization_format' => 'En auktoriseringstoken hittades på denna begäran men formatet verkade felaktigt', diff --git a/lang/sv/settings.php b/lang/sv/settings.php index c9f00a34059..bd6afcf2892 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Åtkomst till systemets API', 'role_manage_settings' => 'Hantera appinställningar', 'role_export_content' => 'Exportera innehåll', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Ändra sidredigerare', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Tillgång till innehåll', diff --git a/lang/sv/validation.php b/lang/sv/validation.php index b9c609f486d..4cc98c57598 100644 --- a/lang/sv/validation.php +++ b/lang/sv/validation.php @@ -105,6 +105,11 @@ 'url' => 'Formatet på :attribute är ogiltigt.', 'uploaded' => 'Filen kunde inte laddas upp. Servern kanske inte tillåter filer med denna storlek.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/tk/activities.php b/lang/tk/activities.php index 092398ef0e1..7c3454d41ca 100644 --- a/lang/tk/activities.php +++ b/lang/tk/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/tk/editor.php b/lang/tk/editor.php index de9aa0ece10..a61b460427e 100644 --- a/lang/tk/editor.php +++ b/lang/tk/editor.php @@ -163,6 +163,8 @@ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/tk/entities.php b/lang/tk/entities.php index 35e6f050bb8..26a563a7eb5 100644 --- a/lang/tk/entities.php +++ b/lang/tk/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF File', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/tk/errors.php b/lang/tk/errors.php index 9c40aa9ed33..9d738379648 100644 --- a/lang/tk/errors.php +++ b/lang/tk/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/lang/tk/validation.php b/lang/tk/validation.php index 2a676c7c4cc..d9b982d1e23 100644 --- a/lang/tk/validation.php +++ b/lang/tk/validation.php @@ -105,6 +105,11 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/tr/activities.php b/lang/tr/activities.php index bfafba4a4f4..fcecd6de3b4 100644 --- a/lang/tr/activities.php +++ b/lang/tr/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'web kancası silindi', 'webhook_delete_notification' => 'Web kancası başarıyla silindi', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'Kullanıcı başarıyla oluşturuldu', diff --git a/lang/tr/editor.php b/lang/tr/editor.php index 585d6ec0516..3dc990f0cb9 100644 --- a/lang/tr/editor.php +++ b/lang/tr/editor.php @@ -163,6 +163,8 @@ 'about' => 'Editör hakkında', 'about_title' => 'WYSIWYG editor hakkında', 'editor_license' => 'Editor Lisans ve Telif Hakkı', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Bu düzenleyici, MIT lisansı altında sağlanan :tinyLink kullanılarak oluşturulmuştur.', 'editor_tiny_license_link' => 'TinyMCE telif ve lisans bilgilerini burada bulabilirsiniz.', 'save_continue' => 'Kaydet & Devam Et', diff --git a/lang/tr/entities.php b/lang/tr/entities.php index ae60e8d07b8..a73cdd85330 100644 --- a/lang/tr/entities.php +++ b/lang/tr/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF Dosyası', 'export_text' => 'Düz Metin Dosyası', 'export_md' => 'Markdown Dosyası', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'İzinler', diff --git a/lang/tr/errors.php b/lang/tr/errors.php index 7087aca4738..fd742687127 100644 --- a/lang/tr/errors.php +++ b/lang/tr/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName şu anda erişilemez durumda', 'back_soon' => 'En kısa sürede tekrar erişilebilir duruma gelecektir.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Yapılan istekte, yetkilendirme anahtarı bulunamadı', 'api_bad_authorization_format' => 'Yapılan istekte bir yetkilendirme anahtarı bulundu fakat doğru görünmüyor', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 8b38e7b955c..68c470b0aa9 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Sistem programlama arayüzüne (API) eriş', 'role_manage_settings' => 'Uygulama ayarlarını yönet', 'role_export_content' => 'İçeriği dışa aktar', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Yazı editörünü değiştir', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Varlık Yetkileri', diff --git a/lang/tr/validation.php b/lang/tr/validation.php index 640fe6131ce..9dbfadd6b9e 100644 --- a/lang/tr/validation.php +++ b/lang/tr/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute formatı geçersiz.', 'uploaded' => 'Dosya yüklemesi başarısız oldu. Sunucu, bu boyuttaki dosyaları kabul etmiyor olabilir.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/uk/activities.php b/lang/uk/activities.php index 1ab0eca61a8..86195224c3e 100644 --- a/lang/uk/activities.php +++ b/lang/uk/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'видалений вебхук', 'webhook_delete_notification' => 'Вебхуки успішно видалено', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'створений користувач', 'user_create_notification' => 'Користувач успішно створений', diff --git a/lang/uk/editor.php b/lang/uk/editor.php index 176f7fab713..85efbc661d9 100644 --- a/lang/uk/editor.php +++ b/lang/uk/editor.php @@ -163,6 +163,8 @@ 'about' => 'Про редактор', 'about_title' => 'Про WYSIWYG редактор', 'editor_license' => 'Ліцензія редактора і авторські права', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Цей редактор побудований за допомогою :tinylink, яке надається за ліцензією MIT.', 'editor_tiny_license_link' => 'Тут можна знайти авторські та умови ліцензії.', 'save_continue' => 'Зберегти і продовжити', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index 01549e7f36f..e955f5801af 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF файл', 'export_text' => 'Текстовий файл', 'export_md' => 'Файл розмітки', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Типовий шаблон сторінки', 'default_template_explain' => 'Призначити шаблон сторінки, який буде використовуватися як типовий вміст для всіх сторінок, створених у цьому елементі. Майте на увазі, що ця сторінка буде використана лише у випадку, якщо вона має доступ до обраної сторінки шаблону.', 'default_template_select' => 'Виберіть сторінку шаблону', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Дозволи', diff --git a/lang/uk/errors.php b/lang/uk/errors.php index 7698093c1d5..5c8148fece2 100644 --- a/lang/uk/errors.php +++ b/lang/uk/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName зараз недоступний', 'back_soon' => 'Він повернеться найближчим часом.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'У запиті не знайдено токен авторизації', 'api_bad_authorization_format' => 'У запиті знайдено токен авторизації, але формат недійсний', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index e26a2a8b829..a897439a14d 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Доступ до системного API', 'role_manage_settings' => 'Керування налаштуваннями програми', 'role_export_content' => 'Вміст експорту', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Змінити редактор сторінок', 'role_notifications' => 'Отримувати та керувати повідомленнями', 'role_asset' => 'Дозволи', diff --git a/lang/uk/validation.php b/lang/uk/validation.php index 51a59166619..ae31bb237aa 100644 --- a/lang/uk/validation.php +++ b/lang/uk/validation.php @@ -105,6 +105,11 @@ 'url' => 'Формат поля :attribute неправильний.', 'uploaded' => 'Не вдалося завантажити файл. Сервер може не приймати файли такого розміру.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/uz/activities.php b/lang/uz/activities.php index 0cbfd264a02..19d52adc49c 100644 --- a/lang/uz/activities.php +++ b/lang/uz/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'o‘chirilgan webhook', 'webhook_delete_notification' => 'Webhook muvaffaqiyatli o‘chirildi', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/uz/editor.php b/lang/uz/editor.php index f3aa4f5cdf9..deea210bab7 100644 --- a/lang/uz/editor.php +++ b/lang/uz/editor.php @@ -163,6 +163,8 @@ 'about' => 'Muharrir haqida', 'about_title' => 'WYSIWYG muharriri haqida', 'editor_license' => 'Muharrir litsenziyasi va mualliflik huquqi', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ushbu muharrir MIT litsenziyasi ostida taqdim etilgan :tinyLink yordamida yaratilgan.', 'editor_tiny_license_link' => 'TinyMCE mualliflik huquqi va litsenziya tafsilotlarini bu yerda topishingiz mumkin.', 'save_continue' => 'Sahifani saqlang va Davom eting', diff --git a/lang/uz/entities.php b/lang/uz/entities.php index f0818207121..fa198c68d8a 100644 --- a/lang/uz/entities.php +++ b/lang/uz/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF holatida', 'export_text' => 'Oddiy matn holatida', 'export_md' => 'Markdown fayli holatida', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Huquqlar', diff --git a/lang/uz/errors.php b/lang/uz/errors.php index 63a61ef844a..a0d86c44191 100644 --- a/lang/uz/errors.php +++ b/lang/uz/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName hozir ishlamayapti', 'back_soon' => 'Tez orada zaxiralanadi.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'So‘rovda hech qanday avtorizatsiya belgisi topilmadi', 'api_bad_authorization_format' => 'So‘rovda avtorizatsiya belgisi topildi, lekin format noto‘g‘ri ko‘rindi', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index 4ce7c06094a..fabef538314 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Kirish tizimi API', 'role_manage_settings' => 'Ilova sozlamalarini boshqaring', 'role_export_content' => 'Kontentni eksport qilish', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Sahifa muharririni o\'zgartirish', 'role_notifications' => 'Bildirishnomalarni qabul qilish va boshqarish', 'role_asset' => 'Obyektga ruxsatlar', diff --git a/lang/uz/validation.php b/lang/uz/validation.php index 4606a012879..d9d0fa0b2af 100644 --- a/lang/uz/validation.php +++ b/lang/uz/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute URL formatida emas.', 'uploaded' => 'Faylni yuklashda xatolik. Server bunday hajmdagi faylllarni yuklamasligi mumkin.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/vi/activities.php b/lang/vi/activities.php index d11de40e4a6..2262e37a005 100644 --- a/lang/vi/activities.php +++ b/lang/vi/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'đã xóa webhook', 'webhook_delete_notification' => 'Webhook đã được xóa thành công', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'đã tạo người dùng', 'user_create_notification' => 'Người dùng được tạo thành công', diff --git a/lang/vi/editor.php b/lang/vi/editor.php index 76203c2d231..2e36df8dd9e 100644 --- a/lang/vi/editor.php +++ b/lang/vi/editor.php @@ -163,6 +163,8 @@ 'about' => 'Giới thiệu về trình soạn thảo', 'about_title' => 'Giới thiệu về trình soạn thảo WYSIWYG', 'editor_license' => 'Giáy phép & Bản quyền của trình soạn thảo', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Trình soạn thảo này được xây dựng bằng các sử dụng :tinyLink theo giấy phép MIT.', 'editor_tiny_license_link' => 'Chi tiết về bản quyền và giấy phép của TinyMCE có thể được tìm thấy tại đây.', 'save_continue' => 'Lưu trang & Tiếp tục', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index f8f6ca293bb..11c47a8d5eb 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'Tệp PDF', 'export_text' => 'Tệp văn bản thuần túy', 'export_md' => 'Tệp Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Quyền', diff --git a/lang/vi/errors.php b/lang/vi/errors.php index a2a7d491b4a..66ad23b7278 100644 --- a/lang/vi/errors.php +++ b/lang/vi/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName hiện đang ngoại tuyến', 'back_soon' => 'Nó sẽ sớm hoạt động trở lại.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => 'Không tìm thấy token ủy quyền trong yêu cầu', 'api_bad_authorization_format' => 'Đã tìm thấy một token ủy quyền trong yêu cầu nhưng định dạng hiển thị không hợp lệ', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index 63024cbd39a..410d28d5941 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Truy cập đến API hệ thống', 'role_manage_settings' => 'Quản lý cài đặt của ứng dụng', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Quyền tài sản (asset)', diff --git a/lang/vi/validation.php b/lang/vi/validation.php index e7bcfd3e2a9..9683c085726 100644 --- a/lang/vi/validation.php +++ b/lang/vi/validation.php @@ -105,6 +105,11 @@ 'url' => 'Định dạng của :attribute không hợp lệ.', 'uploaded' => 'Tệp tin đã không được tải lên. Máy chủ không chấp nhận các tệp tin với dung lượng lớn như tệp tin trên.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/zh_CN/activities.php b/lang/zh_CN/activities.php index 54c93b0efea..7edf9bc79fc 100644 --- a/lang/zh_CN/activities.php +++ b/lang/zh_CN/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'Webhook 已删除', 'webhook_delete_notification' => 'Webhook 删除成功', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => '用户已创建', 'user_create_notification' => '用户创建成功', diff --git a/lang/zh_CN/components.php b/lang/zh_CN/components.php index f04a254ec09..c2ca50d3691 100644 --- a/lang/zh_CN/components.php +++ b/lang/zh_CN/components.php @@ -17,7 +17,7 @@ 'image_page_title' => '查看上传到本页面的图片', 'image_search_hint' => '按图片名称搜索', 'image_uploaded' => '上传于 :uploadedDate', - 'image_uploaded_by' => '由 :username 上传', + 'image_uploaded_by' => '由 :userName 上传', 'image_uploaded_to' => '上传到 :pageLink', 'image_updated' => ':updateDate 更新', 'image_load_more' => '显示更多', diff --git a/lang/zh_CN/editor.php b/lang/zh_CN/editor.php index 1a07362a553..5f4ffdcb96b 100644 --- a/lang/zh_CN/editor.php +++ b/lang/zh_CN/editor.php @@ -163,6 +163,8 @@ 'about' => '关于编辑器', 'about_title' => '关于所见即所得(WYSIWYG)编辑器', 'editor_license' => '编辑器许可证与版权信息', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => '此编辑器是用 :tinyLink 构建的,基于 MIT 许可证。', 'editor_tiny_license_link' => 'TinyMCE 的版权和许可证详细信息可以在这里找到。', 'save_continue' => '保存页面并继续', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 5083b263e6f..9f6ee7ecfb3 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF文件', 'export_text' => '纯文本文件', 'export_md' => 'Markdown 文件', + 'export_zip' => 'Portable ZIP', 'default_template' => '默认页面模板', 'default_template_explain' => '指定一个页面模板,该模板将作为此项目中所有页面的默认内容。请注意,仅当页面创建者具有对所选页面模板的查看访问权限时,此功能才会生效。', 'default_template_select' => '选择模板页面', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', + 'import_run' => 'Run Import', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => '权限', diff --git a/lang/zh_CN/errors.php b/lang/zh_CN/errors.php index b51be47c106..3c5f262a166 100644 --- a/lang/zh_CN/errors.php +++ b/lang/zh_CN/errors.php @@ -105,6 +105,18 @@ 'app_down' => ':appName现在正在关闭', 'back_soon' => '请耐心等待网站的恢复。', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + // API errors 'api_no_authorization_found' => '未在请求中找到授权令牌', 'api_bad_authorization_format' => '已在请求中找到授权令牌,但格式貌似不正确', diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php index 4eae4a98dfa..3343877ab58 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => '访问系统 API', 'role_manage_settings' => '管理 App 设置', 'role_export_content' => '导出内容', + 'role_import_content' => 'Import content', 'role_editor_change' => '更改页面编辑器', 'role_notifications' => '管理和接收通知', 'role_asset' => '资源许可', diff --git a/lang/zh_CN/validation.php b/lang/zh_CN/validation.php index 9f24f3339da..120dfd998c8 100644 --- a/lang/zh_CN/validation.php +++ b/lang/zh_CN/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute 格式无效。', 'uploaded' => '无法上传文件。 服务器可能不接受此大小的文件。', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', + 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/zh_TW/activities.php b/lang/zh_TW/activities.php index dddca399ac9..be12e22c2f4 100644 --- a/lang/zh_TW/activities.php +++ b/lang/zh_TW/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'webhook 已刪除', 'webhook_delete_notification' => 'Webhook 已刪除成功', + // Imports + 'import_create' => '已建立匯入', + 'import_create_notification' => '成功上傳匯入', + 'import_run' => '已更新匯入', + 'import_run_notification' => '成功匯入內容', + 'import_delete' => '已刪除匯入', + 'import_delete_notification' => '匯入刪除成功', + // Users 'user_create' => '建立使用者', 'user_create_notification' => '使用者已成功建立。', diff --git a/lang/zh_TW/common.php b/lang/zh_TW/common.php index 8b003a7303b..f8023bdd5ea 100644 --- a/lang/zh_TW/common.php +++ b/lang/zh_TW/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => '服務條款', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => '搜尋 :appName', ]; diff --git a/lang/zh_TW/editor.php b/lang/zh_TW/editor.php index 2ee37f863de..7eeb48501ac 100644 --- a/lang/zh_TW/editor.php +++ b/lang/zh_TW/editor.php @@ -163,6 +163,8 @@ 'about' => '關於編輯器', 'about_title' => '關於所見即所得(WYSIWYG)編輯器', 'editor_license' => '編輯器許可證與版權信息', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => '此編輯器是用 :tinyLink 構建的,基於 MIT 許可證。', 'editor_tiny_license_link' => 'TinyMCE 的版權和許可證詳細信息可以在這裡找到。', 'save_continue' => '保存頁面並繼續', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index 2f96bea2e56..ee8113bee79 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -39,9 +39,30 @@ 'export_pdf' => 'PDF 檔案', 'export_text' => '純文字檔案', 'export_md' => 'Markdown 檔案', + 'export_zip' => '可攜式 ZIP', 'default_template' => '預設頁面範本', 'default_template_explain' => '請設定一個頁面範本,作為新頁面的預設內容。請注意,這僅限於作者擁有頁面範本讀取權限時才能夠使用。', 'default_template_select' => '選擇一個頁面範本', + 'import' => '匯入', + 'import_validate' => '驗證匯入', + 'import_desc' => '使用從相同或不同站點匯出的可攜式壓縮檔匯入書本、章節與頁面。選取 ZIP 檔案繼續。檔案上傳並通過驗證後,您就可以在下一個檢視中設定並確認匯入。', + 'import_zip_select' => '選取要上傳的 ZIP 檔案', + 'import_zip_validation_errors' => '驗證提供的 ZIP 檔案時偵測到錯誤:', + 'import_pending' => '擱置中的匯入', + 'import_pending_none' => '尚未開始匯入。', + 'import_continue' => '繼續匯入', + 'import_continue_desc' => '檢視要從上傳的 ZIP 檔匯入的內容。準備就緒後,執行匯入以將其內容加入本系統。成功匯入後,上傳的 ZIP 匯入檔案會自動移除。', + 'import_details' => '匯入詳細資訊', + 'import_run' => '執行匯入', + 'import_size' => ':size 匯入 ZIP 大小', + 'import_uploaded_at' => ':relativeTime 已上傳', + 'import_uploaded_by' => '上傳者', + 'import_location' => '匯入位置', + 'import_location_desc' => '為您匯入的內容選取目標位置。您需要相關權限才能在您選擇的位置內建立。', + 'import_delete_confirm' => '您確定要刪除此匯入嗎?', + 'import_delete_desc' => '這將會刪除已上傳的匯入 ZIP 檔案,且無法還原。', + 'import_errors' => '匯入錯誤', + 'import_errors_desc' => '嘗試匯入時發生以下錯誤:', // Permissions and restrictions 'permissions' => '權限', @@ -224,8 +245,8 @@ 'pages_edit_switch_to_markdown_clean' => '(清除內容)', 'pages_edit_switch_to_markdown_stable' => '(保留內容)', 'pages_edit_switch_to_wysiwyg' => '切換到所見即所得編輯器', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => '切換為新的所見即所得編輯器', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(仍在開發測試階段)', 'pages_edit_set_changelog' => '設定變更日誌', 'pages_edit_enter_changelog_desc' => '輸入對您所做變動的簡易描述', 'pages_edit_enter_changelog' => '輸入變更日誌', diff --git a/lang/zh_TW/errors.php b/lang/zh_TW/errors.php index 172c8ad0f82..7a42ce5119c 100644 --- a/lang/zh_TW/errors.php +++ b/lang/zh_TW/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => '您不能刪除唯一的管理員帳號', 'users_cannot_delete_guest' => '您不能刪除訪客使用者', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => '由於寄送邀請電子郵件失敗,因此無法建立使用者', // Roles 'role_cannot_be_edited' => '無法編輯這個角色', @@ -105,6 +105,18 @@ 'app_down' => ':appName 離線中', 'back_soon' => '它應該很快就會重新上線。', + // Import + 'import_zip_cant_read' => '無法讀取 ZIP 檔案。', + 'import_zip_cant_decode_data' => '無法尋找並解碼 ZIP data.json 內容。', + 'import_zip_no_data' => 'ZIP 檔案資料沒有預期的書本、章節或頁面內容。', + 'import_validation_failed' => '匯入 ZIP 驗證失敗,發生錯誤:', + 'import_zip_failed_notification' => '匯入 ZIP 檔案失敗。', + 'import_perms_books' => '您缺乏建立書本所需的權限。', + 'import_perms_chapters' => '您缺乏建立章節所需的權限。', + 'import_perms_pages' => '您缺乏建立頁面所需的權限。', + 'import_perms_images' => '您缺乏建立影像所需的權限。', + 'import_perms_attachments' => '您缺乏建立附件所需的權限。', + // API errors 'api_no_authorization_found' => '在請求上找不到授權權杖', 'api_bad_authorization_format' => '在請求中找到授權權杖,但格式似乎不正確', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 9c893f1fae0..b50d7649e7a 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -163,6 +163,7 @@ 'role_access_api' => '存取系統 API', 'role_manage_settings' => '管理應用程式設定', 'role_export_content' => '匯出內容', + 'role_import_content' => '匯入內容', 'role_editor_change' => '重設頁面編輯器', 'role_notifications' => '管理和接收通知', 'role_asset' => '資源權限', diff --git a/lang/zh_TW/validation.php b/lang/zh_TW/validation.php index 6651f13966c..30031470749 100644 --- a/lang/zh_TW/validation.php +++ b/lang/zh_TW/validation.php @@ -105,6 +105,11 @@ 'url' => ':attribute 格式無效。', 'uploaded' => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。', + 'zip_file' => ':attribute 需要參照 ZIP 中的檔案。', + 'zip_file_mime' => ':attribute 需要參照類型為 :validTypes 的檔案,找到 :foundType。', + 'zip_model_expected' => '預期為資料物件,但找到「:type」。', + 'zip_unique' => '對於 ZIP 中的物件類型,:attribute 必須是唯一的。', + // Custom validation lines 'custom' => [ 'password-confirm' => [ From d56eea9279d37444265563d2794bba475719deb9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Dec 2024 11:27:58 +0000 Subject: [PATCH 274/800] Locales: Updated locale list with new languages --- app/Translation/LocaleManager.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php index ca71df64931..0e2e7372ff6 100644 --- a/app/Translation/LocaleManager.php +++ b/app/Translation/LocaleManager.php @@ -21,6 +21,7 @@ class LocaleManager protected array $localeMap = [ 'ar' => 'ar', 'bg' => 'bg_BG', + 'bn' => 'bn_BD', 'bs' => 'bs_BA', 'ca' => 'ca', 'cs' => 'cs_CZ', @@ -41,6 +42,7 @@ class LocaleManager 'hr' => 'hr_HR', 'hu' => 'hu_HU', 'id' => 'id_ID', + 'is' => 'is_IS', 'it' => 'it_IT', 'ja' => 'ja', 'ka' => 'ka_GE', From 980a684b1468fa15caa509aba534732b68a77fbb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Dec 2024 11:53:35 +0000 Subject: [PATCH 275/800] Updated translator & dependency attribution before release v24.12 --- .github/translators.txt | 6 ++++ dev/licensing/js-library-licenses.txt | 39 ++++++++++++++++++++++++++ dev/licensing/php-library-licenses.txt | 10 +++---- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index 9699be70f8b..9844f65b366 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -455,3 +455,9 @@ Rivo Zängov (Eraser) :: Estonian Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional madnjpn (madnjpn.) :: Georgian +Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic +Mohammad Aftab Uddin (chirohorit) :: Bengali +Yannis Karlaftis (meliseus) :: Greek +felixxx :: German Informal +randi (randi65535) :: Korean +test65428 :: Greek diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index a6a01cdcc4f..479941db199 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -507,6 +507,12 @@ Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***> Source: git://github.com/felixge/node-delayed-stream.git Link: https://github.com/felixge/node-delayed-stream ----------- +detect-libc +License: Apache-2.0 +License File: node_modules/detect-libc/LICENSE +Source: git://github.com/lovell/detect-libc +Link: git://github.com/lovell/detect-libc +----------- detect-newline License: MIT License File: node_modules/detect-newline/license @@ -1819,6 +1825,13 @@ Copyright: Copyright (c) 2018 Tobias Reich Source: https://github.com/electerious/nice-try.git Link: https://github.com/electerious/nice-try ----------- +node-addon-api +License: MIT +License File: node_modules/node-addon-api/LICENSE.md +Copyright: Copyright (c) 2017 [Node.js API collaborators](https://github.com/nodejs/node-addon-api#collaborators) +Source: git://github.com/nodejs/node-addon-api.git +Link: https://github.com/nodejs/node-addon-api +----------- node-int64 License: MIT License File: node_modules/node-int64/LICENSE @@ -3525,6 +3538,11 @@ Copyright: Copyright (C) 2018 by Marijn Haverbeke <******@*********.******> and Source: https://github.com/lezer-parser/xml.git Link: https://github.com/lezer-parser/xml.git ----------- +@marijn/find-cluster-break +License: MIT +Source: git+https://github.com/marijnh/find-cluster-break.git +Link: https://github.com/marijnh/find-cluster-break#readme +----------- @nodelib/fs.scandir License: MIT License File: node_modules/@nodelib/fs.scandir/LICENSE @@ -3546,6 +3564,27 @@ Copyright: Copyright (c) Denis Malinochkin Source: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk Link: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk ----------- +@parcel/watcher-linux-x64-glibc +License: MIT +License File: node_modules/@parcel/watcher-linux-x64-glibc/LICENSE +Copyright: Copyright (c) 2017-present Devon Govett +Source: https://github.com/parcel-bundler/watcher.git +Link: https://github.com/parcel-bundler/watcher.git +----------- +@parcel/watcher-linux-x64-musl +License: MIT +License File: node_modules/@parcel/watcher-linux-x64-musl/LICENSE +Copyright: Copyright (c) 2017-present Devon Govett +Source: https://github.com/parcel-bundler/watcher.git +Link: https://github.com/parcel-bundler/watcher.git +----------- +@parcel/watcher +License: MIT +License File: node_modules/@parcel/watcher/LICENSE +Copyright: Copyright (c) 2017-present Devon Govett +Source: https://github.com/parcel-bundler/watcher.git +Link: https://github.com/parcel-bundler/watcher.git +----------- @rtsao/scc License: MIT License File: node_modules/@rtsao/scc/LICENSE diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 747ccc986d7..bf3619036f7 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -202,7 +202,7 @@ Link: https://github.com/intervention/gif intervention/image License: MIT License File: vendor/intervention/image/LICENSE -Copyright: Copyright (c) 2013-2024 Oliver Vogel +Copyright: Copyright (c) 2013-present Oliver Vogel Source: https://github.com/Intervention/image.git Link: https://image.intervention.io/ ----------- @@ -307,7 +307,7 @@ Link: https://github.com/thephpleague/oauth1-client.git league/oauth2-client License: MIT License File: vendor/league/oauth2-client/LICENSE -Copyright: Copyright (c) 2013-2020 Alex Bilbie <*****@**********.***> +Copyright: Copyright (c) 2013-2023 Alex Bilbie <*****@**********.***> Source: https://github.com/thephpleague/oauth2-client.git Link: https://github.com/thephpleague/oauth2-client.git ----------- @@ -560,9 +560,9 @@ Link: https://github.com/SocialiteProviders/Twitch.git ssddanbrown/htmldiff License: MIT License File: vendor/ssddanbrown/htmldiff/license.md -Copyright: Copyright (c) 2020 Nathan Herald, Rohland de Charmoy, Dan Brown -Source: https://github.com/ssddanbrown/HtmlDiff.git -Link: https://github.com/ssddanbrown/htmldiff +Copyright: Copyright (c) 2024 Nathan Herald, Rohland de Charmoy, Dan Brown +Source: https://codeberg.org/danb/HtmlDiff +Link: https://codeberg.org/danb/HtmlDiff ----------- ssddanbrown/symfony-mailer License: MIT From 6d7ff59a89e572f7ce25057a858e5223740b8549 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 31 Dec 2024 15:13:50 +0000 Subject: [PATCH 276/800] ZIP Exports: Improved temp file tracking & clean-up --- .../Controllers/BookExportController.php | 2 +- .../Controllers/ChapterExportController.php | 2 +- .../Controllers/PageExportController.php | 2 +- app/Exports/ZipExports/ZipExportBuilder.php | 25 ++++++++++++++--- app/Http/DownloadResponseFactory.php | 28 ++++++++++++++++++- tests/Exports/ZipExportTest.php | 19 +++++++++++++ 6 files changed, 70 insertions(+), 8 deletions(-) diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php index f726175a086..184f7c2350e 100644 --- a/app/Exports/Controllers/BookExportController.php +++ b/app/Exports/Controllers/BookExportController.php @@ -75,6 +75,6 @@ public function zip(string $bookSlug, ZipExportBuilder $builder) $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $zip = $builder->buildForBook($book); - return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip)); + return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', filesize($zip), true); } } diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index 0d7a5c0d195..4748ca6a80a 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -81,6 +81,6 @@ public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $bui $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $zip = $builder->buildForChapter($chapter); - return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip)); + return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', filesize($zip), true); } } diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index 34e67ffcf70..203495fe900 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -85,6 +85,6 @@ public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builde $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $zip = $builder->buildForPage($page); - return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip)); + return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); } } diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php index 4c5c638f591..9a52c9a3cb8 100644 --- a/app/Exports/ZipExports/ZipExportBuilder.php +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -84,10 +84,27 @@ protected function build(): string $zip->addEmptyDir('files'); $toRemove = []; - $this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) { - $zip->addFile($filePath, "files/$fileRef"); - $toRemove[] = $filePath; - }); + $addedNames = []; + + try { + $this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove, &$addedNames) { + $entryName = "files/$fileRef"; + $zip->addFile($filePath, $entryName); + $toRemove[] = $filePath; + $addedNames[] = $entryName; + }); + } catch (\Exception $exception) { + // Cleanup the files we've processed so far and respond back with error + foreach ($toRemove as $file) { + unlink($file); + } + foreach ($addedNames as $name) { + $zip->deleteName($name); + } + $zip->close(); + unlink($zipFile); + throw new ZipExportException("Failed to add files for ZIP export, received error: " . $exception->getMessage()); + } $zip->close(); diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index f29aaa2e45a..d06e2bac44d 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -9,7 +9,7 @@ class DownloadResponseFactory { public function __construct( - protected Request $request + protected Request $request, ) { } @@ -35,6 +35,32 @@ public function streamedDirectly($stream, string $fileName, int $fileSize): Stre ); } + /** + * Create a response that downloads the given file via a stream. + * Has the option to delete the provided file once the stream is closed. + */ + public function streamedFileDirectly(string $filePath, string $fileName, int $fileSize, bool $deleteAfter = false): StreamedResponse + { + $stream = fopen($filePath, 'r'); + + if ($deleteAfter) { + // Delete the given file if it still exists after the app terminates + $callback = function () use ($filePath) { + if (file_exists($filePath)) { + unlink($filePath); + } + }; + + // We watch both app terminate and php shutdown to cover both normal app termination + // as well as other potential scenarios (connection termination). + app()->terminating($callback); + register_shutdown_function($callback); + } + + return $this->streamedDirectly($stream, $fileName, $fileSize); + } + + /** * Create a file download response that provides the file with a content-type * correct for the file, in a way so the browser can show the content in browser, diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 163828c1b6e..0e17bc0e01d 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -7,6 +7,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Uploads\Attachment; use BookStack\Uploads\Image; +use FilesystemIterator; use Illuminate\Support\Carbon; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -60,6 +61,24 @@ public function test_export_metadata() $this->assertEquals($instanceId, $zipInstanceId); } + public function test_export_leaves_no_temp_files() + { + $tempDir = sys_get_temp_dir(); + $startTempFileCount = iterator_count((new FileSystemIterator($tempDir, FilesystemIterator::SKIP_DOTS))); + + $page = $this->entities->pageWithinChapter(); + $this->asEditor(); + $pageResp = $this->get($page->getUrl("/export/zip")); + $pageResp->streamedContent(); + $pageResp->assertOk(); + $this->get($page->chapter->getUrl("/export/zip"))->assertOk(); + $this->get($page->book->getUrl("/export/zip"))->assertOk(); + + $afterTempFileCount = iterator_count((new FileSystemIterator($tempDir, FilesystemIterator::SKIP_DOTS))); + + $this->assertEquals($startTempFileCount, $afterTempFileCount); + } + public function test_page_export() { $page = $this->entities->page(); From 7e31725d48f502a3c51d0382c874affa13fdf8ff Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 1 Jan 2025 15:19:11 +0000 Subject: [PATCH 277/800] Exports: Improved PDF command temp file cleanup --- app/Exports/PdfGenerator.php | 12 +++++++++++- tests/Exports/PdfExportTest.php | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/Exports/PdfGenerator.php b/app/Exports/PdfGenerator.php index 0524e063fb2..f31d8aad078 100644 --- a/app/Exports/PdfGenerator.php +++ b/app/Exports/PdfGenerator.php @@ -90,18 +90,28 @@ protected function renderUsingCommand(string $html): string $process = Process::fromShellCommandline($command); $process->setTimeout($timeout); + $cleanup = function () use ($inputHtml, $outputPdf) { + foreach ([$inputHtml, $outputPdf] as $file) { + if (file_exists($file)) { + unlink($file); + } + } + }; + try { $process->run(); } catch (ProcessTimedOutException $e) { + $cleanup(); throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)"); } if (!$process->isSuccessful()) { + $cleanup(); throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}"); } $pdfContents = file_get_contents($outputPdf); - unlink($outputPdf); + $cleanup(); if ($pdfContents === false) { throw new PdfExportException("PDF Export via command failed, unable to read PDF output file"); diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php index 9d85c69e23a..e4de87d0d2f 100644 --- a/tests/Exports/PdfExportTest.php +++ b/tests/Exports/PdfExportTest.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Page; use BookStack\Exceptions\PdfExportException; use BookStack\Exports\PdfGenerator; +use FilesystemIterator; use Tests\TestCase; class PdfExportTest extends TestCase @@ -128,7 +129,7 @@ public function test_pdf_command_option_errors_if_command_returns_error_status() }, PdfExportException::class); } - public function test_pdf_command_timout_option_limits_export_time() + public function test_pdf_command_timeout_option_limits_export_time() { $page = $this->entities->page(); $command = 'php -r \'sleep(4);\''; @@ -143,4 +144,19 @@ public function test_pdf_command_timout_option_limits_export_time() }, PdfExportException::class, "PDF Export via command failed due to timeout at 1 second(s)"); } + + public function test_pdf_command_option_does_not_leave_temp_files() + { + $tempDir = sys_get_temp_dir(); + $startTempFileCount = iterator_count((new FileSystemIterator($tempDir, FilesystemIterator::SKIP_DOTS))); + + $page = $this->entities->page(); + $command = 'cp {input_html_path} {output_pdf_path}'; + config()->set('exports.pdf_command', $command); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + + $afterTempFileCount = iterator_count((new FileSystemIterator($tempDir, FilesystemIterator::SKIP_DOTS))); + $this->assertEquals($startTempFileCount, $afterTempFileCount); + } } From 1ff28266787b0f160697cdc74dfb05907611fcc6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 1 Jan 2025 15:42:59 +0000 Subject: [PATCH 278/800] Exports: Added rate limits for UI exports Just as a measure to prevent potential abuse of these potentially longer-running endpoints. Adds test to cover for ZIP exports, but applied to all formats. --- app/App/Providers/RouteServiceProvider.php | 7 ++++++ .../Controllers/BookExportController.php | 1 + .../Controllers/ChapterExportController.php | 1 + .../Controllers/PageExportController.php | 1 + tests/Exports/ZipExportTest.php | 22 +++++++++++++++++++ 5 files changed, 32 insertions(+) diff --git a/app/App/Providers/RouteServiceProvider.php b/app/App/Providers/RouteServiceProvider.php index d7c1cb73756..97c3e7c770d 100644 --- a/app/App/Providers/RouteServiceProvider.php +++ b/app/App/Providers/RouteServiceProvider.php @@ -85,5 +85,12 @@ protected function configureRateLimiting(): void RateLimiter::for('public', function (Request $request) { return Limit::perMinute(10)->by($request->ip()); }); + + RateLimiter::for('exports', function (Request $request) { + $user = user(); + $attempts = $user->isGuest() ? 4 : 10; + $key = $user->isGuest() ? $request->ip() : $user->id; + return Limit::perMinute($attempts)->by($key); + }); } } diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php index 184f7c2350e..b6b1006bd61 100644 --- a/app/Exports/Controllers/BookExportController.php +++ b/app/Exports/Controllers/BookExportController.php @@ -16,6 +16,7 @@ public function __construct( protected ExportFormatter $exportFormatter, ) { $this->middleware('can:content-export'); + $this->middleware('throttle:exports'); } /** diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index 4748ca6a80a..de2385bb11f 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -16,6 +16,7 @@ public function __construct( protected ExportFormatter $exportFormatter, ) { $this->middleware('can:content-export'); + $this->middleware('throttle:exports'); } /** diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index 203495fe900..d7145411eaa 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -17,6 +17,7 @@ public function __construct( protected ExportFormatter $exportFormatter, ) { $this->middleware('can:content-export'); + $this->middleware('throttle:exports'); } /** diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 0e17bc0e01d..1434c013f73 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -423,6 +423,28 @@ public function test_links_in_markdown_are_parsed() $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']); } + public function test_exports_rate_limited_low_for_guest_viewers() + { + $this->setSettings(['app-public' => 'true']); + + $page = $this->entities->page(); + for ($i = 0; $i < 4; $i++) { + $this->get($page->getUrl("/export/zip"))->assertOk(); + } + $this->get($page->getUrl("/export/zip"))->assertStatus(429); + } + + public function test_exports_rate_limited_higher_for_logged_in_viewers() + { + $this->asAdmin(); + + $page = $this->entities->page(); + for ($i = 0; $i < 10; $i++) { + $this->get($page->getUrl("/export/zip"))->assertOk(); + } + $this->get($page->getUrl("/export/zip"))->assertStatus(429); + } + protected function extractZipResponse(TestResponse $response): ZipResultData { $zipData = $response->streamedContent(); From ff6c5aaecbd470204136e8368eeed69d38d05d15 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 4 Jan 2025 21:01:28 +0000 Subject: [PATCH 279/800] Markdown Editor: Fixed scroll jump on image upload For #5384 --- resources/js/markdown/actions.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/js/markdown/actions.js b/resources/js/markdown/actions.js index 86e25532802..e99bbf3e14f 100644 --- a/resources/js/markdown/actions.js +++ b/resources/js/markdown/actions.js @@ -445,8 +445,12 @@ export class Actions { selectionRange = selectionRange || this.#getSelectionRange(); const newDoc = this.editor.cm.state.toText(text); const newSelectFrom = Math.min(selectionRange.from, newDoc.length); + const scrollTop = this.editor.cm.scrollDOM.scrollTop; this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom); this.focus(); + window.requestAnimationFrame(() => { + this.editor.cm.scrollDOM.scrollTop = scrollTop; + }); } /** From 9a5c287470662817a12ce0be08f71fc708f64189 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 4 Jan 2025 21:45:36 +0000 Subject: [PATCH 280/800] Deps: Updated composer packages --- composer.lock | 244 ++++++++++++++++++++++++-------------------------- 1 file changed, 117 insertions(+), 127 deletions(-) diff --git a/composer.lock b/composer.lock index 2a71d3e7cb0..16156f2dc11 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.336.2", + "version": "3.336.8", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4" + "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/954bfdfc048840ca34afe2a2e1cbcff6681989c4", - "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", + "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.336.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.8" }, - "time": "2024-12-20T19:05:10+00:00" + "time": "2025-01-03T19:06:11+00:00" }, { "name": "bacon/bacon-qr-code", @@ -978,16 +978,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.0.1", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6" + "reference": "baf4084b27c7f4b5b7a221b19a94d11327664eb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6", - "reference": "2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/baf4084b27c7f4b5b7a221b19a94d11327664eb8", + "reference": "baf4084b27c7f4b5b7a221b19a94d11327664eb8", "shasum": "" }, "require": { @@ -1003,7 +1003,7 @@ "ext-json": "*", "ext-zip": "*", "mockery/mockery": "^1.3", - "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", "squizlabs/php_codesniffer": "^3.5", "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" }, @@ -1036,9 +1036,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.0.1" + "source": "https://github.com/dompdf/dompdf/tree/v3.0.2" }, - "time": "2024-12-05T14:59:38+00:00" + "time": "2024-12-27T20:27:37+00:00" }, { "name": "dompdf/php-font-lib", @@ -1198,16 +1198,16 @@ }, { "name": "egulias/email-validator", - "version": "4.0.2", + "version": "4.0.3", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e" + "reference": "b115554301161fa21467629f1e1391c1936de517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e", - "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517", + "reference": "b115554301161fa21467629f1e1391c1936de517", "shasum": "" }, "require": { @@ -1253,7 +1253,7 @@ ], "support": { "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/4.0.2" + "source": "https://github.com/egulias/EmailValidator/tree/4.0.3" }, "funding": [ { @@ -1261,7 +1261,7 @@ "type": "github" } ], - "time": "2023-10-06T06:47:41+00:00" + "time": "2024-12-27T00:36:43+00:00" }, { "name": "firebase/php-jwt", @@ -1940,16 +1940,16 @@ }, { "name": "intervention/image", - "version": "3.10.0", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "1ddc9a096b3a641958515700e09be910bf03a5bd" + "reference": "1c68e5fdf443374f3580c5b920a7f177eccdab85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/1ddc9a096b3a641958515700e09be910bf03a5bd", - "reference": "1ddc9a096b3a641958515700e09be910bf03a5bd", + "url": "https://api.github.com/repos/Intervention/image/zipball/1c68e5fdf443374f3580c5b920a7f177eccdab85", + "reference": "1c68e5fdf443374f3580c5b920a7f177eccdab85", "shasum": "" }, "require": { @@ -1996,7 +1996,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.10.0" + "source": "https://github.com/Intervention/image/tree/3.10.2" }, "funding": [ { @@ -2012,7 +2012,7 @@ "type": "ko_fi" } ], - "time": "2024-12-21T07:41:40+00:00" + "time": "2025-01-04T07:31:37+00:00" }, { "name": "knplabs/knp-snappy", @@ -2547,16 +2547,16 @@ }, { "name": "league/commonmark", - "version": "2.6.0", + "version": "2.6.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "d150f911e0079e90ae3c106734c93137c184f932" + "reference": "d990688c91cedfb69753ffc2512727ec646df2ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d150f911e0079e90ae3c106734c93137c184f932", - "reference": "d150f911e0079e90ae3c106734c93137c184f932", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad", + "reference": "d990688c91cedfb69753ffc2512727ec646df2ad", "shasum": "" }, "require": { @@ -2650,7 +2650,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T15:34:16+00:00" + "time": "2024-12-29T14:10:59+00:00" }, { "name": "league/config", @@ -3445,16 +3445,16 @@ }, { "name": "nesbot/carbon", - "version": "2.72.5", + "version": "2.72.6", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "afd46589c216118ecd48ff2b95d77596af1e57ed" + "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/afd46589c216118ecd48ff2b95d77596af1e57ed", - "reference": "afd46589c216118ecd48ff2b95d77596af1e57ed", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", + "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5", "shasum": "" }, "require": { @@ -3474,7 +3474,7 @@ "doctrine/orm": "^2.7 || ^3.0", "friendsofphp/php-cs-fixer": "^3.0", "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "*", + "ondrejmirtes/better-reflection": "<6", "phpmd/phpmd": "^2.9", "phpstan/extension-installer": "^1.0", "phpstan/phpstan": "^0.12.99 || ^1.7.14", @@ -3548,7 +3548,7 @@ "type": "tidelift" } ], - "time": "2024-06-03T19:18:41+00:00" + "time": "2024-12-27T09:28:11+00:00" }, { "name": "nette/schema", @@ -3700,16 +3700,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.4.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "447a020a1f875a434d62f2a401f53b82a396e494" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", + "reference": "447a020a1f875a434d62f2a401f53b82a396e494", "shasum": "" }, "require": { @@ -3752,9 +3752,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2024-12-30T11:07:19+00:00" }, { "name": "nunomaduro/termwind", @@ -5285,22 +5285,22 @@ }, { "name": "socialiteproviders/manager", - "version": "v4.7.0", + "version": "v4.8.0", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "ab0691b82cec77efd90154c78f1854903455c82f" + "reference": "e93acc38f8464cc775a2b8bf09df311d1fdfefcb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/ab0691b82cec77efd90154c78f1854903455c82f", - "reference": "ab0691b82cec77efd90154c78f1854903455c82f", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/e93acc38f8464cc775a2b8bf09df311d1fdfefcb", + "reference": "e93acc38f8464cc775a2b8bf09df311d1fdfefcb", "shasum": "" }, "require": { "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0", "laravel/socialite": "^5.5", - "php": "^8.0" + "php": "^8.1" }, "require-dev": { "mockery/mockery": "^1.2", @@ -5355,7 +5355,7 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2024-11-10T01:56:18+00:00" + "time": "2025-01-03T09:40:37+00:00" }, { "name": "socialiteproviders/microsoft-azure", @@ -5635,16 +5635,16 @@ }, { "name": "symfony/console", - "version": "v6.4.15", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd" + "reference": "799445db3f15768ecc382ac5699e6da0520a0a04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", + "url": "https://api.github.com/repos/symfony/console/zipball/799445db3f15768ecc382ac5699e6da0520a0a04", + "reference": "799445db3f15768ecc382ac5699e6da0520a0a04", "shasum": "" }, "require": { @@ -5709,7 +5709,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.15" + "source": "https://github.com/symfony/console/tree/v6.4.17" }, "funding": [ { @@ -5725,7 +5725,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2024-12-07T12:07:30+00:00" }, { "name": "symfony/css-selector", @@ -5811,12 +5811,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -5861,16 +5861,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.14", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9" + "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/9e024324511eeb00983ee76b9aedc3e6ecd993d9", - "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/37ad2380e8c1a8cf62a1200a5c10080b679b446c", + "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c", "shasum": "" }, "require": { @@ -5916,7 +5916,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.14" + "source": "https://github.com/symfony/error-handler/tree/v6.4.17" }, "funding": [ { @@ -5932,7 +5932,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T15:34:40+00:00" + "time": "2024-12-06T13:30:51+00:00" }, { "name": "symfony/event-dispatcher", @@ -6034,12 +6034,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -6092,16 +6092,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.13", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958" + "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/daea9eca0b08d0ed1dc9ab702a46128fd1be4958", - "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958", + "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", "shasum": "" }, "require": { @@ -6136,7 +6136,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.13" + "source": "https://github.com/symfony/finder/tree/v6.4.17" }, "funding": [ { @@ -6152,7 +6152,7 @@ "type": "tidelift" } ], - "time": "2024-10-01T08:30:56+00:00" + "time": "2024-12-29T13:51:37+00:00" }, { "name": "symfony/http-foundation", @@ -6233,16 +6233,16 @@ }, { "name": "symfony/http-kernel", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0" + "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8838b5b21d807923b893ccbfc2cbeda0f1bc00f0", - "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c5647393c5ce11833d13e4b70fff4b571d4ac710", + "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710", "shasum": "" }, "require": { @@ -6327,7 +6327,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.16" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.17" }, "funding": [ { @@ -6343,20 +6343,20 @@ "type": "tidelift" } ], - "time": "2024-11-27T12:49:36+00:00" + "time": "2024-12-31T14:49:31+00:00" }, { "name": "symfony/mime", - "version": "v6.4.13", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855" + "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1de1cf14d99b12c7ebbb850491ec6ae3ed468855", - "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855", + "url": "https://api.github.com/repos/symfony/mime/zipball/ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", + "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", "shasum": "" }, "require": { @@ -6412,7 +6412,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.13" + "source": "https://github.com/symfony/mime/tree/v6.4.17" }, "funding": [ { @@ -6428,7 +6428,7 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2024-12-02T11:09:41+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7234,12 +7234,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -7493,12 +7493,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -7713,31 +7713,33 @@ }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.2.7", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb" + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/83ee6f38df0a63106a9e4536e3060458b74ccedb", - "reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", - "php": "^5.5 || ^7.0 || ^8.0", - "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5 || ^8.5.21 || ^9.5.10" + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.2.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -7760,9 +7762,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.2.7" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" }, - "time": "2023-12-08T13:03:43+00:00" + "time": "2024-12-21T16:25:41+00:00" }, { "name": "vlucas/phpdotenv", @@ -8782,16 +8784,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.13", + "version": "1.12.14", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" + "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e73868f809e68fff33be961ad4946e2e43ec9e38", + "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38", "shasum": "" }, "require": { @@ -8836,7 +8838,7 @@ "type": "github" } ], - "time": "2024-12-17T17:00:20+00:00" + "time": "2024-12-31T07:26:13+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10258,17 +10260,15 @@ }, { "name": "ssddanbrown/asserthtml", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", - "url": "https://github.com/ssddanbrown/asserthtml.git", - "reference": "a2cf9394dfc4138b8d9691e1bd128ccc3d8fcc5d" + "url": "https://codeberg.org/danb/asserthtml", + "reference": "31b3035b5533ae80ab16c80d3774dd840dbfd079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/asserthtml/zipball/a2cf9394dfc4138b8d9691e1bd128ccc3d8fcc5d", - "reference": "a2cf9394dfc4138b8d9691e1bd128ccc3d8fcc5d", - "shasum": "" + "url": "https://codeberg.org/api/v1/repos/danb/asserthtml/archive/%prettyVersion%.zip" }, "require": { "ext-dom": "*", @@ -10299,18 +10299,8 @@ } ], "description": "HTML Content Assertions for PHPUnit", - "homepage": "https://github.com/ssddanbrown/asserthtml", - "support": { - "issues": "https://github.com/ssddanbrown/asserthtml/issues", - "source": "https://github.com/ssddanbrown/asserthtml/tree/v3.0.0" - }, - "funding": [ - { - "url": "https://github.com/ssddanbrown", - "type": "github" - } - ], - "time": "2023-05-11T14:26:12+00:00" + "homepage": "https://codeberg.org/danb/asserthtml", + "time": "2024-12-28T15:15:02+00:00" }, { "name": "symfony/dom-crawler", From 33b46882f39106697a704dadd25f221f0dc2ff53 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 4 Jan 2025 21:46:35 +0000 Subject: [PATCH 281/800] Updated translations with latest Crowdin changes (#5370) --- lang/cs/activities.php | 12 ++--- lang/cs/common.php | 4 +- lang/cs/editor.php | 4 +- lang/cs/entities.php | 42 +++++++++--------- lang/cs/errors.php | 20 ++++----- lang/cs/settings.php | 2 +- lang/cs/validation.php | 8 ++-- lang/de/activities.php | 12 ++--- lang/de/editor.php | 4 +- lang/de/entities.php | 40 ++++++++--------- lang/de/errors.php | 22 +++++----- lang/de/settings.php | 2 +- lang/de/validation.php | 8 ++-- lang/de_informal/editor.php | 4 +- lang/es/editor.php | 4 +- lang/es_AR/editor.php | 4 +- lang/it/activities.php | 12 ++--- lang/it/editor.php | 4 +- lang/it/entities.php | 42 +++++++++--------- lang/it/errors.php | 20 ++++----- lang/it/settings.php | 2 +- lang/it/validation.php | 8 ++-- lang/ja/activities.php | 6 +-- lang/ja/editor.php | 4 +- lang/ja/entities.php | 42 +++++++++--------- lang/ja/errors.php | 20 ++++----- lang/ja/settings.php | 2 +- lang/ja/validation.php | 8 ++-- lang/ko/activities.php | 12 ++--- lang/ko/auth.php | 8 ++-- lang/ko/common.php | 2 +- lang/ko/components.php | 2 +- lang/ko/editor.php | 2 +- lang/ko/entities.php | 88 ++++++++++++++++++------------------- lang/ko/errors.php | 24 +++++----- lang/ko/passwords.php | 4 +- lang/ko/preferences.php | 4 +- lang/ko/settings.php | 20 ++++----- lang/ko/validation.php | 8 ++-- lang/nl/editor.php | 4 +- lang/nl/entities.php | 10 ++--- lang/nl/errors.php | 16 +++---- lang/nl/settings.php | 2 +- lang/nl/validation.php | 8 ++-- lang/pt_BR/activities.php | 24 +++++----- lang/pt_BR/common.php | 14 +++--- lang/pt_BR/editor.php | 12 ++--- lang/pt_BR/entities.php | 62 +++++++++++++------------- lang/pt_BR/errors.php | 40 ++++++++--------- lang/pt_BR/settings.php | 2 +- lang/pt_BR/validation.php | 8 ++-- lang/sk/activities.php | 10 ++--- 52 files changed, 374 insertions(+), 374 deletions(-) diff --git a/lang/cs/activities.php b/lang/cs/activities.php index 3b4a05213ba..8ce0397a74a 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -85,12 +85,12 @@ 'webhook_delete_notification' => 'Webhook byl úspěšně odstraněn', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'vytvořil/a import', + 'import_create_notification' => 'Import byl úspěšně nahrán', + 'import_run' => 'aktualizoval/a import', + 'import_run_notification' => 'Obsah byl úspěšně importován', + 'import_delete' => 'odstranil/a import', + 'import_delete_notification' => 'Import byl úspěšně odstraněn', // Users 'user_create' => 'vytvořil uživatele', diff --git a/lang/cs/common.php b/lang/cs/common.php index 66ad222792d..af02491e460 100644 --- a/lang/cs/common.php +++ b/lang/cs/common.php @@ -73,8 +73,8 @@ 'toggle_details' => 'Přepnout podrobnosti', 'toggle_thumbnails' => 'Přepnout náhledy', 'details' => 'Podrobnosti', - 'grid_view' => 'Zobrazení mřížky', - 'list_view' => 'Zobrazení seznamu', + 'grid_view' => 'Zobrazit mřížku', + 'list_view' => 'Zobrazit seznam', 'default' => 'Výchozí', 'breadcrumb' => 'Drobečková navigace', 'status' => 'Stav', diff --git a/lang/cs/editor.php b/lang/cs/editor.php index 8e7001c081c..fc5a4a74ac2 100644 --- a/lang/cs/editor.php +++ b/lang/cs/editor.php @@ -163,8 +163,8 @@ 'about' => 'O editoru', 'about_title' => 'O WYSIWYG editoru', 'editor_license' => 'Editor licence a autorská práva', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Tento editor je vytvořen jako fork :lexicalLink, který je distribuován pod licencí MIT.', + 'editor_lexical_license_link' => 'Kompletní údaje o licenci naleznete zde.', 'editor_tiny_license' => 'Tento editor je vytvořen pomocí :tinyLink, který je poskytován pod licencí MIT.', 'editor_tiny_license_link' => 'Podrobnosti o autorských právech a licenci TinyMCE naleznete zde.', 'save_continue' => 'Uložit stránku a pokračovat', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index a87ba49d044..21d6c8dd33d 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -39,30 +39,30 @@ 'export_pdf' => 'PDF dokument', 'export_text' => 'Textový soubor', 'export_md' => 'Markdown', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'Přenosný archiv ZIP', 'default_template' => 'Výchozí šablona stránky', 'default_template_explain' => 'Přiřadit šablonu stránky, která bude použita jako výchozí obsah pro všechny nové stránky v této knize. Mějte na paměti, že šablona bude použita pouze v případě, že tvůrce stránek bude mít přístup k těmto vybraným stránkám šablony.', 'default_template_select' => 'Vyberte šablonu stránky', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import' => 'Importovat', + 'import_validate' => 'Ověřit import', + 'import_desc' => 'Import knih, kapitol a stránek pomocí přenosného archivu ZIP z tohoto nebo jiného webu. Pro pokračování vyberte ZIP soubor. Po nahrání a ověření souboru budete moci v dalším kroku nastavit a potvrdit import.', + 'import_zip_select' => 'Vybrat ZIP soubor k nahrání', + 'import_zip_validation_errors' => 'Při ověřování vybraného souboru ZIP byly zjištěny chyby:', + 'import_pending' => 'Čekající importy', + 'import_pending_none' => 'Žádné importy nebyly spuštěny.', + 'import_continue' => 'Pokračovat v importu', + 'import_continue_desc' => 'Zkontrolujte obsah, který má být importován z nahraného souboru. Pokud je vše v pořádku, spusťte import pro přidání obsahu ZIP archivu na tento web. Nahraný soubor ZIP bude automaticky odstraněn po úspěšném importu.', + 'import_details' => 'Obsah importu', + 'import_run' => 'Spustit import', + 'import_size' => 'Velikost archivu ZIP: :size', + 'import_uploaded_at' => 'Nahráno :relativeTime', + 'import_uploaded_by' => 'Nahrál/a', + 'import_location' => 'Cílové umístění', + 'import_location_desc' => 'Vyberte cílové umístění pro importovaný obsah. K vytvoření potřebujete příslušná oprávnění.', + 'import_delete_confirm' => 'Opravdu chcete tento import odstranit?', + 'import_delete_desc' => 'Potvrzením odstraníte nahraný ZIP soubor. Tento krok nelze vrátit zpět.', + 'import_errors' => 'Chyby importu', + 'import_errors_desc' => 'Při pokusu o import došlo k následujícím chybám:', // Permissions and restrictions 'permissions' => 'Oprávnění', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index c740ee193e7..b1eeb54e015 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -106,16 +106,16 @@ 'back_soon' => 'Brzy bude opět v provozu.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Nelze načíst ZIP soubor.', + 'import_zip_cant_decode_data' => 'Nelze najít a dekódovat data.json v archivu ZIP.', + 'import_zip_no_data' => 'ZIP archiv neobsahuje knihy, kapitoly nebo stránky.', + 'import_validation_failed' => 'Importování ZIP selhalo s chybami:', + 'import_zip_failed_notification' => 'Nepodařilo se naimportovat ZIP soubor.', + 'import_perms_books' => 'Chybí vám požadovaná oprávnění k vytvoření knih.', + 'import_perms_chapters' => 'Chybí vám požadovaná oprávnění k vytvoření kapitol.', + 'import_perms_pages' => 'Chybí vám požadovaná oprávnění k vytvoření stránek.', + 'import_perms_images' => 'Chybí vám požadovaná oprávnění k vytvoření obrázků.', + 'import_perms_attachments' => 'Chybí vám požadovaná oprávnění k vytvoření příloh.', // API errors 'api_no_authorization_found' => 'V požadavku nebyl nalezen žádný autorizační token', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index 67d7d825a5e..17c25b27b62 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -162,7 +162,7 @@ 'role_access_api' => 'Přístup k systémovému API', 'role_manage_settings' => 'Správa nastavení aplikace', 'role_export_content' => 'Exportovat obsah', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importovat obsah', 'role_editor_change' => 'Změnit editor stránek', 'role_notifications' => 'Přijímat a spravovat oznámení', 'role_asset' => 'Obsahová oprávnění', diff --git a/lang/cs/validation.php b/lang/cs/validation.php index f2ee59e5be0..b05d94625af 100644 --- a/lang/cs/validation.php +++ b/lang/cs/validation.php @@ -105,10 +105,10 @@ 'url' => 'Formát :attribute je neplatný.', 'uploaded' => 'Nahrávání :attribute se nezdařilo.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => ':attribute musí odkazovat na soubor v archivu ZIP.', + 'zip_file_mime' => ':attribute musí odkazovat na soubor typu :validTypes, nalezen :foundType.', + 'zip_model_expected' => 'Očekáván datový objekt, ale nalezen „:type“.', + 'zip_unique' => ':attribute musí být jedinečný pro typ objektu v archivu ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/de/activities.php b/lang/de/activities.php index 79e05fbaf3d..012e024a235 100644 --- a/lang/de/activities.php +++ b/lang/de/activities.php @@ -85,12 +85,12 @@ 'webhook_delete_notification' => 'Webhook erfolgreich gelöscht', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'Import erstellt', + 'import_create_notification' => 'Import erfolgreich hochgeladen', + 'import_run' => 'Import aktualisiert', + 'import_run_notification' => 'Inhalt erfolgreich importiert', + 'import_delete' => 'Import gelöscht', + 'import_delete_notification' => 'Import erfolgreich gelöscht', // Users 'user_create' => 'hat Benutzer erzeugt:', diff --git a/lang/de/editor.php b/lang/de/editor.php index ab19d167e62..6e6b1fa63df 100644 --- a/lang/de/editor.php +++ b/lang/de/editor.php @@ -163,8 +163,8 @@ 'about' => 'Über den Editor', 'about_title' => 'Über den WYSIWYG-Editor', 'editor_license' => 'Editorlizenz & Copyright', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Dieser Editor wurde mithilfe von :lexicalLink erstellt, der unter der MIT-Lizenz bereitgestellt wird.', + 'editor_lexical_license_link' => 'Vollständige Lizenzdetails finden Sie hier.', 'editor_tiny_license' => 'Dieser Editor wurde mithilfe von :tinyLink erstellt, der unter der MIT-Lizenz bereitgestellt wird.', 'editor_tiny_license_link' => 'Die Copyright- und Lizenzdetails von TinyMCE finden Sie hier.', 'save_continue' => 'Speichern & Fortfahren', diff --git a/lang/de/entities.php b/lang/de/entities.php index b3e1b38f9fe..799aa46050f 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -44,25 +44,25 @@ 'default_template_explain' => 'Zuweisen einer Seitenvorlage, die als Standardinhalt für alle Seiten verwendet wird, die innerhalb dieses Elements erstellt wurden. Beachten Sie, dass dies nur dann verwendet wird, wenn der Ersteller der Seite Zugriff auf die ausgewählte Vorlagen-Seite hat.', 'default_template_select' => 'Wählen Sie eine Seitenvorlage', 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import_validate' => 'Import validieren', + 'import_desc' => 'Importieren Sie Bücher, Kapitel & Seiten mit einem "Portable Zip-Export" von der gleichen oder einer anderen Instanz. Wählen Sie eine ZIP-Datei, um fortzufahren. Nachdem die Datei hochgeladen und bestätigt wurde, können Sie den Import in der nächsten Ansicht konfigurieren und bestätigen.', + 'import_zip_select' => 'ZIP-Datei zum Hochladen auswählen', + 'import_zip_validation_errors' => 'Fehler bei der Validierung der angegebenen ZIP-Datei:', + 'import_pending' => 'Ausstehende Importe', + 'import_pending_none' => 'Es wurden keine Importe gestartet.', + 'import_continue' => 'Import fortsetzen', + 'import_continue_desc' => 'Überprüfen Sie den Inhalt, der aus der hochgeladenen ZIP-Datei importiert werden soll. Führen Sie den Import aus, um dessen Inhalt zu diesem System hinzuzufügen. Die hochgeladene ZIP-Importdatei wird bei erfolgreichem Import automatisch entfernt.', + 'import_details' => 'Einzelheiten zum Import', + 'import_run' => 'Import starten', + 'import_size' => ':size Import ZIP Größe', + 'import_uploaded_at' => 'Hochgeladen :relativeTime', + 'import_uploaded_by' => 'Hochgeladen von', + 'import_location' => 'Import Ort', + 'import_location_desc' => 'Wählen Sie einen Zielort für Ihren importierten Inhalt. Sie benötigen die entsprechenden Berechtigungen, um innerhalb des gewünschten Standortes zu erstellen.', + 'import_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Import löschen möchten?', + 'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.', + 'import_errors' => 'Importfehler', + 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', // Permissions and restrictions 'permissions' => 'Berechtigungen', @@ -245,7 +245,7 @@ 'pages_edit_switch_to_markdown_clean' => '(gesäuberter Output)', 'pages_edit_switch_to_markdown_stable' => '(html beibehalten)', 'pages_edit_switch_to_wysiwyg' => 'Wechseln Sie zum WYSIWYG-Editor', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg' => 'Zu neuem WYSIWYG wechseln', 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', 'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen', 'pages_edit_enter_changelog_desc' => 'Bitte geben Sie eine kurze Zusammenfassung Ihrer Änderungen ein', diff --git a/lang/de/errors.php b/lang/de/errors.php index f940f67436b..c1f65d94006 100644 --- a/lang/de/errors.php +++ b/lang/de/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Sie können den einzigen Administrator nicht löschen', 'users_cannot_delete_guest' => 'Sie können den Gast-Benutzer nicht löschen', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Benutzer konnte nicht erstellt werden, da die Einladungs-E-Mail nicht gesendet wurde', // Roles 'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden', @@ -106,16 +106,16 @@ 'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', + 'import_zip_cant_decode_data' => 'ZIP data.json konnte nicht gefunden und dekodiert werden.', + 'import_zip_no_data' => 'ZIP-Datei Daten haben kein erwartetes Buch, Kapitel oder Seiteninhalt.', + 'import_validation_failed' => 'ZIP Import konnte mit Fehlern nicht validiert werden:', + 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', + 'import_perms_books' => 'Ihnen fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', + 'import_perms_chapters' => 'Ihnen fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', + 'import_perms_pages' => 'Ihnen fehlt die erforderliche Berechtigung, um Seiten zu erstellen.', + 'import_perms_images' => 'Ihnen fehlt die erforderliche Berechtigung, um Bilder zu erstellen.', + 'import_perms_attachments' => 'Ihnen fehlt die erforderliche Berechtigung, um Anhänge zu erstellen.', // API errors 'api_no_authorization_found' => 'Kein Autorisierungstoken für die Anfrage gefunden', diff --git a/lang/de/settings.php b/lang/de/settings.php index c1e4bcdd92f..78cd2b500a2 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -163,7 +163,7 @@ 'role_access_api' => 'Systemzugriffs-API', 'role_manage_settings' => 'Globaleinstellungen verwalten', 'role_export_content' => 'Inhalt exportieren', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiten-Editor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', 'role_asset' => 'Berechtigungen', diff --git a/lang/de/validation.php b/lang/de/validation.php index 072bf79d1ec..10961eb7fb2 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -105,10 +105,10 @@ 'url' => ':attribute ist kein valides Format.', 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => ':attribute muss eine Datei innerhalb des ZIP referenzieren.', + 'zip_file_mime' => ':attribute muss eine Datei des Typs :validType referenzieren, gefunden :foundType.', + 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', + 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', // Custom validation lines 'custom' => [ diff --git a/lang/de_informal/editor.php b/lang/de_informal/editor.php index 81f5eab9e64..23ce1ee4aab 100644 --- a/lang/de_informal/editor.php +++ b/lang/de_informal/editor.php @@ -163,8 +163,8 @@ 'about' => 'Über den Editor', 'about_title' => 'Über den WYSIWYG Editor', 'editor_license' => 'Editorlizenz & Copyright', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Dieser Editor wurde mithilfe von :lexicalLink erstellt, der unter der MIT-Lizenz bereitgestellt wird.', + 'editor_lexical_license_link' => 'Vollständige Lizenzdetails findest du hier.', 'editor_tiny_license' => 'Dieser Editor wurde mit :tinyLink erstellt, das unter der MIT-Lizenz zur Verfügung gestellt wird.', 'editor_tiny_license_link' => 'Die Copyright- und Lizenzdetails von TinyMCE findest du hier.', 'save_continue' => 'Seite speichern & fortfahren', diff --git a/lang/es/editor.php b/lang/es/editor.php index 3d9e90c13fb..6253c7ab3c6 100644 --- a/lang/es/editor.php +++ b/lang/es/editor.php @@ -163,8 +163,8 @@ 'about' => 'Acerca del editor', 'about_title' => 'Acerca del editor WYSIWYG', 'editor_license' => 'Licencia del editor y derechos de autor', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Este editor está construido como una bifurcación de :lexicalLink que se distribuye bajo la licencia MIT.', + 'editor_lexical_license_link' => 'Los detalles completos de la licencia se pueden encontrar aquí.', 'editor_tiny_license' => 'Este editor se construye usando :tinyLink que se proporciona bajo la licencia MIT.', 'editor_tiny_license_link' => 'Aquí encontrará los detalles de los derechos de autor y la licencia de TinyMCE.', 'save_continue' => 'Guardar Página y Continuar', diff --git a/lang/es_AR/editor.php b/lang/es_AR/editor.php index 9002dc72bac..28b6e92c88a 100644 --- a/lang/es_AR/editor.php +++ b/lang/es_AR/editor.php @@ -163,8 +163,8 @@ 'about' => 'Acerca del editor', 'about_title' => 'Acerca del editor WYSIWYG', 'editor_license' => 'Licencia del editor y derechos de autor', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Este editor está construido como una bifurcación de :lexicalLink que se distribuye bajo la licencia MIT.', + 'editor_lexical_license_link' => 'Los detalles completos de la licencia se pueden encontrar aquí.', 'editor_tiny_license' => 'Este editor se construye usando :tinyLink provisto bajo la licencia MIT.', 'editor_tiny_license_link' => 'Aquí se muestran los detalles de los derechos de autor y la licencia de TinyMCE.', 'save_continue' => 'Guardar Página y Continuar', diff --git a/lang/it/activities.php b/lang/it/activities.php index 0747d7784e5..32d71ac083e 100644 --- a/lang/it/activities.php +++ b/lang/it/activities.php @@ -85,12 +85,12 @@ 'webhook_delete_notification' => 'Webhook eliminato con successo', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'importazione creata', + 'import_create_notification' => 'Importazione caricata con successo', + 'import_run' => 'importazione aggiornata', + 'import_run_notification' => 'Contenuto importato con successo', + 'import_delete' => 'importazione cancellata', + 'import_delete_notification' => 'Importazione eliminata con successo', // Users 'user_create' => 'ha creato un utente', diff --git a/lang/it/editor.php b/lang/it/editor.php index 11b019ee382..fac478a4aca 100644 --- a/lang/it/editor.php +++ b/lang/it/editor.php @@ -163,8 +163,8 @@ 'about' => 'Informazioni sull\'editor', 'about_title' => 'Informazioni sull\'editor di WYSIWYG', 'editor_license' => 'Licenza e copyright dell\'editor', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Questo editor è stato creato come fork di :lexicalLink ed è distribuito sotto la licenza MIT.', + 'editor_lexical_license_link' => 'I dettagli della licenza sono disponibili qui.', 'editor_tiny_license' => 'Questo editor è realizzato usando :tinyLink che è fornito sotto la licenza MIT.', 'editor_tiny_license_link' => 'I dettagli del copyright e della licenza di TinyMCE sono disponibili qui.', 'save_continue' => 'Salva pagina e continua', diff --git a/lang/it/entities.php b/lang/it/entities.php index 22c428ed3e1..c3efa5d3390 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -39,30 +39,30 @@ 'export_pdf' => 'File PDF', 'export_text' => 'File di testo', 'export_md' => 'File Markdown', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'ZIP Portatile', 'default_template' => 'Modello di pagina predefinito', 'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che potrà essere utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.', 'default_template_select' => 'Seleziona una pagina modello', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import' => 'Importa', + 'import_validate' => 'Convalida Importazione', + 'import_desc' => 'Importa libri, capitoli e pagine utilizzando un\'esportazione zip portatile dalla stessa istanza o da un\'istanza diversa. Selezionare un file ZIP per procedere. Dopo che il file è stato caricato e convalidato, sarà possibile configurare e confermare l\'importazione nella vista successiva.', + 'import_zip_select' => 'Seleziona il file ZIP da caricare', + 'import_zip_validation_errors' => 'Sono stati rilevati errori durante la convalida del file ZIP fornito:', + 'import_pending' => 'Importazioni In Attesa', + 'import_pending_none' => 'Non sono state avviate importazioni.', + 'import_continue' => 'Continua l\'importazione', + 'import_continue_desc' => 'Esaminare il contenuto da importare dal file ZIP caricato. Quando è pronto, eseguire l\'importazione per aggiungere i contenuti al sistema. Il file di importazione ZIP caricato verrà automaticamente rimosso quando l\'importazione sarà riuscita.', + 'import_details' => 'Dettagli dell\'importazione', + 'import_run' => 'Esegui Importazione', + 'import_size' => 'Dimensione dello ZIP importato :size', + 'import_uploaded_at' => 'Caricato il :relativeTime', + 'import_uploaded_by' => 'Caricata da', + 'import_location' => 'Posizione Di Importazione', + 'import_location_desc' => 'Selezionare una posizione di destinazione per i contenuti importati. È necessario disporre delle autorizzazioni adeguate per creare all\'interno della posizione scelta.', + 'import_delete_confirm' => 'Sei sicuro di voler eliminare questa importazione?', + 'import_delete_desc' => 'Questa operazione cancella il file ZIP di importazione caricato e non può essere annullata.', + 'import_errors' => 'Errori di importazione', + 'import_errors_desc' => 'Gli seguenti errori si sono verificati durante il tentativo di importazione:', // Permissions and restrictions 'permissions' => 'Permessi', diff --git a/lang/it/errors.php b/lang/it/errors.php index a68da405bb5..b3763cce252 100644 --- a/lang/it/errors.php +++ b/lang/it/errors.php @@ -106,16 +106,16 @@ 'back_soon' => 'Tornerà presto online.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Impossibile leggere il file ZIP.', + 'import_zip_cant_decode_data' => 'Impossibile trovare e decodificare il contenuto ZIP data.json.', + 'import_zip_no_data' => 'I dati del file ZIP non hanno il contenuto previsto di libri, capitoli o pagine.', + 'import_validation_failed' => 'L\'importazione ZIP non è stata convalidata con errori:', + 'import_zip_failed_notification' => 'Impossibile importare il file ZIP.', + 'import_perms_books' => 'Non hai i permessi necessari per creare libri.', + 'import_perms_chapters' => 'Non hai i permessi necessari per creare capitoli.', + 'import_perms_pages' => 'Non hai i permessi necessari per creare pagine.', + 'import_perms_images' => 'Non hai i permessi necessari per creare immagini.', + 'import_perms_attachments' => 'Non hai il permesso necessario per creare allegati.', // API errors 'api_no_authorization_found' => 'Nessun token di autorizzazione trovato nella richiesta', diff --git a/lang/it/settings.php b/lang/it/settings.php index e57baceb49e..e9c45bced20 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -162,7 +162,7 @@ 'role_access_api' => 'Accedere alle API di sistema', 'role_manage_settings' => 'Gestire impostazioni app', 'role_export_content' => 'Esportare contenuto', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importa contenuto', 'role_editor_change' => 'Cambiare editor di pagina', 'role_notifications' => 'Ricevere e gestire le notifiche', 'role_asset' => 'Permessi entità', diff --git a/lang/it/validation.php b/lang/it/validation.php index b21e8d932b4..c945ff7d426 100644 --- a/lang/it/validation.php +++ b/lang/it/validation.php @@ -105,10 +105,10 @@ 'url' => 'Il formato :attribute non è valido.', 'uploaded' => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'L\'attributo :attribute deve fare riferimento a un file all\'interno dello ZIP.', + 'zip_file_mime' => 'Il campo :attribute deve fare riferimento a un file di tipo :validTypes, trovato :foundType.', + 'zip_model_expected' => 'Oggetto dati atteso ma ":type" trovato.', + 'zip_unique' => 'L\'attributo :attribute deve essere univoco per il tipo di oggetto all\'interno dello ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/ja/activities.php b/lang/ja/activities.php index f20f9a90d5a..c97fbd7ed76 100644 --- a/lang/ja/activities.php +++ b/lang/ja/activities.php @@ -86,11 +86,11 @@ // Imports 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', + 'import_create_notification' => 'インポートファイルが正常にアップロードされました', 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', + 'import_run_notification' => 'コンテンツが正常にインポートされました', 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_delete_notification' => 'インポートファイルが正常に削除されました', // Users 'user_create' => 'がユーザを作成', diff --git a/lang/ja/editor.php b/lang/ja/editor.php index 576e08f14b2..6d4a424c5f6 100644 --- a/lang/ja/editor.php +++ b/lang/ja/editor.php @@ -163,8 +163,8 @@ 'about' => 'エディタについて', 'about_title' => 'WYSIWYGエディタについて', 'editor_license' => 'エディタのライセンスと著作権', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'このエディタはMITライセンスの下で配布されている :lexicalLink のフォークとして構築されています。', + 'editor_lexical_license_link' => '完全なライセンスの詳細はこちらをご覧ください。', 'editor_tiny_license' => 'このエディタはMITライセンスの下で提供される:tinyLinkを利用して構築されています。', 'editor_tiny_license_link' => 'TinyMCEの著作権およびライセンスの詳細は、こちらをご覧ください。', 'save_continue' => 'ページを保存して続行', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index 05e2f33ab8f..76cc2a7693d 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -39,30 +39,30 @@ 'export_pdf' => 'PDF', 'export_text' => 'テキストファイル', 'export_md' => 'Markdown', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'ポータブルZIP', 'default_template' => 'デフォルトページテンプレート', 'default_template_explain' => 'このアイテム内に新しいページを作成する際にデフォルトコンテンツとして使用されるページテンプレートを割り当てます。これはページ作成者が選択したテンプレートページへのアクセス権を持つ場合にのみ使用されることに注意してください。', 'default_template_select' => 'テンプレートページを選択', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import' => 'インポート', + 'import_validate' => 'インポートの検証', + 'import_desc' => 'ポータブルzipエクスポートファイルを使用して、同一または別のインスタンスからブック、チャプタ、ページをインポートします。続行するにはZIPファイルを選択します。 ファイルをアップロードすると検証が行われ、次のビューでインポートの設定と確認ができます。', + 'import_zip_select' => 'アップロードするZIPファイルの選択', + 'import_zip_validation_errors' => '指定された ZIP ファイルの検証中にエラーが検出されました:', + 'import_pending' => '保留中のインポート', + 'import_pending_none' => 'インポートは行われていません。', + 'import_continue' => 'インポートを続行', + 'import_continue_desc' => 'アップロードされたZIPファイルからインポートされるコンテンツを確認してください。準備ができたらインポートを実行してこのシステムにコンテンツを追加します。 アップロードされたZIPファイルは、インポートが成功すると自動的に削除されます。', + 'import_details' => 'インポートの詳細', + 'import_run' => 'インポートを実行', + 'import_size' => 'インポートZIPサイズ: :size', + 'import_uploaded_at' => 'アップロード: :relativeTime', + 'import_uploaded_by' => 'アップロードユーザ:', + 'import_location' => 'インポートの場所', + 'import_location_desc' => 'コンテンツをインポートする場所を選択します。選択した場所に作成するための権限を持つ必要があります。', + 'import_delete_confirm' => 'このインポートを削除してもよろしいですか?', + 'import_delete_desc' => 'アップロードされたインポートZIPファイルは削除され、元に戻すことはできません。', + 'import_errors' => 'インポートエラー', + 'import_errors_desc' => 'インポート中に次のエラーが発生しました:', // Permissions and restrictions 'permissions' => '権限', diff --git a/lang/ja/errors.php b/lang/ja/errors.php index ca001d7680b..8161c08b860 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -106,16 +106,16 @@ 'back_soon' => '回復までしばらくお待ちください。', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'ZIPファイルを読み込めません。', + 'import_zip_cant_decode_data' => 'ZIPファイル内に data.json が見つからないかデコードできませんでした。', + 'import_zip_no_data' => 'ZIPファイルのデータにブック、チャプター、またはページコンテンツがありません。', + 'import_validation_failed' => 'エラーによりインポートZIPの検証に失敗しました:', + 'import_zip_failed_notification' => 'ZIP ファイルのインポートに失敗しました。', + 'import_perms_books' => 'ブックを作成するために必要な権限がありません。', + 'import_perms_chapters' => 'チャプタを作成するために必要な権限がありません。', + 'import_perms_pages' => 'ページを作成するために必要な権限がありません。', + 'import_perms_images' => '画像を作成するために必要な権限がありません。', + 'import_perms_attachments' => '添付ファイルを作成するために必要な権限がありません。', // API errors 'api_no_authorization_found' => 'リクエストに認証トークンが見つかりません', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index d73f12cb2c9..ffced8dd16e 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -162,7 +162,7 @@ 'role_access_api' => 'システムのAPIへのアクセス', 'role_manage_settings' => 'アプリケーション設定の管理', 'role_export_content' => 'コンテンツのエクスポート', - 'role_import_content' => 'Import content', + 'role_import_content' => 'コンテンツのインポート', 'role_editor_change' => 'ページエディタの変更', 'role_notifications' => '通知の受信と管理', 'role_asset' => 'アセット権限', diff --git a/lang/ja/validation.php b/lang/ja/validation.php index 110bcb01213..7d18c85be72 100644 --- a/lang/ja/validation.php +++ b/lang/ja/validation.php @@ -105,10 +105,10 @@ 'url' => ':attributeのフォーマットは不正です。', 'uploaded' => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => ':attribute はZIP 内のファイルを参照する必要があります。', + 'zip_file_mime' => ':attribute は種別 :validType のファイルを参照する必要がありますが、種別 :foundType となっています。', + 'zip_model_expected' => 'データオブジェクトが期待されますが、":type" が見つかりました。', + 'zip_unique' => 'ZIP内のオブジェクトタイプに :attribute が一意である必要があります。', // Custom validation lines 'custom' => [ diff --git a/lang/ko/activities.php b/lang/ko/activities.php index 356568d1fd6..c3f35e44375 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -85,12 +85,12 @@ 'webhook_delete_notification' => '웹 훅 삭제함', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => '컨텐츠 ZIP 파일이 생성되었습니다.', + 'import_create_notification' => '컨텐츠 ZIP 파일이 업로드 되었습니다.', + 'import_run' => '컨텐츠 ZIP 파일을 업데이트하였습니다.', + 'import_run_notification' => '내용을 가져왔습니다.', + 'import_delete' => '임포트 파일 삭제', + 'import_delete_notification' => '임포트 파일을 삭제하였습니다.', // Users 'user_create' => '사용자 생성', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index fbf12859f18..6b60225cbd4 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -68,7 +68,7 @@ 'email_not_confirmed_text' => '전자우편 확인이 아직 완료되지 않았습니다.', 'email_not_confirmed_click_link' => '등록한 직후에 발송된 전자우편에 있는 확인 링크를 클릭하세요.', 'email_not_confirmed_resend' => '전자우편 확인을 위해 발송된 전자우편을 찾을 수 없다면 아래의 폼을 다시 발행하여 전자우편 확인을 재발송할 수 있습니다.', - 'email_not_confirmed_resend_button' => '전자우편 확인 재전송', + 'email_not_confirmed_resend_button' => '확인용 이메일 재전송', // User Invite 'user_invite_email_subject' => ':appName 애플리케이션에서 초대를 받았습니다!', @@ -84,7 +84,7 @@ 'mfa_setup' => '다중 인증 설정', 'mfa_setup_desc' => '추가 보안 계층으로 다중 인증을 설정합니다.', 'mfa_setup_configured' => '이미 설정되었습니다', - 'mfa_setup_reconfigure' => '다시 설정', + 'mfa_setup_reconfigure' => '재설정', 'mfa_setup_remove_confirmation' => '다중 인증을 해제할까요?', 'mfa_setup_action' => '설정', 'mfa_backup_codes_usage_limit_warning' => '남은 백업 코드가 다섯 개 미만입니다. 새 백업 코드 세트를 생성하지 않아 코드가 소진되면 계정이 잠길 수 있습니다.', @@ -95,7 +95,7 @@ 'mfa_gen_confirm_and_enable' => '확인 및 활성화', 'mfa_gen_backup_codes_title' => '백업 코드 설정', 'mfa_gen_backup_codes_desc' => '코드 목록을 안전한 장소에 보관하세요. 코드 중 하나를 2FA에 쓸 수 있습니다.', - 'mfa_gen_backup_codes_download' => '코드 내려받기', + 'mfa_gen_backup_codes_download' => '코드 설치', 'mfa_gen_backup_codes_usage_warning' => '각 코드는 한 번씩만 유효합니다.', 'mfa_gen_totp_title' => '모바일 앱 설정', 'mfa_gen_totp_desc' => '다중 인증에는 Google Authenticator, Authy나 Microsoft Authenticator와 같은 TOTP 지원 모바일 앱이 필요합니다.', @@ -104,7 +104,7 @@ 'mfa_gen_totp_verify_setup_desc' => '인증 앱에서 생성한 코드를 입력하세요:', 'mfa_gen_totp_provide_code_here' => '백업 코드를 입력하세요.', 'mfa_verify_access' => '접근 확인', - 'mfa_verify_access_desc' => '추가 인증으로 신원을 확인합니다. 설정한 방법 중 하나를 고르세요.', + 'mfa_verify_access_desc' => '사용자 계정에서는 액세스 권한을 부여받기 전에 추가 검증 수준을 통해 신원을 확인해야 합니다. 계속하려면 구성된 방법 중 하나를 사용하여 확인하세요.', 'mfa_verify_no_methods' => '설정한 방법이 없습니다.', 'mfa_verify_no_methods_desc' => '다중 인증을 설정하지 않았습니다. 접근 권한을 얻기 전에 하나 이상의 다중 인증을 설정해야 합니다.', 'mfa_verify_use_totp' => '모바일 앱으로 인증하기', diff --git a/lang/ko/common.php b/lang/ko/common.php index b19b0d5cdd2..a0751d4c020 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -109,5 +109,5 @@ 'terms_of_service' => '서비스 이용 약관', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => '검색 :appName', ]; diff --git a/lang/ko/components.php b/lang/ko/components.php index cbda4d15980..cbe4d44c27d 100644 --- a/lang/ko/components.php +++ b/lang/ko/components.php @@ -33,7 +33,7 @@ 'image_update_success' => '이미지 정보가 수정되었습니다.', 'image_delete_success' => '이미지가 삭제되었습니다.', 'image_replace' => '이미지 교체', - 'image_replace_success' => '이미지 파일 업데이트 성공', + 'image_replace_success' => '이미지가 수정되었습니다.', 'image_rebuild_thumbs' => '사이즈 변경 재생성하기', 'image_rebuild_thumbs_success' => '이미지 크기 변경이 성공적으로 완료되었습니다!', diff --git a/lang/ko/editor.php b/lang/ko/editor.php index 6892410169d..81c2dbc54dd 100644 --- a/lang/ko/editor.php +++ b/lang/ko/editor.php @@ -163,7 +163,7 @@ 'about' => '이 편집기에 대하여', 'about_title' => 'WYSIWYG 편집기에 대하여', 'editor_license' => '편집기 라이선스 & 저작권', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license' => '이 편집기는 MIT 라이선스에 따라 배포되는 :lexicalLink의 포크로 만들어졌습니다.', 'editor_lexical_license_link' => '전체 라이센스 세부 사항은 여기에서 확인할 수 있습니다.', 'editor_tiny_license' => '이 편집기는 MIT 라이선스에 따라 제공되는 :tinyLink를 사용하여 제작되었습니다.', 'editor_tiny_license_link' => 'TinyMCE의 저작권 및 라이선스 세부 정보는 여기에서 확인할 수 있습니다.', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index 4fbbe376629..cfb905a8fb4 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -12,17 +12,17 @@ 'recently_created_chapters' => '최근에 만든 챕터', 'recently_created_books' => '최근에 만든 책', 'recently_created_shelves' => '최근에 만든 책장', - 'recently_update' => '최근에 수정함', - 'recently_viewed' => '최근에 읽음', - 'recent_activity' => '최근에 활동함', + 'recently_update' => '최신 수정 목록', + 'recently_viewed' => '최근에 본 목록', + 'recent_activity' => '최근 활동 기록', 'create_now' => '바로 만들기', - 'revisions' => '수정본', - 'meta_revision' => '판본 #:revisionCount', - 'meta_created' => '만듦 :timeLength', - 'meta_created_name' => '만듦 :timeLength, :user', - 'meta_updated' => '수정함 :timeLength', - 'meta_updated_name' => '수정함 :timeLength, :user', - 'meta_owned_name' => '소유함 :user', + 'revisions' => '버전', + 'meta_revision' => '버전 #:revisionCount', + 'meta_created' => '생성 :timeLength', + 'meta_created_name' => '생성 :timeLength, :user', + 'meta_updated' => '수정 :timeLength', + 'meta_updated_name' => '수정 :timeLength, :user', + 'meta_owned_name' => ':user 소유', 'meta_reference_count' => '참조 대상 :count item|참조 대상 :count items', 'entity_select' => '항목 선택', 'entity_select_lack_permission' => '이 항목을 선택하기 위해 필요한 권한이 없습니다', @@ -39,30 +39,30 @@ 'export_pdf' => 'PDF 파일', 'export_text' => '일반 텍스트 파일', 'export_md' => '마크다운 파일', - 'export_zip' => 'Portable ZIP', + 'export_zip' => '압축 파일', 'default_template' => '기본 페이지 템플릿', 'default_template_explain' => '이 항목 내에서 생성되는 모든 페이지의 기본 콘텐츠로 사용할 페이지 템플릿을 지정합니다. 페이지 작성자가 선택한 템플릿 페이지를 볼 수 있는 권한이 있는 경우에만 이 항목이 사용된다는 점을 유의하세요.', 'default_template_select' => '템플릿 페이지 선택', - 'import' => 'Import', - 'import_validate' => 'Validate Import', + 'import' => '압축 파일 가져오기', + 'import_validate' => '압축 파일 검증하기', 'import_desc' => '같은 인스턴스나 다른 인스턴스에서 휴대용 zip 내보내기를 사용하여 책, 장 및 페이지를 가져옵니다. 진행하려면 ZIP 파일을 선택합니다. 파일을 업로드하고 검증한 후 다음 보기에서 가져오기를 구성하고 확인할 수 있습니다.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_zip_select' => '업로드할 휴대용 압축 파일 선택', + 'import_zip_validation_errors' => '제공된 Portable ZIP 파일을 검증하는 동안 오류가 감지되었습니다.', + 'import_pending' => 'Portable ZIP 파일 가져오기가 일시정지 되었습니다.', + 'import_pending_none' => 'Portable ZIP 파일 가져오기가 시작되지 않았습니다.', + 'import_continue' => 'Portable ZIP 가져오기 계속하기', + 'import_continue_desc' => '업로드된 ZIP 파일에서 가져올 내용을 검토합니다. 준비가 되면 가져오기를 실행하여 이 시스템에 내용을 추가합니다. 업로드된 ZIP 가져오기 파일은 가져오기가 성공하면 자동으로 제거됩니다.', + 'import_details' => '압축된 ZIP 파일 상세', + 'import_run' => '가져오기 실행', + 'import_size' => '압축된 ZIP 파일 사이즈', + 'import_uploaded_at' => '업로드되었습니다. :relativeTime', + 'import_uploaded_by' => '업로드되었습니다.', + 'import_location' => '가져올 경로', + 'import_location_desc' => '가져온 콘텐츠에 대한 대상 위치를 선택하세요. 선택한 위치 내에서 만들려면 관련 권한이 필요합니다.', + 'import_delete_confirm' => '정말 삭제하시겠습니까?', 'import_delete_desc' => '업로드된 ZIP 파일이 삭제되며, 실행 취소할 수 없습니다.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import_errors' => '가져오기 오류', + 'import_errors_desc' => '가져오기 중 에러가 발생했습니다.', // Permissions and restrictions 'permissions' => '권한', @@ -157,7 +157,7 @@ 'books_form_book_name' => '책 이름', 'books_save' => '저장', 'books_permissions' => '책 권한', - 'books_permissions_updated' => '권한 저장함', + 'books_permissions_updated' => '책의 권한이 수정되었습니다.', 'books_empty_contents' => '이 책에 챕터나 문서가 없습니다.', 'books_empty_create_page' => '문서 만들기', 'books_empty_sort_current_book' => '현재 책 정렬', @@ -165,7 +165,7 @@ 'books_permissions_active' => '책 권한 허용함', 'books_search_this' => '이 책에서 검색', 'books_navigation' => '목차', - 'books_sort' => '다른 책들', + 'books_sort' => '책 내용 정렬', 'books_sort_desc' => '책 내에서 챕터와 페이지를 이동하여 내용을 재구성할 수 있습니다. 다른 책을 추가하여 책 간에 챕터와 페이지를 쉽게 이동할 수 있습니다.', 'books_sort_named' => ':bookName 정렬', 'books_sort_name' => '제목', @@ -187,7 +187,7 @@ 'books_sort_move_before_chapter' => '이전 챕터로 이동', 'books_sort_move_after_chapter' => '챕터 뒤로 이동', 'books_copy' => '책 복사하기', - 'books_copy_success' => '책 복사함', + 'books_copy_success' => '책을 복사하였습니다.', // Chapters 'chapter' => '챕터', @@ -200,17 +200,17 @@ 'chapters_delete_named' => ':chapterName(을)를 지웁니다.', 'chapters_delete_explain' => '\':ChapterName\'에 있는 모든 페이지도 지웁니다.', 'chapters_delete_confirm' => '이 챕터를 지울 건가요?', - 'chapters_edit' => '챕터 바꾸기', + 'chapters_edit' => '챕터 수정하기', 'chapters_edit_named' => ':chapterName 바꾸기', 'chapters_save' => '저장', 'chapters_move' => '챕터 이동하기', 'chapters_move_named' => ':chapterName 이동하기', 'chapters_copy' => '챕터 복사하기', - 'chapters_copy_success' => '챕터 복사함', + 'chapters_copy_success' => '챕터를 복사하였습니다.', 'chapters_permissions' => '챕터 권한', 'chapters_empty' => '이 챕터에 문서가 없습니다.', 'chapters_permissions_active' => '문서 권한 허용함', - 'chapters_permissions_success' => '권한 저장함', + 'chapters_permissions_success' => '챕터의 권한을 수정하였습니다.', 'chapters_search_this' => '이 챕터에서 검색', 'chapter_sort_book' => '책 정렬하기', @@ -240,21 +240,21 @@ 'pages_edit_draft_save_at' => '보관함: ', 'pages_edit_delete_draft' => '초안 삭제', 'pages_edit_delete_draft_confirm' => '초안 페이지 변경 내용을 삭제하시겠습니까? 마지막 전체 저장 이후의 모든 변경 내용이 손실되고 편집기가 최신 페이지의 초안 저장 상태가 아닌 상태로 업데이트됩니다.', - 'pages_edit_discard_draft' => '폐기', + 'pages_edit_discard_draft' => '변경된 내용 삭제', 'pages_edit_switch_to_markdown' => '마크다운 편집기로 전환', 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_wysiwyg' => 'WYSIWYG 편집기로 전환', 'pages_edit_switch_to_new_wysiwyg' => '새 위지윅 편집기로 변경', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '알파 테스트 중', 'pages_edit_set_changelog' => '수정본 설명', 'pages_edit_enter_changelog_desc' => '수정본 설명', - 'pages_edit_enter_changelog' => '설명', + 'pages_edit_enter_changelog' => '변경 로그 입력란', 'pages_editor_switch_title' => '편집기 전환', 'pages_editor_switch_are_you_sure' => '이 페이지의 편집기를 변경하시겠어요?', 'pages_editor_switch_consider_following' => '편집기를 전환할 때에 다음 사항들을 고려하세요:', 'pages_editor_switch_consideration_a' => '저장된 새 편집기 옵션은 편집기 유형을 직접 변경할 수 없는 편집자를 포함하여 향후 모든 편집자가 사용할 수 있습니다.', - 'pages_editor_switch_consideration_b' => '이로 인해 특정 상황에서는 디테일과 구문이 손실될 수 있습니다.', + 'pages_editor_switch_consideration_b' => '이로 인해 특정 상황에서는 상세 내용과 구문이 손실될 수 있습니다.', 'pages_editor_switch_consideration_c' => '마지막 저장 이후 변경된 태그 또는 변경 로그 변경 사항은 이 변경 사항에서 유지되지 않습니다.', 'pages_save' => '저장', 'pages_title' => '문서 제목', @@ -406,8 +406,8 @@ // Revision 'revision_delete_confirm' => '이 수정본을 지울 건가요?', - 'revision_restore_confirm' => '이 수정본을 되돌릴 건가요? 현재 판본을 바꿉니다.', - 'revision_cannot_delete_latest' => '현재 판본은 지울 수 없습니다.', + 'revision_restore_confirm' => '이 버전을 되돌릴 건가요? 현재 페이지는 대체됩니다.', + 'revision_cannot_delete_latest' => '현재 버전본은 지울 수 없습니다.', // Copy view 'copy_consider' => '항목을 복사할 때 다음을 고려하세요.', @@ -435,10 +435,10 @@ 'references_to_desc' => '이 항목으로 연결되는 시스템에서 알려진 모든 콘텐츠가 아래에 나열되어 있습니다.', // Watch Options - 'watch' => '주시', + 'watch' => '변경 알림 설정', 'watch_title_default' => '기본 설정', - 'watch_desc_default' => '보기를 기본 알림 환경설정으로 되돌릴 수 있습니다.', - 'watch_title_ignore' => '무시', + 'watch_desc_default' => '알림 설정을 기본값으로 되돌리기', + 'watch_title_ignore' => '알림 설정 끄기', 'watch_desc_ignore' => '사용자 수준 환경설정의 알림을 포함한 모든 알림을 무시합니다.', 'watch_title_new' => '새로운 페이지', 'watch_desc_new' => '이 항목에 새 페이지가 생성되면 알림을 받습니다.', diff --git a/lang/ko/errors.php b/lang/ko/errors.php index f5ba13efa3b..ea1fd7d61e7 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -78,7 +78,7 @@ // Users 'users_cannot_delete_only_admin' => 'Admin을 삭제할 수 없습니다.', 'users_cannot_delete_guest' => 'Guest를 삭제할 수 없습니다.', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => '초대 이메일을 보내는 데 실패하여 사용자를 생성할 수 없습니다.', // Roles 'role_cannot_be_edited' => '권한을 수정할 수 없습니다.', @@ -94,7 +94,7 @@ 'empty_comment' => '빈 댓글은 등록할 수 없습니다.', // Error pages - '404_page_not_found' => '404 Not Found', + '404_page_not_found' => '페이지를 찾을 수 없습니다.', 'sorry_page_not_found' => '문서를 못 찾았습니다.', 'sorry_page_not_found_permission_warning' => '문서를 볼 권한이 없습니다.', 'image_not_found' => '이미지를 찾을 수 없습니다', @@ -106,16 +106,16 @@ 'back_soon' => '곧 돌아갑니다.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'ZIP 파일을 읽을 수 없습니다.', + 'import_zip_cant_decode_data' => 'ZIP data.json 콘텐츠를 찾아서 디코딩할 수 없습니다.', + 'import_zip_no_data' => 'ZIP 파일 데이터에는 예상되는 책, 장 또는 페이지 콘텐츠가 없습니다.', + 'import_validation_failed' => 'ZIP 파일을 가져오려다 실패했습니다. 이유:', + 'import_zip_failed_notification' => 'ZIP 파일을 가져오지 못했습니다.', + 'import_perms_books' => '책을 만드는 데 필요한 권한이 없습니다.', + 'import_perms_chapters' => '챕터를 만드는 데 필요한 권한이 없습니다.', + 'import_perms_pages' => '페이지를 만드는 데 필요한 권한이 없습니다.', + 'import_perms_images' => '이미지를 만드는 데 필요한 권한이 없습니다.', + 'import_perms_attachments' => '첨부 파일을 만드는 데 필요한 권한이 없습니다.', // API errors 'api_no_authorization_found' => '요청에서 인증 토큰을 찾을 수 없습니다.', diff --git a/lang/ko/passwords.php b/lang/ko/passwords.php index 064f4d92576..97061631a85 100644 --- a/lang/ko/passwords.php +++ b/lang/ko/passwords.php @@ -6,10 +6,10 @@ */ return [ - 'password' => '여덟 글자를 넘어야 합니다.', + 'password' => '최소 8글자 이상이어야 합니다.', 'user' => "메일 주소를 가진 사용자가 없습니다.", 'token' => '유효하지 않거나 만료된 토큰입니다.', 'sent' => '메일을 보냈습니다.', - 'reset' => '패스워드를 바꿨습니다.', + 'reset' => '패스워드가 초기화되었습니다.', ]; diff --git a/lang/ko/preferences.php b/lang/ko/preferences.php index ded933923b6..79692f564c3 100644 --- a/lang/ko/preferences.php +++ b/lang/ko/preferences.php @@ -26,8 +26,8 @@ 'notifications_opt_comment_replies' => '내 댓글에 대한 답글 알림 받기', 'notifications_save' => '환경설정 저장', 'notifications_update_success' => '알림 환경설정이 업데이트되었습니다!', - 'notifications_watched' => '주시 및 무시한 항목', - 'notifications_watched_desc' => '아래는 사용자 지정 시계 환경설정이 적용된 항목입니다. 이러한 항목에 대한 환경설정을 업데이트하려면 해당 항목을 본 다음 사이드바에서 시계 옵션을 찾습니다.', + 'notifications_watched' => '알람 설정 및 알람 무시한 항목', + 'notifications_watched_desc' => '아래는 사용자 지정 알람 환경설정이 적용된 항목입니다. 이러한 항목에 대한 환경설정을 업데이트하려면 해당 항목을 본 다음 사이드바에서 알림 옵션을 찾습니다.', 'auth' => '액세스 및 보안', 'auth_change_password' => '패스워드 변경', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 7277891d55b..c2f8fd780c6 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -13,13 +13,13 @@ 'categories' => '카테고리', // App Settings - 'app_customization' => '맞춤', + 'app_customization' => '어플리케이션 설정', 'app_features_security' => '기능 및 보안', 'app_name' => '애플리케이션 이름 (사이트 제목)', 'app_name_desc' => '이 이름은 헤더와 시스템에서 보낸 모든 이메일에 표시됩니다.', 'app_name_header' => '헤더에 이름 표시', 'app_public_access' => '사이트 공개', - 'app_public_access_desc' => '이 옵션을 활성화하면 로그인하지 않은 방문자도 북스택 인스턴스의 콘텐츠에 액세스할 수 있습니다.', + 'app_public_access_desc' => '이 옵션을 활성화하면 로그인하지 않은 방문자도 이 서버의 콘텐츠에 액세스할 수 있습니다.', 'app_public_access_desc_guest' => '일반 방문자의 액세스는 "Guest" 사용자를 통해 제어할 수 있습니다.', 'app_public_access_toggle' => '공개 액세스 허용', 'app_public_viewing' => '공개 열람을 허용할까요?', @@ -63,7 +63,7 @@ // Registration Settings 'reg_settings' => '가입', 'reg_enable' => '가입 활성화', - 'reg_enable_toggle' => '가입 받기', + 'reg_enable_toggle' => '가입 허용', 'reg_enable_desc' => '가입한 사용자는 한 가지 권한을 가집니다.', 'reg_default_role' => '기본 권한', 'reg_enable_external_warning' => '외부 시스템이 LDAP나 SAML 인증이 활성화되어 있다면 설정과 관계없이 인증을 성공할 때 없는 계정을 만듭니다.', @@ -75,9 +75,9 @@ 'reg_confirm_restrict_domain_placeholder' => '차단한 도메인 없음', // Maintenance settings - 'maint' => '데이터', + 'maint' => '유지관리', 'maint_image_cleanup' => '이미지 정리', - 'maint_image_cleanup_desc' => '중복한 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.', + 'maint_image_cleanup_desc' => '중복인 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.', 'maint_delete_images_only_in_revisions' => '지난 버전에만 있는 이미지 지우기', 'maint_image_cleanup_run' => '실행', 'maint_image_cleanup_warning' => '이미지 :count개를 지울 건가요?', @@ -162,7 +162,7 @@ 'role_access_api' => '시스템 접근 API', 'role_manage_settings' => '사이트 설정 관리', 'role_export_content' => '항목 내보내기', - 'role_import_content' => 'Import content', + 'role_import_content' => '내용 가져오기', 'role_editor_change' => '페이지 편집기 변경', 'role_notifications' => '알림 수신 및 관리', 'role_asset' => '권한 항목', @@ -188,9 +188,9 @@ 'users_details_desc' => '메일 주소로 로그인합니다.', 'users_details_desc_no_email' => '사용자 이름을 바꿉니다.', 'users_role' => '사용자 권한', - 'users_role_desc' => '고른 권한 모두를 적용합니다.', + 'users_role_desc' => '이 사용자에게 할당될 역할을 선택합니다. 사용자가 여러 역할에 할당된 경우 해당 역할의 권한이 겹쳐서 적용됩니다. 할당된 역할의 모든 기능을 받게 됩니다.', 'users_password' => '사용자 패스워드', - 'users_password_desc' => '패스워드는 여덟 글자를 넘어야 합니다.', + 'users_password_desc' => '패스워드는 8 글자를 넘어야 합니다.', 'users_send_invite_text' => '패스워드 설정을 권유하는 메일을 보내거나 내가 정할 수 있습니다.', 'users_send_invite_option' => '메일 보내기', 'users_external_auth_id' => '외부 인증 계정', @@ -215,8 +215,8 @@ 'users_social_accounts_info' => '다른 계정으로 간단하게 로그인하세요. 여기에서 계정 연결을 끊는 것과 소셜 계정에서 접근 권한을 취소하는 것은 다릅니다.', 'users_social_connect' => '계정 연결', 'users_social_disconnect' => '계정 연결 끊기', - 'users_social_status_connected' => '연결됨', - 'users_social_status_disconnected' => '연결 해제됨', + 'users_social_status_connected' => '연결되었습니다.', + 'users_social_status_disconnected' => '연결 해제되었습니다.', 'users_social_connected' => ':socialAccount(와)과 연결했습니다.', 'users_social_disconnected' => ':socialAccount(와)과의 연결을 끊었습니다.', 'users_api_tokens' => 'API 토큰', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 7a4d228ce10..df766740342 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -105,10 +105,10 @@ 'url' => ':attribute(은)는 유효하지 않은 형식입니다.', 'uploaded' => '파일 크기가 서버에서 허용하는 수치를 넘습니다.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => ':attribute은(는) ZIP 파일 내의 파일을 참조해야 합니다.', + 'zip_file_mime' => ':attribute은(는) :validTypes, found :foundType 유형의 파일을 참조해야 합니다.', + 'zip_model_expected' => '데이터 객체가 필요하지만 ":type" 타입이 발견되었습니다.', + 'zip_unique' => ':attribute은(는) ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', // Custom validation lines 'custom' => [ diff --git a/lang/nl/editor.php b/lang/nl/editor.php index d82dd86014b..fa813ec16b4 100644 --- a/lang/nl/editor.php +++ b/lang/nl/editor.php @@ -163,8 +163,8 @@ 'about' => 'Over de bewerker', 'about_title' => 'Over de WYSIWYG Bewerker', 'editor_license' => 'Bewerker Licentie & Copyright', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Deze editor is gemaakt als een fork van :lexicalLink welke is verstrekt onder de MIT-licentie.', + 'editor_lexical_license_link' => 'Volledige licentieinformatie kan hier gevonden worden.', 'editor_tiny_license' => 'Deze editor is gemaakt met behulp van :tinyLink welke is verstrekt onder de MIT-licentie.', 'editor_tiny_license_link' => 'De copyright- en licentiegegevens van TinyMCE vindt u hier.', 'save_continue' => 'Pagina opslaan en verdergaan', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index 57967f9f1a6..878adb1e1c2 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -58,11 +58,11 @@ 'import_uploaded_at' => 'Geüpload :relativeTime', 'import_uploaded_by' => 'Geüpload door', 'import_location' => 'Importlocatie', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_location_desc' => 'Selecteer een locatie voor de geïmporteerde inhoud. Je hebt de bijbehorende machtigingen nodig op de importlocatie.', + 'import_delete_confirm' => 'Weet je zeker dat je deze import wilt verwijderen?', + 'import_delete_desc' => 'Dit zal het Zip-bestand van de import permanent verwijderen.', 'import_errors' => 'Importeerfouten', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import_errors_desc' => 'De volgende fouten deden zich voor tijdens het importeren:', // Permissions and restrictions 'permissions' => 'Machtigingen', @@ -373,7 +373,7 @@ 'attachments_link_attached' => 'Hyperlink succesvol gekoppeld aan de pagina', 'templates' => 'Sjablonen', 'templates_set_as_template' => 'Pagina is een sjabloon', - 'templates_explain_set_as_template' => 'Je kan deze pagina als sjabloon instellen zodat de inhoud gebruikt kan worden bij het maken van andere pagina\'s. Andere gebruikers kunnen dit sjabloon gebruiken als ze de machtiging hebben voor deze pagina.', + 'templates_explain_set_as_template' => 'Je kan deze pagina als sjabloon instellen zodat de inhoud gebruikt kan worden bij het maken van andere pagina\'s. Andere gebruikers kunnen dit sjabloon gebruiken als ze de machtiging hebben om deze pagina te zien.', 'templates_replace_content' => 'Pagina-inhoud vervangen', 'templates_append_content' => 'Toevoegen aan pagina-inhoud', 'templates_prepend_content' => 'Voeg vooraan toe aan pagina-inhoud', diff --git a/lang/nl/errors.php b/lang/nl/errors.php index 07900fc6b4f..d8d5af7239c 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -107,15 +107,15 @@ // Import 'import_zip_cant_read' => 'Kon het Zip-bestand niet lezen.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_cant_decode_data' => 'Kon de data.json Zip-inhoud niet vinden of decoderen.', + 'import_zip_no_data' => 'Zip-bestand bevat niet de verwachte boek, hoofdstuk of pagina-inhoud.', + 'import_validation_failed' => 'De validatie van het Zip-bestand is mislukt met de volgende fouten:', 'import_zip_failed_notification' => 'Importeren van het Zip-bestand is mislukt.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_perms_books' => 'Je mist de vereiste machtigingen om boeken te maken.', + 'import_perms_chapters' => 'Je mist de vereiste machtigingen om hoofdstukken te maken.', + 'import_perms_pages' => 'Je mist de vereiste machtigingen om pagina\'s te maken.', + 'import_perms_images' => 'Je mist de vereiste machtigingen om afbeeldingen toe te voegen.', + 'import_perms_attachments' => 'Je mist de vereiste machtigingen om bijlagen toe te voegen.', // API errors 'api_no_authorization_found' => 'Geen autorisatie token gevonden', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 646eb2ba995..5cdd45a7e71 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -155,7 +155,7 @@ 'role_external_auth_id' => 'Externe authenticatie ID\'s', 'role_system' => 'Systeem Machtigingen', 'role_manage_users' => 'Gebruikers beheren', - 'role_manage_roles' => 'Beheer rollen & rolmachtigingen', + 'role_manage_roles' => 'Beheer rollen & machtigingen', 'role_manage_entity_permissions' => 'Beheer alle machtigingen voor boeken, hoofdstukken en pagina\'s', 'role_manage_own_entity_permissions' => 'Beheer machtigingen van je eigen boek, hoofdstuk & pagina\'s', 'role_manage_page_templates' => 'Paginasjablonen beheren', diff --git a/lang/nl/validation.php b/lang/nl/validation.php index 52b1a22651a..7c7e20c8bfc 100644 --- a/lang/nl/validation.php +++ b/lang/nl/validation.php @@ -105,10 +105,10 @@ 'url' => ':attribute formaat is ongeldig.', 'uploaded' => 'Het bestand kon niet worden geüpload. De server accepteert mogelijk geen bestanden van deze grootte.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'Het \':attribute\' veld moet verwijzen naar een bestand in de ZIP.', + 'zip_file_mime' => 'Het \':attribute\' veld moet verwijzen naar een bestand met het type :validTypes, vond :foundType.', + 'zip_model_expected' => 'Dataobject verwacht maar vond ":type".', + 'zip_unique' => ':attribute moet uniek zijn voor het objecttype binnen de ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/pt_BR/activities.php b/lang/pt_BR/activities.php index 845b41cc305..536005d8f5b 100644 --- a/lang/pt_BR/activities.php +++ b/lang/pt_BR/activities.php @@ -40,14 +40,14 @@ 'book_sort_notification' => 'Livro reordenado com sucesso', // Bookshelves - 'bookshelf_create' => 'prateleira criada', + 'bookshelf_create' => 'estante criada', 'bookshelf_create_notification' => 'Prateleira criada com sucesso', 'bookshelf_create_from_book' => 'livro convertido em estante', - 'bookshelf_create_from_book_notification' => 'Capítulo convertido com sucesso em um livro', - 'bookshelf_update' => 'prateleira atualizada', - 'bookshelf_update_notification' => 'Prateleira atualizada com sucesso', - 'bookshelf_delete' => 'prateleira excluída', - 'bookshelf_delete_notification' => 'Prateleira excluída com sucesso', + 'bookshelf_create_from_book_notification' => 'Livro convertido com sucesso em uma estante', + 'bookshelf_update' => 'estante atualizada', + 'bookshelf_update_notification' => 'Estante atualizada com sucesso', + 'bookshelf_delete' => 'estante excluída', + 'bookshelf_delete_notification' => 'Estante excluída com sucesso', // Revisions 'revision_restore' => 'revisão restaurada', @@ -85,12 +85,12 @@ 'webhook_delete_notification' => 'Webhook excluido com sucesso', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'importação criada', + 'import_create_notification' => 'Importação carregada com sucesso', + 'import_run' => 'importação atualizada', + 'import_run_notification' => 'Conteúdo importado com sucesso', + 'import_delete' => 'importação excluída', + 'import_delete_notification' => 'Importação excluída com sucesso', // Users 'user_create' => 'usuário criado', diff --git a/lang/pt_BR/common.php b/lang/pt_BR/common.php index f5e6d230d43..10ac376517b 100644 --- a/lang/pt_BR/common.php +++ b/lang/pt_BR/common.php @@ -5,13 +5,13 @@ return [ // Buttons - 'cancel' => 'Cancela', - 'close' => 'Fecha', - 'confirm' => 'Confirma', - 'back' => 'Volta', - 'save' => 'Salva', - 'continue' => 'Continua', - 'select' => 'Seleciona', + 'cancel' => 'Cancelar', + 'close' => 'Fechar', + 'confirm' => 'Confirmar', + 'back' => 'Voltar', + 'save' => 'Salvar', + 'continue' => 'Continuar', + 'select' => 'Selecionar', 'toggle_all' => 'Alternar Tudo', 'more' => 'Mais ', diff --git a/lang/pt_BR/editor.php b/lang/pt_BR/editor.php index e251ba96636..f470647e3aa 100644 --- a/lang/pt_BR/editor.php +++ b/lang/pt_BR/editor.php @@ -40,12 +40,12 @@ 'callout_success' => 'Sucesso', 'callout_warning' => 'Atenção', 'callout_danger' => 'Perigo', - 'bold' => 'Bold', + 'bold' => 'Negrito', 'italic' => 'Itálico', - 'underline' => 'Sublinhar', + 'underline' => 'Sublinhado', 'strikethrough' => 'Riscado', - 'superscript' => 'Superinscrição', - 'subscript' => 'Subscrição', + 'superscript' => 'Sobrescrito', + 'subscript' => 'Subscrito', 'text_color' => 'Cor do texto', 'custom_color' => 'Cor personalizada', 'remove_color' => 'Remover cor', @@ -163,8 +163,8 @@ 'about' => 'Sobre o editor', 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Licença do Editor e Direitos Autorais', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Este editor é criado como uma bifurcação de :lexicalLink distribuído sob a licença MIT.', + 'editor_lexical_license_link' => 'Aqui podem ser encontrados detalhes da licença.', 'editor_tiny_license' => 'Este editor é construído usando :tinyLink que é fornecido sob a licença MIT.', 'editor_tiny_license_link' => 'Os dados relativos aos direitos de autor e à licença do TinyMCE podem ser encontrados aqui.', 'save_continue' => 'Salvar Página e Continuar', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index b3e794a2d76..fe73c35b810 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -17,13 +17,13 @@ 'recent_activity' => 'Atividades recentes', 'create_now' => 'Criar agora', 'revisions' => 'Revisões', - 'meta_revision' => 'Revisão #: contagem de revisões', - 'meta_created' => 'Criado: duração de tempo', - 'meta_created_name' => 'Criado: duração de tempo por usuário', - 'meta_updated' => 'Atualizado: duração de tempo', - 'meta_updated_name' => 'Atualizado: duração de tempo por usuário', - 'meta_owned_name' => 'Propriedade de: usuário', - 'meta_reference_count' => 'Referenciado por: count item|Referenciado por: count itens', + 'meta_revision' => 'Revisão #:revisionCount', + 'meta_created' => 'Criado :timeLength', + 'meta_created_name' => 'Criado :timeLength por :user', + 'meta_updated' => 'Atualizado :timeLength', + 'meta_updated_name' => 'Atualizado: :timeLength por :user', + 'meta_owned_name' => 'Propriedade de :user', + 'meta_reference_count' => 'Referenciado por :count item|Referenciado por :count itens', 'entity_select' => 'Seleção de entidade', 'entity_select_lack_permission' => 'Você não tem as permissões necessárias para selecionar este ‘item’', 'images' => 'Imagens', @@ -39,30 +39,30 @@ 'export_pdf' => 'Arquivo PDF', 'export_text' => 'Arquivo de texto simples', 'export_md' => 'Arquivo de redução', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'ZIP portátil', 'default_template' => 'Modelo padrão de página', 'default_template_explain' => 'Atribuir o modelo de página que será usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.', 'default_template_select' => 'Selecione uma página de modelo', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import' => 'Importação', + 'import_validate' => 'Validar Importação', + 'import_desc' => 'Importar livros, capítulos e páginas usando uma exportação zip portátil da mesma ou de uma instância diferente. Selecione um arquivo ZIP para prosseguir. Após a carga e validação do arquivo, você será capaz de configurar e confirmar a importação na próxima visualização.', + 'import_zip_select' => 'Selecione o arquivo ZIP para carregar', + 'import_zip_validation_errors' => 'Foram detectados erros ao validar o arquivo ZIP:', + 'import_pending' => 'Importações pendentes', + 'import_pending_none' => 'Nenhuma importação foi iniciada.', + 'import_continue' => 'Continuar a importação', + 'import_continue_desc' => 'Revise o conteúdo que deve ser importado do arquivo ZIP carregado. Quando estiver pronto, execute a importação para adicionar seu conteúdo a este sistema. O arquivo ZIP importado será automaticamente removido quando a importação for bem sucedida.', + 'import_details' => 'Detalhes da importação', + 'import_run' => 'Executar Importação', + 'import_size' => ':size Tamanho do ZIP', + 'import_uploaded_at' => 'Carregado :relativeTime', + 'import_uploaded_by' => 'Enviado por', + 'import_location' => 'Local da importação', + 'import_location_desc' => 'Selecione um local para o conteúdo importado. Você precisa das permissões necessárias para criar no local escolhido.', + 'import_delete_confirm' => 'Tem certeza que deseja excluir esta importação?', + 'import_delete_desc' => 'Isto irá excluir o arquivo ZIP de importação carregado e não poderá ser desfeito.', + 'import_errors' => 'Erros de importação', + 'import_errors_desc' => 'Os seguintes erros ocorreram durante a tentativa de importação:', // Permissions and restrictions 'permissions' => 'Permissões', @@ -78,10 +78,10 @@ // Search 'search_results' => 'Resultado(s) da Pesquisa', - 'search_total_results_found' => ':count resultado encontrado (contagem de resultados) :count resultados totais encontrados', + 'search_total_results_found' => ':count resultado encontrado|:count resultados encontrados', 'search_clear' => 'Limpar Pesquisa', 'search_no_pages' => 'Nenhuma página corresponde a esta pesquisa', - 'search_for_term' => 'Pesquisar por: termo', + 'search_for_term' => 'Pesquisar por :term', 'search_more' => 'Mais resultados', 'search_advanced' => 'Pesquisa avançada', 'search_terms' => 'Termos da pesquisa', @@ -246,7 +246,7 @@ 'pages_edit_switch_to_markdown_stable' => '(Conteúdo Estável)', 'pages_edit_switch_to_wysiwyg' => 'Alternar para o Editor WYSIWYG', 'pages_edit_switch_to_new_wysiwyg' => 'Mudar para o novo WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(No Teste Alfa)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Em teste alfa)', 'pages_edit_set_changelog' => 'Relatar Alterações', 'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por você', 'pages_edit_enter_changelog' => 'Insira Alterações', diff --git a/lang/pt_BR/errors.php b/lang/pt_BR/errors.php index 15464c95ac3..fdad94139d5 100644 --- a/lang/pt_BR/errors.php +++ b/lang/pt_BR/errors.php @@ -22,17 +22,17 @@ 'saml_already_logged_in' => '\'Login\' já efetuado', 'saml_no_email_address' => 'Não foi possível encontrar um endereço de mensagem eletrônica para este usuário nos dados providos pelo sistema de autenticação externa', 'saml_invalid_response_id' => 'A requisição do sistema de autenticação externa não foi reconhecida por um processo iniciado por esta aplicação. Após o \'login\', navegar para o caminho anterior pode causar um problema.', - 'saml_fail_authed' => '\'Login\' utilizando :sistema falhou. Sistema não forneceu autorização bem sucedida', + 'saml_fail_authed' => 'Login utilizando :system falhou. Sistema não forneceu autorização bem sucedida', 'oidc_already_logged_in' => '\'Login\' já efetuado', 'oidc_no_email_address' => 'Não foi possível encontrar um endereço de mensagem eletrônica para este usuário, nos dados fornecidos pelo sistema de autenticação externa', - 'oidc_fail_authed' => '\'Login\' usando: sistema falhou, o sistema não forneceu autorização com sucesso', + 'oidc_fail_authed' => 'Login usando :system falhou, o sistema não forneceu autorização com sucesso', 'social_no_action_defined' => 'Nenhuma ação definida', 'social_login_bad_response' => "Erro recebido durante o 'login' :socialAccount: \n: error", - 'social_account_in_use' => 'Essa conta: conta social já está em uso. Por favor, tente entrar utilizando a opção :conta social.', - 'social_account_email_in_use' => 'O endereço eletrônico: endereço eletrônico já está em uso. Se você já tem uma conta você poderá se conectar a conta social a partir das configurações de seu perfil.', - 'social_account_existing' => 'Essa conta: conta social já está vinculada a esse perfil.', - 'social_account_already_used_existing' => 'Essa conta: conta social já está sendo utilizada por outro usuário.', - 'social_account_not_used' => 'Essa conta: conta social não está vinculada a nenhum usuário. Por favor, vincule a conta nas suas configurações de perfil. ', + 'social_account_in_use' => 'Essa conta :socialAccount já está em uso. Por favor, tente entrar utilizando a opção :socialAccount.', + 'social_account_email_in_use' => 'O e-mail :email já está em uso. Se você já tem uma conta você poderá se conectar a conta :socialAccount a partir das configurações de seu perfil.', + 'social_account_existing' => 'Essa conta :socialAccount já está vinculada a esse perfil.', + 'social_account_already_used_existing' => 'Essa conta :socialAccount já está sendo utilizada por outro usuário.', + 'social_account_not_used' => 'Essa conta :socialAccount não está vinculada a nenhum usuário. Por favor vincule a conta nas suas configurações de perfil. ', 'social_account_register_instructions' => 'Se você não tem uma conta, você poderá se cadastrar usando a opção: conta social.', 'social_driver_not_found' => 'Social driver não encontrado', 'social_driver_not_configured' => 'Seus parâmetros sociais de: conta social não estão configurados corretamente.', @@ -40,8 +40,8 @@ 'login_user_not_found' => 'Não foi possível encontrar um usuário para esta ação.', // System - 'path_not_writable' => 'O caminho de destino de ‘upload’ de arquivo não possui permissão de escrita. Certifique-se que ele possui direitos de escrita no servidor.', - 'cannot_get_image_from_url' => 'Não foi possível obter a imagem a partir de: URL', + 'path_not_writable' => 'O caminho de destino (:filePath) de upload de arquivo não possui permissão de escrita. Certifique-se que ele possui direitos de escrita no servidor.', + 'cannot_get_image_from_url' => 'Não foi possível obter a imagem a partir de :url', 'cannot_create_thumbs' => 'O servidor não pôde criar as miniaturas de imagem. Por favor, verifique se a extensão GD PHP está instalada.', 'server_upload_limit' => 'O servidor não permite o ‘upload’ de arquivos com esse tamanho. Por favor, tente um tamanho de arquivo menor.', 'server_post_limit' => 'O servidor não pode receber a quantidade de dados fornecida. Tente novamente com menos dados ou um arquivo menor.', @@ -103,20 +103,20 @@ 'image_not_found_details' => 'Se você esperava que esta imagem existisse, ela pode ter sido excluída.', 'return_home' => 'Retornar à página inicial', 'error_occurred' => 'Ocorreu um Erro', - 'app_down' => 'Agora está baixo', + 'app_down' => ':appName está fora do ar no momento', 'back_soon' => 'Vai estar de volta em breve.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Não foi possível ler o arquivo ZIP.', + 'import_zip_cant_decode_data' => 'Não foi possível encontrar e decodificar o conteúdo ZIP data.json.', + 'import_zip_no_data' => 'Os dados do arquivo ZIP não têm o conteúdo esperado livro, capítulo ou página.', + 'import_validation_failed' => 'Falhou na validação da importação do ZIP com erros:', + 'import_zip_failed_notification' => 'Falhou ao importar arquivo ZIP.', + 'import_perms_books' => 'Você não tem as permissões necessárias para criar livros.', + 'import_perms_chapters' => 'Você não tem as permissões necessárias para criar capítulos.', + 'import_perms_pages' => 'Você não tem as permissões necessárias para criar páginas.', + 'import_perms_images' => 'Está não tem permissões necessárias para criar imagens.', + 'import_perms_attachments' => 'Você não tem a permissão necessária para criar anexos.', // API errors 'api_no_authorization_found' => 'Nenhum código de autorização encontrado na requisição', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index a5b68c7e4ec..30d5b82a1cf 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -162,7 +162,7 @@ 'role_access_api' => 'Acessar API do sistema', 'role_manage_settings' => 'Gerenciar configurações da aplicação', 'role_export_content' => 'Exportar conteúdo', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importar conteúdo', 'role_editor_change' => 'Alterar página de edição', 'role_notifications' => 'Receber e gerenciar notificações', 'role_asset' => 'Permissões de Ativos', diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php index b3a9a729438..9ddfdf2a566 100644 --- a/lang/pt_BR/validation.php +++ b/lang/pt_BR/validation.php @@ -105,10 +105,10 @@ 'url' => 'O formato da URL :attribute é inválido.', 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'O :attribute precisa fazer referência a um arquivo do ZIP.', + 'zip_file_mime' => 'O :attribute precisa fazer referência a um arquivo do tipo :validTypes, encontrado :foundType.', + 'zip_model_expected' => 'Objeto de dados esperado, mas ":type" encontrado.', + 'zip_unique' => 'O :attribute deve ser único para o tipo de objeto dentro do ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/sk/activities.php b/lang/sk/activities.php index a1ef597ca24..5e1800dabf6 100644 --- a/lang/sk/activities.php +++ b/lang/sk/activities.php @@ -51,8 +51,8 @@ // Revisions 'revision_restore' => 'restored revision', - 'revision_delete' => 'deleted revision', - 'revision_delete_notification' => 'Revision successfully deleted', + 'revision_delete' => 'odstránil(a) revíziu', + 'revision_delete_notification' => 'Revízia úspešne odstránená', // Favourites 'favourite_add_notification' => '":name" bol pridaný medzi obľúbené', @@ -62,7 +62,7 @@ 'watch_update_level_notification' => 'Watch preferences successfully updated', // Auth - 'auth_login' => 'logged in', + 'auth_login' => 'sa prihlásil(a)', 'auth_register' => 'registered as new user', 'auth_password_reset_request' => 'requested user password reset', 'auth_password_reset_update' => 'reset user password', @@ -123,8 +123,8 @@ // Comments 'commented_on' => 'komentoval(a)', - 'comment_create' => 'added comment', - 'comment_update' => 'updated comment', + 'comment_create' => 'pridal(a) komentár', + 'comment_update' => 'aktualizoval(a) komentár', 'comment_delete' => 'odstrániť komentár', // Other From 5116d83d388d7994d5ce62b63c3c512fdf37bbc5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 9 Jan 2025 16:46:13 +0000 Subject: [PATCH 282/800] PHP: Updated min version to 8.2 PHPStan config not yet compatible, but should work after moving to Laravel 11, which would allow using larastan 3.x. --- .github/workflows/test-php.yml | 2 +- composer.json | 4 +- composer.lock | 95 +++++++++++++++++----------------- phpstan.neon.dist | 4 +- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index ee9cf39bc2a..277af907025 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index b8d8da9e72f..426602e9a94 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "license": "MIT", "type": "project", "require": { - "php": "^8.1.0", + "php": "^8.2.0", "ext-curl": "*", "ext-dom": "*", "ext-fileinfo": "*", @@ -104,7 +104,7 @@ "preferred-install": "dist", "sort-packages": true, "platform": { - "php": "8.1.0" + "php": "8.2.0" } }, "extra": { diff --git a/composer.lock b/composer.lock index 16156f2dc11..a2ceed629f8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9c0520d8b0c13ae46bd0213c4dec5e38", + "content-hash": "a8875f121a0e28301e3ca8b3e63d394c", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.336.8", + "version": "3.336.11", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6" + "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", - "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/442039c766a82f06ecfecb0ac2c610d6aaba228d", + "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.336.8" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.11" }, - "time": "2025-01-03T19:06:11+00:00" + "time": "2025-01-08T19:06:59+00:00" }, { "name": "bacon/bacon-qr-code", @@ -2016,16 +2016,16 @@ }, { "name": "knplabs/knp-snappy", - "version": "v1.5.0", + "version": "v1.5.1", "source": { "type": "git", "url": "https://github.com/KnpLabs/snappy.git", - "reference": "98468898b50c09f26d56d905b79b0f52a2215da6" + "reference": "3dd138e9e47de91cd2e056c5e6e1a0dd72547ee7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/98468898b50c09f26d56d905b79b0f52a2215da6", - "reference": "98468898b50c09f26d56d905b79b0f52a2215da6", + "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/3dd138e9e47de91cd2e056c5e6e1a0dd72547ee7", + "reference": "3dd138e9e47de91cd2e056c5e6e1a0dd72547ee7", "shasum": "" }, "require": { @@ -2077,9 +2077,9 @@ ], "support": { "issues": "https://github.com/KnpLabs/snappy/issues", - "source": "https://github.com/KnpLabs/snappy/tree/v1.5.0" + "source": "https://github.com/KnpLabs/snappy/tree/v1.5.1" }, - "time": "2023-12-18T09:12:11+00:00" + "time": "2025-01-06T16:53:26+00:00" }, { "name": "laravel/framework", @@ -3448,12 +3448,12 @@ "version": "2.72.6", "source": { "type": "git", - "url": "https://github.com/briannesbitt/Carbon.git", + "url": "https://github.com/CarbonPHP/carbon.git", "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5", "shasum": "" }, @@ -5936,24 +5936,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v6.4.13", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e" + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", - "reference": "0ffc48080ab3e9132ea74ef4e09d8dcf26bf897e", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -5962,13 +5962,13 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5996,7 +5996,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.13" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" }, "funding": [ { @@ -6012,7 +6012,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7295,20 +7295,20 @@ }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", @@ -7318,11 +7318,12 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7361,7 +7362,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v7.2.0" }, "funding": [ { @@ -7377,7 +7378,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2024-11-13T13:31:26+00:00" }, { "name": "symfony/translation", @@ -8784,16 +8785,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.14", + "version": "1.12.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38" + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e73868f809e68fff33be961ad4946e2e43ec9e38", - "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", "shasum": "" }, "require": { @@ -8838,7 +8839,7 @@ "type": "github" } ], - "time": "2024-12-31T07:26:13+00:00" + "time": "2025-01-05T16:40:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10428,7 +10429,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.1.0", + "php": "^8.2.0", "ext-curl": "*", "ext-dom": "*", "ext-fileinfo": "*", @@ -10440,7 +10441,7 @@ }, "platform-dev": {}, "platform-overrides": { - "php": "8.1.0" + "php": "8.2.0" }, "plugin-api-version": "2.6.0" } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bcf4e5aa208..aa2ad3d9e0f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,7 +9,9 @@ parameters: # The level 8 is the highest level level: 1 - phpVersion: 80200 + phpVersion: + min: 80200 + max: 80400 bootstrapFiles: - bootstrap/phpstan.php From cf9ccfcd5b0186249fd904067a1f343ea2f1dd04 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 11:14:49 +0000 Subject: [PATCH 283/800] Framework: Performed Laravel 11 upgrade guide steps Performed a little code cleanups when observed along the way. Tested not yet ran. --- app/Access/ExternalBaseUserProvider.php | 49 +- app/App/Providers/EventServiceProvider.php | 8 + app/Config/broadcasting.php | 37 - app/Config/cache.php | 5 +- app/Config/filesystems.php | 3 + app/Config/mail.php | 10 +- app/Config/queue.php | 1 + app/Uploads/FileStorage.php | 1 - composer.json | 8 +- composer.lock | 1452 ++++++++--------- ..._04_25_140741_update_polymorphic_types.php | 4 +- public/index.php | 35 +- 12 files changed, 694 insertions(+), 919 deletions(-) delete mode 100644 app/Config/broadcasting.php diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index d3ece983fb8..2b5ddfbf3d0 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -8,27 +8,15 @@ class ExternalBaseUserProvider implements UserProvider { - /** - * The user model. - * - * @var string - */ - protected $model; - - /** - * LdapUserProvider constructor. - */ - public function __construct(string $model) - { - $this->model = $model; + public function __construct( + protected string $model + ) { } /** * Create a new instance of the model. - * - * @return Model */ - public function createModel() + public function createModel(): Model { $class = '\\' . ltrim($this->model, '\\'); @@ -37,12 +25,8 @@ public function createModel() /** * Retrieve a user by their unique identifier. - * - * @param mixed $identifier - * - * @return Authenticatable|null */ - public function retrieveById($identifier) + public function retrieveById(mixed $identifier): ?Authenticatable { return $this->createModel()->newQuery()->find($identifier); } @@ -50,12 +34,9 @@ public function retrieveById($identifier) /** * Retrieve a user by their unique identifier and "remember me" token. * - * @param mixed $identifier * @param string $token - * - * @return Authenticatable|null */ - public function retrieveByToken($identifier, $token) + public function retrieveByToken(mixed $identifier, $token): null { return null; } @@ -75,12 +56,8 @@ public function updateRememberToken(Authenticatable $user, $token) /** * Retrieve a user by the given credentials. - * - * @param array $credentials - * - * @return Authenticatable|null */ - public function retrieveByCredentials(array $credentials) + public function retrieveByCredentials(array $credentials): ?Authenticatable { // Search current user base by looking up a uid $model = $this->createModel(); @@ -92,15 +69,15 @@ public function retrieveByCredentials(array $credentials) /** * Validate a user against the given credentials. - * - * @param Authenticatable $user - * @param array $credentials - * - * @return bool */ - public function validateCredentials(Authenticatable $user, array $credentials) + public function validateCredentials(Authenticatable $user, array $credentials): bool { // Should be done in the guard. return false; } + + public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false) + { + // No action to perform, any passwords are external in the auth system + } } diff --git a/app/App/Providers/EventServiceProvider.php b/app/App/Providers/EventServiceProvider.php index 4cd527ba4a4..34ab7cfefea 100644 --- a/app/App/Providers/EventServiceProvider.php +++ b/app/App/Providers/EventServiceProvider.php @@ -42,4 +42,12 @@ public function shouldDiscoverEvents(): bool { return false; } + + /** + * Overrides the registration of Laravel's default email verification system + */ + protected function configureEmailVerification(): void + { + // + } } diff --git a/app/Config/broadcasting.php b/app/Config/broadcasting.php deleted file mode 100644 index 3b95698ccc2..00000000000 --- a/app/Config/broadcasting.php +++ /dev/null @@ -1,37 +0,0 @@ - 'null', - - // Broadcast Connections - // Here you may define all of the broadcast connections that will be used - // to broadcast events to other systems or over websockets. Samples of - // each available type of connection are provided inside this array. - 'connections' => [ - - // Default options removed since we don't use broadcasting. - - 'log' => [ - 'driver' => 'log', - ], - - 'null' => [ - 'driver' => 'null', - ], - - ], - -]; diff --git a/app/Config/cache.php b/app/Config/cache.php index b588437ff1a..9a0be8eabfc 100644 --- a/app/Config/cache.php +++ b/app/Config/cache.php @@ -35,10 +35,6 @@ // Available caches stores 'stores' => [ - 'apc' => [ - 'driver' => 'apc', - ], - 'array' => [ 'driver' => 'array', 'serialize' => false, @@ -49,6 +45,7 @@ 'table' => 'cache', 'connection' => null, 'lock_connection' => null, + 'lock_table' => null, ], 'file' => [ diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index 1319c8886f6..08ae7b04776 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -33,12 +33,14 @@ 'driver' => 'local', 'root' => public_path(), 'visibility' => 'public', + 'serve' => false, 'throw' => true, ], 'local_secure_attachments' => [ 'driver' => 'local', 'root' => storage_path('uploads/files/'), + 'serve' => false, 'throw' => true, ], @@ -46,6 +48,7 @@ 'driver' => 'local', 'root' => storage_path('uploads/images/'), 'visibility' => 'public', + 'serve' => false, 'throw' => true, ], diff --git a/app/Config/mail.php b/app/Config/mail.php index 2906d769afd..038864f8cf4 100644 --- a/app/Config/mail.php +++ b/app/Config/mail.php @@ -38,7 +38,7 @@ 'password' => env('MAIL_PASSWORD'), 'verify_peer' => env('MAIL_VERIFY_SSL', true), 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN'), + 'local_domain' => null, 'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'), ], @@ -64,12 +64,4 @@ ], ], ], - - // Email markdown configuration - 'markdown' => [ - 'theme' => 'default', - 'paths' => [ - resource_path('views/vendor/mail'), - ], - ], ]; diff --git a/app/Config/queue.php b/app/Config/queue.php index 795a7932545..08f3a5baab5 100644 --- a/app/Config/queue.php +++ b/app/Config/queue.php @@ -23,6 +23,7 @@ 'database' => [ 'driver' => 'database', + 'connection' => null, 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index e6ac368d000..6e4a210a162 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\FileUploadException; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; -use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; diff --git a/composer.json b/composer.json index 426602e9a94..58b89fcce60 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,11 @@ "ext-xml": "*", "ext-zip": "*", "bacon/bacon-qr-code": "^3.0", - "doctrine/dbal": "^3.5", "dompdf/dompdf": "^3.0", "guzzlehttp/guzzle": "^7.4", "intervention/image": "^3.5", "knplabs/knp-snappy": "^1.5", - "laravel/framework": "^10.48.23", + "laravel/framework": "^v11.37", "laravel/socialite": "^5.10", "laravel/tinker": "^2.8", "league/commonmark": "^2.3", @@ -39,14 +38,13 @@ "socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/okta": "^4.2", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^1.0.2", - "ssddanbrown/symfony-mailer": "6.4.x-dev" + "ssddanbrown/htmldiff": "^1.0.2" }, "require-dev": { "fakerphp/faker": "^1.21", "itsgoingd/clockwork": "^5.1", "mockery/mockery": "^1.5", - "nunomaduro/collision": "^7.0", + "nunomaduro/collision": "^8.1", "larastan/larastan": "^2.7", "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^3.7", diff --git a/composer.lock b/composer.lock index a2ceed629f8..1d512f85148 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a8875f121a0e28301e3ca8b3e63d394c", + "content-hash": "518176ac5bb608061e0f74b06fdae582", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.336.11", + "version": "3.336.13", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d" + "reference": "dcb43c029ca74c52fa03a739341cc77086296a83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/442039c766a82f06ecfecb0ac2c610d6aaba228d", - "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/dcb43c029ca74c52fa03a739341cc77086296a83", + "reference": "dcb43c029ca74c52fa03a739341cc77086296a83", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.336.11" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.13" }, - "time": "2025-01-08T19:06:59+00:00" + "time": "2025-01-10T19:04:25+00:00" }, { "name": "bacon/bacon-qr-code", @@ -274,26 +274,26 @@ }, { "name": "carbonphp/carbon-doctrine-types", - "version": "2.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb" + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.1" }, "conflict": { - "doctrine/dbal": "<3.7.0 || >=4.0.0" + "doctrine/dbal": "<4.0.0 || >=5.0.0" }, "require-dev": { - "doctrine/dbal": "^3.7.0", + "doctrine/dbal": "^4.0.0", "nesbot/carbon": "^2.71.0 || ^3.0.0", "phpunit/phpunit": "^10.3" }, @@ -323,7 +323,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" }, "funding": [ { @@ -339,7 +339,7 @@ "type": "tidelift" } ], - "time": "2023-12-11T17:09:12+00:00" + "time": "2024-02-09T16:56:22+00:00" }, { "name": "dasprid/enum", @@ -466,348 +466,6 @@ }, "time": "2024-07-08T12:26:09+00:00" }, - { - "name": "doctrine/cache", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", - "shasum": "" - }, - "require": { - "php": "~7.1 || ^8.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "symfony/var-exporter": "^4.4 || ^5.4 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", - "homepage": "https://www.doctrine-project.org/projects/cache.html", - "keywords": [ - "abstraction", - "apcu", - "cache", - "caching", - "couchdb", - "memcached", - "php", - "redis", - "xcache" - ], - "support": { - "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", - "type": "tidelift" - } - ], - "time": "2022-05-20T20:07:39+00:00" - }, - { - "name": "doctrine/dbal", - "version": "3.9.3", - "source": { - "type": "git", - "url": "https://github.com/doctrine/dbal.git", - "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", - "reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "doctrine/cache": "^1.11|^2.0", - "doctrine/deprecations": "^0.5.3|^1", - "doctrine/event-manager": "^1|^2", - "php": "^7.4 || ^8.0", - "psr/cache": "^1|^2|^3", - "psr/log": "^1|^2|^3" - }, - "require-dev": { - "doctrine/coding-standard": "12.0.0", - "fig/log-test": "^1", - "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.12.6", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "9.6.20", - "psalm/plugin-phpunit": "0.18.4", - "slevomat/coding-standard": "8.13.1", - "squizlabs/php_codesniffer": "3.10.2", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0", - "vimeo/psalm": "4.30.0" - }, - "suggest": { - "symfony/console": "For helpful console commands such as SQL execution and import of files." - }, - "bin": [ - "bin/doctrine-dbal" - ], - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\DBAL\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - } - ], - "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", - "homepage": "https://www.doctrine-project.org/projects/dbal.html", - "keywords": [ - "abstraction", - "database", - "db2", - "dbal", - "mariadb", - "mssql", - "mysql", - "oci8", - "oracle", - "pdo", - "pgsql", - "postgresql", - "queryobject", - "sasql", - "sql", - "sqlite", - "sqlserver", - "sqlsrv" - ], - "support": { - "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.3" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", - "type": "tidelift" - } - ], - "time": "2024-10-10T17:56:43+00:00" - }, - { - "name": "doctrine/deprecations", - "version": "1.1.4", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", - "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" - }, - "time": "2024-12-07T21:18:45+00:00" - }, - { - "name": "doctrine/event-manager", - "version": "2.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/event-manager.git", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "conflict": { - "doctrine/common": "<2.9" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.24" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - } - ], - "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", - "homepage": "https://www.doctrine-project.org/projects/event-manager.html", - "keywords": [ - "event", - "event dispatcher", - "event manager", - "event system", - "events" - ], - "support": { - "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", - "type": "tidelift" - } - ], - "time": "2024-05-22T20:47:39+00:00" - }, { "name": "doctrine/inflector", "version": "2.0.10", @@ -2083,23 +1741,23 @@ }, { "name": "laravel/framework", - "version": "v10.48.25", + "version": "v11.37.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c" + "reference": "6cb103d2024b087eae207654b3f4b26646119ba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f132b23b13909cc22c615c01b0c5640541c3da0c", - "reference": "f132b23b13909cc22c615c01b0c5640541c3da0c", + "url": "https://api.github.com/repos/laravel/framework/zipball/6cb103d2024b087eae207654b3f4b26646119ba5", + "reference": "6cb103d2024b087eae207654b3f4b26646119ba5", "shasum": "" }, "require": { "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", - "dragonmantank/cron-expression": "^3.3.2", + "dragonmantank/cron-expression": "^3.4", "egulias/email-validator": "^3.2.1|^4.0", "ext-ctype": "*", "ext-filter": "*", @@ -2108,44 +1766,45 @@ "ext-openssl": "*", "ext-session": "*", "ext-tokenizer": "*", - "fruitcake/php-cors": "^1.2", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.9", - "laravel/serializable-closure": "^1.3", - "league/commonmark": "^2.2.1", - "league/flysystem": "^3.8.0", + "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.6", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", "monolog/monolog": "^3.0", - "nesbot/carbon": "^2.67", - "nunomaduro/termwind": "^1.13", - "php": "^8.1", + "nesbot/carbon": "^2.72.2|^3.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", "psr/container": "^1.1.1|^2.0.1", "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^6.2", - "symfony/error-handler": "^6.2", - "symfony/finder": "^6.2", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.2", - "symfony/mailer": "^6.2", - "symfony/mime": "^6.2", - "symfony/process": "^6.2", - "symfony/routing": "^6.2", - "symfony/uid": "^6.2", - "symfony/var-dumper": "^6.2", + "symfony/console": "^7.0.3", + "symfony/error-handler": "^7.0.3", + "symfony/finder": "^7.0.3", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.0.3", + "symfony/mailer": "^7.0.3", + "symfony/mime": "^7.0.3", + "symfony/polyfill-php83": "^1.31", + "symfony/process": "^7.0.3", + "symfony/routing": "^7.0.3", + "symfony/uid": "^7.0.3", + "symfony/var-dumper": "^7.0.3", "tijsverkoyen/css-to-inline-styles": "^2.2.5", - "vlucas/phpdotenv": "^5.4.1", - "voku/portable-ascii": "^2.0" + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" }, "conflict": { - "carbonphp/carbon-doctrine-types": ">=3.0", - "doctrine/dbal": ">=4.0", - "mockery/mockery": "1.6.8", - "phpunit/phpunit": ">=11.0.0", "tightenco/collect": "<5.5.33" }, "provide": { "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", "psr/simple-cache-implementation": "1.0|2.0|3.0" }, "replace": { @@ -2154,6 +1813,7 @@ "illuminate/bus": "self.version", "illuminate/cache": "self.version", "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", "illuminate/conditionable": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", @@ -2181,36 +1841,38 @@ "illuminate/testing": "self.version", "illuminate/translation": "self.version", "illuminate/validation": "self.version", - "illuminate/view": "self.version" + "illuminate/view": "self.version", + "spatie/once": "*" }, "require-dev": { "ably/ably-php": "^1.0", - "aws/aws-sdk-php": "^3.235.5", - "doctrine/dbal": "^3.5.1", + "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", - "fakerphp/faker": "^1.21", - "guzzlehttp/guzzle": "^7.5", - "league/flysystem-aws-s3-v3": "^3.0", - "league/flysystem-ftp": "^3.0", - "league/flysystem-path-prefixing": "^3.3", - "league/flysystem-read-only": "^3.3", - "league/flysystem-sftp-v3": "^3.0", - "mockery/mockery": "^1.5.1", - "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^8.23.4", - "pda/pheanstalk": "^4.0", - "phpstan/phpstan": "~1.11.11", - "phpunit/phpunit": "^10.0.7", - "predis/predis": "^2.0.2", - "symfony/cache": "^6.2", - "symfony/http-client": "^6.2.4", - "symfony/psr-http-message-bridge": "^2.0" + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^9.6", + "pda/pheanstalk": "^5.0.6", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^1.11.5", + "phpunit/phpunit": "^10.5.35|^11.3.6", + "predis/predis": "^2.3", + "resend/resend-php": "^0.10.0", + "symfony/cache": "^7.0.3", + "symfony/http-client": "^7.0.3", + "symfony/psr-http-message-bridge": "^7.0.3", + "symfony/translation": "^7.0.3" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.235.5).", - "brianium/paratest": "Required to run tests in parallel (^6.0).", - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^3.5.1).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -2219,42 +1881,45 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", - "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.5).", "laravel/tinker": "Required to use the tinker console command (^2.0).", - "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", - "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", - "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).", - "league/flysystem-read-only": "Required to use read-only disks (^3.3)", - "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", - "mockery/mockery": "Required to use mocking (^1.5.1).", - "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8|^10.0.7).", - "predis/predis": "Required to use the predis connector (^2.0.2).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5|^11.0).", + "predis/predis": "Required to use the predis connector (^2.3).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^6.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^6.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^6.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "11.x-dev" } }, "autoload": { "files": [ + "src/Illuminate/Collections/functions.php", "src/Illuminate/Collections/helpers.php", "src/Illuminate/Events/functions.php", "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { @@ -2286,25 +1951,25 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-11-26T15:32:57+00:00" + "time": "2025-01-02T20:10:21+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.25", + "version": "v0.3.2", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" + "reference": "0e0535747c6b8d6d10adca8b68293cf4517abb0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", + "url": "https://api.github.com/repos/laravel/prompts/zipball/0e0535747c6b8d6d10adca8b68293cf4517abb0f", + "reference": "0e0535747c6b8d6d10adca8b68293cf4517abb0f", "shasum": "" }, "require": { + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0|^11.0", "php": "^8.1", "symfony/console": "^6.2|^7.0" }, @@ -2313,8 +1978,9 @@ "laravel/framework": ">=10.17.0 <10.25.0" }, "require-dev": { + "illuminate/collections": "^10.0|^11.0", "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3", + "pestphp/pest": "^2.3|^3.4", "phpstan/phpstan": "^1.11", "phpstan/phpstan-mockery": "^1.1" }, @@ -2324,7 +1990,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { @@ -2342,38 +2008,38 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.25" + "source": "https://github.com/laravel/prompts/tree/v0.3.2" }, - "time": "2024-08-12T22:06:33+00:00" + "time": "2024-11-12T14:59:47+00:00" }, { "name": "laravel/serializable-closure", - "version": "v1.3.7", + "version": "v2.0.1", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" + "reference": "613b2d4998f85564d40497e05e89cb6d9bd1cbe8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/613b2d4998f85564d40497e05e89cb6d9bd1cbe8", + "reference": "613b2d4998f85564d40497e05e89cb6d9bd1cbe8", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "php": "^8.1" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "illuminate/support": "^10.0|^11.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -2405,7 +2071,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-14T18:34:49+00:00" + "time": "2024-12-16T15:26:28+00:00" }, { "name": "laravel/socialite", @@ -3161,16 +2827,178 @@ "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", "php": "^7.1 || >=8.0.0 <8.5.0" }, - "require-dev": { - "mockery/mockery": "^1.3.5", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", - "squizlabs/php_codesniffer": "^3.11" + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.0" + }, + "time": "2024-12-11T05:05:52+00:00" + }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, "autoload": { "psr-4": { - "League\\OAuth2\\Client\\": "src/" + "League\\Uri\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -3179,33 +3007,45 @@ ], "authors": [ { - "name": "Alex Bilbie", - "email": "hello@alexbilbie.com", - "homepage": "http://www.alexbilbie.com", - "role": "Developer" - }, - { - "name": "Woody Gilk", - "homepage": "https://github.com/shadowhand", - "role": "Contributor" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "OAuth 2.0 Client Library", + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "Authentication", - "SSO", - "authorization", - "identity", - "idp", - "oauth", - "oauth2", - "single sign on" + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" ], "support": { - "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.0" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" }, - "time": "2024-12-11T05:05:52+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" }, { "name": "masterminds/html5", @@ -3445,42 +3285,41 @@ }, { "name": "nesbot/carbon", - "version": "2.72.6", + "version": "3.8.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5" + "reference": "129700ed449b1f02d70272d2ac802357c8c30c58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1e9d50601e7035a4c61441a208cb5bed73e108c5", - "reference": "1e9d50601e7035a4c61441a208cb5bed73e108c5", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/129700ed449b1f02d70272d2ac802357c8c30c58", + "reference": "129700ed449b1f02d70272d2ac802357c8c30c58", "shasum": "" }, "require": { - "carbonphp/carbon-doctrine-types": "*", + "carbonphp/carbon-doctrine-types": "<100.0", "ext-json": "*", - "php": "^7.1.8 || ^8.0", + "php": "^8.1", "psr/clock": "^1.0", + "symfony/clock": "^6.3 || ^7.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" + "symfony/translation": "^4.4.18 || ^5.2.1|| ^6.0 || ^7.0" }, "provide": { "psr/clock-implementation": "1.0" }, "require-dev": { - "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", - "doctrine/orm": "^2.7 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.0", - "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "<6", - "phpmd/phpmd": "^2.9", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.99 || ^1.7.14", - "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", - "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", - "squizlabs/php_codesniffer": "^3.4" + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.57.2", + "kylekatarnls/multi-tester": "^2.5.3", + "ondrejmirtes/better-reflection": "^6.25.0.4", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.11.2", + "phpunit/phpunit": "^10.5.20", + "squizlabs/php_codesniffer": "^3.9.0" }, "bin": [ "bin/carbon" @@ -3548,7 +3387,7 @@ "type": "tidelift" } ], - "time": "2024-12-27T09:28:11+00:00" + "time": "2024-12-27T09:25:35+00:00" }, { "name": "nette/schema", @@ -3758,32 +3597,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v1.17.0", + "version": "v2.3.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301" + "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "^8.1", - "symfony/console": "^6.4.15" + "php": "^8.2", + "symfony/console": "^7.1.8" }, "require-dev": { - "illuminate/console": "^10.48.24", - "illuminate/support": "^10.48.24", + "illuminate/console": "^11.33.2", "laravel/pint": "^1.18.2", + "mockery/mockery": "^1.6.12", "pestphp/pest": "^2.36.0", - "pestphp/pest-plugin-mock": "2.0.0", "phpstan/phpstan": "^1.12.11", "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^6.4.15", + "symfony/var-dumper": "^7.1.8", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3792,6 +3630,9 @@ "providers": [ "Termwind\\Laravel\\TermwindServiceProvider" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -3823,7 +3664,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v1.17.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" }, "funding": [ { @@ -3839,7 +3680,7 @@ "type": "github" } ], - "time": "2024-11-21T10:36:35+00:00" + "time": "2024-11-21T10:39:51+00:00" }, { "name": "onelogin/php-saml", @@ -4320,55 +4161,6 @@ ], "time": "2024-11-21T20:00:02+00:00" }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, { "name": "psr/clock", "version": "1.0.0", @@ -5560,49 +5352,34 @@ "time": "2024-12-12T16:45:37+00:00" }, { - "name": "ssddanbrown/symfony-mailer", - "version": "6.4.x-dev", + "name": "symfony/clock", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/ssddanbrown/symfony-mailer.git", - "reference": "0497d6eb2734fe22b9550f88ae6526611c9df7ae" + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/symfony-mailer/zipball/0497d6eb2734fe22b9550f88ae6526611c9df7ae", - "reference": "0497d6eb2734fe22b9550f88ae6526611c9df7ae", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", "shasum": "" }, "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.1", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/messenger": "<6.2", - "symfony/mime": "<6.2", - "symfony/twig-bridge": "<6.2.1" - }, - "replace": { - "symfony/mailer": "^6.0" + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" }, - "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.2|^7.0", - "symfony/twig-bridge": "^6.2|^7.0" + "provide": { + "psr/clock-implementation": "1.0" }, - "default-branch": true, "type": "library", "autoload": { + "files": [ + "Resources/now.php" + ], "psr-4": { - "Symfony\\Component\\Mailer\\": "" + "Symfony\\Component\\Clock\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5614,68 +5391,82 @@ ], "authors": [ { - "name": "Dan Brown", - "homepage": "https://danb.me" - }, - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Helps sending emails", + "description": "Decouples applications from the system clock", "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], "support": { - "source": "https://github.com/ssddanbrown/symfony-mailer/tree/6.4" + "source": "https://github.com/symfony/clock/tree/v7.2.0" }, - "time": "2024-03-17T16:25:21+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/console", - "version": "v6.4.17", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "799445db3f15768ecc382ac5699e6da0520a0a04" + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/799445db3f15768ecc382ac5699e6da0520a0a04", - "reference": "799445db3f15768ecc382ac5699e6da0520a0a04", + "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^6.4|^7.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -5709,7 +5500,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.17" + "source": "https://github.com/symfony/console/tree/v7.2.1" }, "funding": [ { @@ -5725,7 +5516,7 @@ "type": "tidelift" } ], - "time": "2024-12-07T12:07:30+00:00" + "time": "2024-12-11T03:49:26+00:00" }, { "name": "symfony/css-selector", @@ -5861,22 +5652,22 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.17", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c" + "reference": "6150b89186573046167796fa5f3f76601d5145f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/37ad2380e8c1a8cf62a1200a5c10080b679b446c", - "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/6150b89186573046167796fa5f3f76601d5145f8", + "reference": "6150b89186573046167796fa5f3f76601d5145f8", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/var-dumper": "^6.4|^7.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", @@ -5885,7 +5676,7 @@ "require-dev": { "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^5.4|^6.0|^7.0" + "symfony/serializer": "^6.4|^7.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -5916,7 +5707,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.17" + "source": "https://github.com/symfony/error-handler/tree/v7.2.1" }, "funding": [ { @@ -5932,7 +5723,7 @@ "type": "tidelift" } ], - "time": "2024-12-06T13:30:51+00:00" + "time": "2024-12-07T08:50:44+00:00" }, { "name": "symfony/event-dispatcher", @@ -6092,23 +5883,23 @@ }, { "name": "symfony/finder", - "version": "v6.4.17", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" + "reference": "87a71856f2f56e4100373e92529eed3171695cfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", - "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "url": "https://api.github.com/repos/symfony/finder/zipball/87a71856f2f56e4100373e92529eed3171695cfb", + "reference": "87a71856f2f56e4100373e92529eed3171695cfb", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -6136,7 +5927,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.17" + "source": "https://github.com/symfony/finder/tree/v7.2.2" }, "funding": [ { @@ -6152,40 +5943,41 @@ "type": "tidelift" } ], - "time": "2024-12-29T13:51:37+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.16", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57" + "reference": "62d1a43796ca3fea3f83a8470dfe63a4af3bc588" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/431771b7a6f662f1575b3cfc8fd7617aa9864d57", - "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/62d1a43796ca3fea3f83a8470dfe63a4af3bc588", + "reference": "62d1a43796ca3fea3f83a8470dfe63a4af3bc588", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-mbstring": "~1.1", "symfony/polyfill-php83": "^1.27" }, "conflict": { + "doctrine/dbal": "<3.6", "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "symfony/cache": "^6.4.12|^7.1.5", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0" + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -6213,7 +6005,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.16" + "source": "https://github.com/symfony/http-foundation/tree/v7.2.2" }, "funding": [ { @@ -6229,77 +6021,77 @@ "type": "tidelift" } ], - "time": "2024-11-13T18:58:10+00:00" + "time": "2024-12-30T19:00:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.17", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710" + "reference": "3c432966bd8c7ec7429663105f5a02d7e75b4306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c5647393c5ce11833d13e4b70fff4b571d4ac710", - "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3c432966bd8c7ec7429663105f5a02d7e75b4306", + "reference": "3c432966bd8c7ec7429663105f5a02d7e75b4306", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.4", - "symfony/config": "<6.1", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<5.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/translation": "<5.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<5.4", + "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.3", - "twig/twig": "<2.13" + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.5|^6.0.5|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.4|^7.0.4", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.4|^7.0", - "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" }, "type": "library", "autoload": { @@ -6327,7 +6119,87 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.17" + "source": "https://github.com/symfony/http-kernel/tree/v7.2.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-31T14:59:40+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e4d358702fb66e4c8a2af08e90e7271a62de39cc", + "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.2.0" }, "funding": [ { @@ -6343,25 +6215,24 @@ "type": "tidelift" } ], - "time": "2024-12-31T14:49:31+00:00" + "time": "2024-11-25T15:21:05+00:00" }, { "name": "symfony/mime", - "version": "v6.4.17", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232" + "reference": "7f9617fcf15cb61be30f8b252695ed5e2bfac283" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", - "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", + "url": "https://api.github.com/repos/symfony/mime/zipball/7f9617fcf15cb61be30f8b252695ed5e2bfac283", + "reference": "7f9617fcf15cb61be30f8b252695ed5e2bfac283", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -6369,17 +6240,17 @@ "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<5.4", + "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.4|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", "symfony/serializer": "^6.4.3|^7.0.3" }, "type": "library", @@ -6412,7 +6283,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.17" + "source": "https://github.com/symfony/mime/tree/v7.2.1" }, "funding": [ { @@ -6428,7 +6299,7 @@ "type": "tidelift" } ], - "time": "2024-12-02T11:09:41+00:00" + "time": "2024-12-07T08:50:44+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7068,20 +6939,20 @@ }, { "name": "symfony/process", - "version": "v6.4.15", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "3cb242f059c14ae08591c5c4087d1fe443564392" + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/3cb242f059c14ae08591c5c4087d1fe443564392", - "reference": "3cb242f059c14ae08591c5c4087d1fe443564392", + "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -7109,7 +6980,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.15" + "source": "https://github.com/symfony/process/tree/v7.2.0" }, "funding": [ { @@ -7125,40 +6996,38 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2024-11-06T14:24:19+00:00" }, { "name": "symfony/routing", - "version": "v6.4.16", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220" + "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/91e02e606b4b705c2f4fb42f7e7708b7923a3220", - "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220", + "url": "https://api.github.com/repos/symfony/routing/zipball/e10a2450fa957af6c448b9b93c9010a4e4c0725e", + "reference": "e10a2450fa957af6c448b9b93c9010a4e4c0725e", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "doctrine/annotations": "<1.12", - "symfony/config": "<6.2", - "symfony/dependency-injection": "<5.4", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "psr/log": "^1|^2|^3", - "symfony/config": "^6.2|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7192,7 +7061,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.16" + "source": "https://github.com/symfony/routing/tree/v7.2.0" }, "funding": [ { @@ -7208,7 +7077,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T15:31:34+00:00" + "time": "2024-11-25T11:08:51+00:00" }, { "name": "symfony/service-contracts", @@ -7382,33 +7251,33 @@ }, { "name": "symfony/translation", - "version": "v6.4.13", + "version": "v7.2.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66" + "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/bee9bfabfa8b4045a66bf82520e492cddbaffa66", - "reference": "bee9bfabfa8b4045a66bf82520e492cddbaffa66", + "url": "https://api.github.com/repos/symfony/translation/zipball/e2674a30132b7cc4d74540d6c2573aa363f05923", + "reference": "e2674a30132b7cc4d74540d6c2573aa363f05923", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.4", "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -7416,17 +7285,17 @@ "require-dev": { "nikic/php-parser": "^4.18|^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/routing": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7457,7 +7326,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.13" + "source": "https://github.com/symfony/translation/tree/v7.2.2" }, "funding": [ { @@ -7473,7 +7342,7 @@ "type": "tidelift" } ], - "time": "2024-09-27T18:14:25+00:00" + "time": "2024-12-07T08:18:10+00:00" }, { "name": "symfony/translation-contracts", @@ -7555,24 +7424,24 @@ }, { "name": "symfony/uid", - "version": "v6.4.13", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007" + "reference": "2d294d0c48df244c71c105a169d0190bfb080426" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/18eb207f0436a993fffbdd811b5b8fa35fa5e007", - "reference": "18eb207f0436a993fffbdd811b5b8fa35fa5e007", + "url": "https://api.github.com/repos/symfony/uid/zipball/2d294d0c48df244c71c105a169d0190bfb080426", + "reference": "2d294d0c48df244c71c105a169d0190bfb080426", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -7609,7 +7478,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.13" + "source": "https://github.com/symfony/uid/tree/v7.2.0" }, "funding": [ { @@ -7625,38 +7494,36 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.15", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80" + "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80", - "reference": "38254d5a5ac2e61f2b52f9caf54e7aa3c9d36b80", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c6a22929407dec8765d6e2b6ff85b800b245879c", + "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { "ext-iconv": "*", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^6.3|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" }, "bin": [ "Resources/bin/var-dump-server" @@ -7694,7 +7561,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.15" + "source": "https://github.com/symfony/var-dumper/tree/v7.2.0" }, "funding": [ { @@ -7710,7 +7577,7 @@ "type": "tidelift" } ], - "time": "2024-11-08T15:28:48+00:00" + "time": "2024-11-08T15:48:14+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8484,40 +8351,38 @@ }, { "name": "nunomaduro/collision", - "version": "v7.11.0", + "version": "v8.5.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05" + "reference": "f5c101b929c958e849a633283adff296ed5f38f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/994ea93df5d4132f69d3f1bd74730509df6e8a05", - "reference": "994ea93df5d4132f69d3f1bd74730509df6e8a05", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f5c101b929c958e849a633283adff296ed5f38f5", + "reference": "f5c101b929c958e849a633283adff296ed5f38f5", "shasum": "" }, "require": { "filp/whoops": "^2.16.0", - "nunomaduro/termwind": "^1.15.1", - "php": "^8.1.0", - "symfony/console": "^6.4.12" + "nunomaduro/termwind": "^2.1.0", + "php": "^8.2.0", + "symfony/console": "^7.1.5" }, "conflict": { - "laravel/framework": ">=11.0.0" + "laravel/framework": "<11.0.0 || >=12.0.0", + "phpunit/phpunit": "<10.5.1 || >=12.0.0" }, "require-dev": { - "brianium/paratest": "^7.3.1", - "laravel/framework": "^10.48.22", + "larastan/larastan": "^2.9.8", + "laravel/framework": "^11.28.0", "laravel/pint": "^1.18.1", "laravel/sail": "^1.36.0", - "laravel/sanctum": "^3.3.3", + "laravel/sanctum": "^4.0.3", "laravel/tinker": "^2.10.0", - "nunomaduro/larastan": "^2.9.8", - "orchestra/testbench-core": "^8.28.3", - "pestphp/pest": "^2.35.1", - "phpunit/phpunit": "^10.5.36", - "sebastian/environment": "^6.1.0", - "spatie/laravel-ignition": "^2.8.0" + "orchestra/testbench-core": "^9.5.3", + "pestphp/pest": "^2.36.0 || ^3.4.0", + "sebastian/environment": "^6.1.0 || ^7.2.0" }, "type": "library", "extra": { @@ -8525,6 +8390,9 @@ "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" } }, "autoload": { @@ -8576,7 +8444,7 @@ "type": "patreon" } ], - "time": "2024-10-15T15:12:40+00:00" + "time": "2024-10-15T16:06:32+00:00" }, { "name": "phar-io/manifest", @@ -10423,9 +10291,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "ssddanbrown/symfony-mailer": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/database/migrations/2022_04_25_140741_update_polymorphic_types.php b/database/migrations/2022_04_25_140741_update_polymorphic_types.php index 00e1e0688fc..f201d41ccc6 100644 --- a/database/migrations/2022_04_25_140741_update_polymorphic_types.php +++ b/database/migrations/2022_04_25_140741_update_polymorphic_types.php @@ -8,7 +8,7 @@ /** * Mapping of old polymorphic types to new simpler values. */ - protected $changeMap = [ + protected array $changeMap = [ 'BookStack\\Bookshelf' => 'bookshelf', 'BookStack\\Book' => 'book', 'BookStack\\Chapter' => 'chapter', @@ -18,7 +18,7 @@ /** * Mapping of tables and columns that contain polymorphic types. */ - protected $columnsByTable = [ + protected array $columnsByTable = [ 'activities' => 'entity_type', 'comments' => 'entity_type', 'deletions' => 'deletable_type', diff --git a/public/index.php b/public/index.php index fdf6e720f53..2b5c57b8f40 100644 --- a/public/index.php +++ b/public/index.php @@ -5,45 +5,16 @@ define('LARAVEL_START', microtime(true)); -/* -|-------------------------------------------------------------------------- -| Check If The Application Is Under Maintenance -|-------------------------------------------------------------------------- -| -| If the application is in maintenance / demo mode via the "down" command -| we will load this file so that any pre-rendered content can be shown -| instead of starting the framework, which could cause an exception. -| -*/ - +// Determine if the application is in maintenance mode... if (file_exists(__DIR__ . '/../storage/framework/maintenance.php')) { require __DIR__ . '/../storage/framework/maintenance.php'; } -/* -|-------------------------------------------------------------------------- -| Register The Auto Loader -|-------------------------------------------------------------------------- -| -| Composer provides a convenient, automatically generated class loader for -| this application. We just need to utilize it! We'll simply require it -| into the script here so we don't need to manually load our classes. -| -*/ - +// Register the Composer autoloader... require __DIR__ . '/../vendor/autoload.php'; -/* -|-------------------------------------------------------------------------- -| Run The Application -|-------------------------------------------------------------------------- -| -| Once we have the application, we can handle the incoming request using -| the application's HTTP kernel. Then, we will send the response back -| to this client's browser, allowing them to enjoy our application. -| -*/ +// Run the application $app = require_once __DIR__ . '/../bootstrap/app.php'; $app->alias('request', Request::class); From 5bf75786c6326f4c2ef5df577a1062aeacce7bff Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 13:22:49 +0000 Subject: [PATCH 284/800] Framework: Fixed Laravel 11 upgrade test issues, updated phpstan - Fixed failing tests due to Laravel 11 changes - Updated phpstan to 3.x branch - Removed some seemingly redundant comment code, which was triggering phpstan. --- app/Activity/Models/Comment.php | 17 ------ composer.json | 2 +- composer.lock | 60 +++++++++---------- .../2015_08_31_175240_add_search_indexes.php | 12 +--- .../2015_12_05_145049_fulltext_weighting.php | 12 +--- ...03_19_091553_create_search_index_table.php | 12 +--- phpstan.neon.dist | 4 +- tests/Entity/PageTest.php | 2 +- 8 files changed, 42 insertions(+), 79 deletions(-) diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 7d1c54646c1..d0385d3962f 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -26,7 +26,6 @@ class Comment extends Model implements Loggable use HasCreatorAndUpdater; protected $fillable = ['parent_id']; - protected $appends = ['created', 'updated']; /** * Get the entity that this comment belongs to. @@ -54,22 +53,6 @@ public function isUpdated(): bool return $this->updated_at->timestamp > $this->created_at->timestamp; } - /** - * Get created date as a relative diff. - */ - public function getCreatedAttribute(): string - { - return $this->created_at->diffForHumans(); - } - - /** - * Get updated date as a relative diff. - */ - public function getUpdatedAttribute(): string - { - return $this->updated_at->diffForHumans(); - } - public function logDescriptor(): string { return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})"; diff --git a/composer.json b/composer.json index 58b89fcce60..2c3c047322f 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "itsgoingd/clockwork": "^5.1", "mockery/mockery": "^1.5", "nunomaduro/collision": "^8.1", - "larastan/larastan": "^2.7", + "larastan/larastan": "^v3.0", "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^3.7", "ssddanbrown/asserthtml": "^3.0" diff --git a/composer.lock b/composer.lock index 1d512f85148..c7c4018a127 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "518176ac5bb608061e0f74b06fdae582", + "content-hash": "36bd84db9a3fd3e801fbd8375b91e0e5", "packages": [ { "name": "aws/aws-crt-php", @@ -8115,40 +8115,40 @@ }, { "name": "larastan/larastan", - "version": "v2.9.12", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908" + "reference": "b2e24e1605cff1d1097ccb6fb8af3bbd1dfe1c6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/19012b39fbe4dede43dbe0c126d9681827a5e908", - "reference": "19012b39fbe4dede43dbe0c126d9681827a5e908", + "url": "https://api.github.com/repos/larastan/larastan/zipball/b2e24e1605cff1d1097ccb6fb8af3bbd1dfe1c6f", + "reference": "b2e24e1605cff1d1097ccb6fb8af3bbd1dfe1c6f", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/console": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/container": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/contracts": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/database": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/http": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/pipeline": "^9.52.16 || ^10.28.0 || ^11.16", - "illuminate/support": "^9.52.16 || ^10.28.0 || ^11.16", - "php": "^8.0.2", + "illuminate/console": "^11.15.0", + "illuminate/container": "^11.15.0", + "illuminate/contracts": "^11.15.0", + "illuminate/database": "^11.15.0", + "illuminate/http": "^11.15.0", + "illuminate/pipeline": "^11.15.0", + "illuminate/support": "^11.15.0", + "php": "^8.2", "phpmyadmin/sql-parser": "^5.9.0", - "phpstan/phpstan": "^1.12.11" + "phpstan/phpstan": "^2.0.2" }, "require-dev": { "doctrine/coding-standard": "^12.0", - "laravel/framework": "^9.52.16 || ^10.28.0 || ^11.16", - "mockery/mockery": "^1.5.1", - "nikic/php-parser": "^4.19.1", - "orchestra/canvas": "^7.11.1 || ^8.11.0 || ^9.0.2", - "orchestra/testbench-core": "^7.33.0 || ^8.13.0 || ^9.0.9", - "phpstan/phpstan-deprecation-rules": "^1.2", - "phpunit/phpunit": "^9.6.13 || ^10.5.16" + "laravel/framework": "^11.15.0", + "mockery/mockery": "^1.6", + "nikic/php-parser": "^5.3", + "orchestra/canvas": "^v9.1.3", + "orchestra/testbench-core": "^9.5.2", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpunit/phpunit": "^10.5.16" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" @@ -8183,7 +8183,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel", "keywords": [ "PHPStan", "code analyse", @@ -8196,7 +8196,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v2.9.12" + "source": "https://github.com/larastan/larastan/tree/v3.0.2" }, "funding": [ { @@ -8204,7 +8204,7 @@ "type": "github" } ], - "time": "2024-11-26T23:09:02+00:00" + "time": "2024-11-26T23:15:21+00:00" }, { "name": "mockery/mockery", @@ -8653,20 +8653,20 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.15", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" + "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", - "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", + "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -8707,7 +8707,7 @@ "type": "github" } ], - "time": "2025-01-05T16:40:22+00:00" + "time": "2025-01-05T16:43:48+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/database/migrations/2015_08_31_175240_add_search_indexes.php b/database/migrations/2015_08_31_175240_add_search_indexes.php index 4d58d940959..13141698a9f 100644 --- a/database/migrations/2015_08_31_175240_add_search_indexes.php +++ b/database/migrations/2015_08_31_175240_add_search_indexes.php @@ -26,25 +26,19 @@ public function up() */ public function down(): void { - $sm = Schema::getConnection()->getDoctrineSchemaManager(); - $prefix = DB::getTablePrefix(); - $pages = $sm->introspectTable($prefix . 'pages'); - $books = $sm->introspectTable($prefix . 'books'); - $chapters = $sm->introspectTable($prefix . 'chapters'); - - if ($pages->hasIndex('search')) { + if (Schema::hasIndex('pages', 'search')) { Schema::table('pages', function (Blueprint $table) { $table->dropIndex('search'); }); } - if ($books->hasIndex('search')) { + if (Schema::hasIndex('books', 'search')) { Schema::table('books', function (Blueprint $table) { $table->dropIndex('search'); }); } - if ($chapters->hasIndex('search')) { + if (Schema::hasIndex('chapters', 'search')) { Schema::table('chapters', function (Blueprint $table) { $table->dropIndex('search'); }); diff --git a/database/migrations/2015_12_05_145049_fulltext_weighting.php b/database/migrations/2015_12_05_145049_fulltext_weighting.php index b20c04520cb..3d0d21e7081 100644 --- a/database/migrations/2015_12_05_145049_fulltext_weighting.php +++ b/database/migrations/2015_12_05_145049_fulltext_weighting.php @@ -26,25 +26,19 @@ public function up() */ public function down(): void { - $sm = Schema::getConnection()->getDoctrineSchemaManager(); - $prefix = DB::getTablePrefix(); - $pages = $sm->introspectTable($prefix . 'pages'); - $books = $sm->introspectTable($prefix . 'books'); - $chapters = $sm->introspectTable($prefix . 'chapters'); - - if ($pages->hasIndex('name_search')) { + if (Schema::hasIndex('pages', 'name_search')) { Schema::table('pages', function (Blueprint $table) { $table->dropIndex('name_search'); }); } - if ($books->hasIndex('name_search')) { + if (Schema::hasIndex('books', 'name_search')) { Schema::table('books', function (Blueprint $table) { $table->dropIndex('name_search'); }); } - if ($chapters->hasIndex('name_search')) { + if (Schema::hasIndex('chapters', 'name_search')) { Schema::table('chapters', function (Blueprint $table) { $table->dropIndex('name_search'); }); diff --git a/database/migrations/2017_03_19_091553_create_search_index_table.php b/database/migrations/2017_03_19_091553_create_search_index_table.php index 56281741e3c..7f96aca6b33 100644 --- a/database/migrations/2017_03_19_091553_create_search_index_table.php +++ b/database/migrations/2017_03_19_091553_create_search_index_table.php @@ -25,27 +25,21 @@ public function up(): void $table->index('score'); }); - $sm = Schema::getConnection()->getDoctrineSchemaManager(); - $prefix = DB::getTablePrefix(); - $pages = $sm->introspectTable($prefix . 'pages'); - $books = $sm->introspectTable($prefix . 'books'); - $chapters = $sm->introspectTable($prefix . 'chapters'); - - if ($pages->hasIndex('search')) { + if (Schema::hasIndex('pages', 'search')) { Schema::table('pages', function (Blueprint $table) { $table->dropIndex('search'); $table->dropIndex('name_search'); }); } - if ($books->hasIndex('search')) { + if (Schema::hasIndex('books', 'search')) { Schema::table('books', function (Blueprint $table) { $table->dropIndex('search'); $table->dropIndex('name_search'); }); } - if ($chapters->hasIndex('search')) { + if (Schema::hasIndex('chapters', 'search')) { Schema::table('chapters', function (Blueprint $table) { $table->dropIndex('search'); $table->dropIndex('name_search'); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index aa2ad3d9e0f..0f20213836c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,4 @@ parameters: excludePaths: - ./Config/**/*.php - - ./dev/**/*.php - - checkMissingIterableValueType: false \ No newline at end of file + - ./dev/**/*.php \ No newline at end of file diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index b96d455eb25..deeead09911 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -300,7 +300,7 @@ public function test_recently_updated_pages_view_shows_updated_by_details() ]); $resp = $this->asAdmin()->get('/pages/recently-updated'); - $this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 1 second ago by ' . $user->name); + $this->withHtml($resp)->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 0 seconds ago by ' . $user->name); } public function test_recently_updated_pages_view_shows_parent_chain() From ad8bc5fe21ddccc243f6a4ef4e87bbd28668c423 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 13:50:01 +0000 Subject: [PATCH 285/800] Framework: Updated phpunit to 11, updated migration test php versions --- .github/workflows/test-migrations.yml | 2 +- .github/workflows/test-php.yml | 2 +- composer.json | 4 +- composer.lock | 552 ++++++++++++++------------ 4 files changed, 312 insertions(+), 248 deletions(-) diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 2d6d280b2ee..23e58a772c2 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 277af907025..64132f673a4 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -13,7 +13,7 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: php: ['8.2', '8.3', '8.4'] diff --git a/composer.json b/composer.json index 2c3c047322f..9ffded1c587 100644 --- a/composer.json +++ b/composer.json @@ -46,9 +46,9 @@ "mockery/mockery": "^1.5", "nunomaduro/collision": "^8.1", "larastan/larastan": "^v3.0", - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^11.5", "squizlabs/php_codesniffer": "^3.7", - "ssddanbrown/asserthtml": "^3.0" + "ssddanbrown/asserthtml": "^3.1" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index c7c4018a127..5d2b38215a7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "36bd84db9a3fd3e801fbd8375b91e0e5", + "content-hash": "f8d7d0be63e355bf8d39ebf2ced165b7", "packages": [ { "name": "aws/aws-crt-php", @@ -5520,20 +5520,20 @@ }, { "name": "symfony/css-selector", - "version": "v6.4.13", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e" + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/cb23e97813c5837a041b73a6d63a9ddff0778f5e", - "reference": "cb23e97813c5837a041b73a6d63a9ddff0778f5e", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -5565,7 +5565,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.13" + "source": "https://github.com/symfony/css-selector/tree/v7.2.0" }, "funding": [ { @@ -5581,7 +5581,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:18:03+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8711,35 +8711,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "10.1.16", + "version": "11.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", - "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/418c59fd080954f8c4aa5631d9502ecda2387118", + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=8.1", - "phpunit/php-file-iterator": "^4.1.0", - "phpunit/php-text-template": "^3.0.1", - "sebastian/code-unit-reverse-lookup": "^3.0.0", - "sebastian/complexity": "^3.2.0", - "sebastian/environment": "^6.1.0", - "sebastian/lines-of-code": "^2.0.2", - "sebastian/version": "^4.0.1", + "nikic/php-parser": "^5.3.1", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^10.1" + "phpunit/phpunit": "^11.5.0" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8748,7 +8748,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1.x-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -8777,7 +8777,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.8" }, "funding": [ { @@ -8785,32 +8785,32 @@ "type": "github" } ], - "time": "2024-08-22T04:31:57+00:00" + "time": "2024-12-11T12:34:27+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "4.1.0", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8838,7 +8838,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" }, "funding": [ { @@ -8846,28 +8846,28 @@ "type": "github" } ], - "time": "2023-08-31T06:24:48+00:00" + "time": "2024-08-27T05:02:59+00:00" }, { "name": "phpunit/php-invoker", - "version": "4.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -8875,7 +8875,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -8901,7 +8901,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { @@ -8909,32 +8910,32 @@ "type": "github" } ], - "time": "2023-02-03T06:56:09+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "3.0.1", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -8961,7 +8962,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { @@ -8969,32 +8970,32 @@ "type": "github" } ], - "time": "2023-08-31T14:07:24+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "6.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9020,7 +9021,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { @@ -9028,20 +9030,20 @@ "type": "github" } ], - "time": "2023-02-03T06:57:52+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "10.5.40", + "version": "11.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c" + "reference": "153d0531b9f7e883c5053160cad6dd5ac28140b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6ddda95af52f69c1e0c7b4f977cccb58048798c", - "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/153d0531b9f7e883c5053160cad6dd5ac28140b3", + "reference": "153d0531b9f7e883c5053160cad6dd5ac28140b3", "shasum": "" }, "require": { @@ -9054,23 +9056,23 @@ "myclabs/deep-copy": "^1.12.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.1", - "phpunit/php-code-coverage": "^10.1.16", - "phpunit/php-file-iterator": "^4.1.0", - "phpunit/php-invoker": "^4.0.0", - "phpunit/php-text-template": "^3.0.1", - "phpunit/php-timer": "^6.0.0", - "sebastian/cli-parser": "^2.0.1", - "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", - "sebastian/diff": "^5.1.1", - "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", - "sebastian/global-state": "^6.0.2", - "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.0", - "sebastian/type": "^4.0.0", - "sebastian/version": "^4.0.1" + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.8", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.2", + "sebastian/comparator": "^6.2.1", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.0", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.0", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" @@ -9081,7 +9083,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.5-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -9113,7 +9115,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.40" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.2" }, "funding": [ { @@ -9129,32 +9131,32 @@ "type": "tidelift" } ], - "time": "2024-12-21T05:49:06+00:00" + "time": "2024-12-21T05:51:08+00:00" }, { "name": "sebastian/cli-parser", - "version": "2.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", - "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9178,7 +9180,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -9186,32 +9188,32 @@ "type": "github" } ], - "time": "2024-03-02T07:12:49+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", - "version": "2.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9234,7 +9236,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.2" }, "funding": [ { @@ -9242,32 +9245,32 @@ "type": "github" } ], - "time": "2023-02-03T06:58:43+00:00" + "time": "2024-12-12T09:59:06+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "3.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9289,7 +9292,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -9297,36 +9301,39 @@ "type": "github" } ], - "time": "2023-02-03T06:59:15+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/d4e47a769525c4dd38cea90e5dcd435ddbbc7115", + "reference": "d4e47a769525c4dd38cea90e5dcd435ddbbc7115", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/diff": "^5.0", - "sebastian/exporter": "^5.0" + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.2-dev" } }, "autoload": { @@ -9366,7 +9373,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.0" }, "funding": [ { @@ -9374,33 +9381,33 @@ "type": "github" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2025-01-06T10:28:19+00:00" }, { "name": "sebastian/complexity", - "version": "3.2.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "68ff824baeae169ec9f2137158ee529584553799" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", - "reference": "68ff824baeae169ec9f2137158ee529584553799", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.2-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9424,7 +9431,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -9432,33 +9439,33 @@ "type": "github" } ], - "time": "2023-12-21T08:37:17+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "5.1.1", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "symfony/process": "^6.4" + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9491,7 +9498,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -9499,27 +9506,27 @@ "type": "github" } ], - "time": "2024-03-02T07:15:17+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "6.1.0", + "version": "7.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", - "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-posix": "*" @@ -9527,7 +9534,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -9555,7 +9562,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" }, "funding": [ { @@ -9563,34 +9570,34 @@ "type": "github" } ], - "time": "2024-03-23T08:47:14+00:00" + "time": "2024-07-03T04:54:44+00:00" }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -9633,7 +9640,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" }, "funding": [ { @@ -9641,35 +9648,35 @@ "type": "github" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2024-12-05T09:17:50+00:00" }, { "name": "sebastian/global-state", - "version": "6.0.2", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", - "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9695,7 +9702,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { @@ -9703,33 +9710,33 @@ "type": "github" } ], - "time": "2024-03-02T07:19:19+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", - "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9753,7 +9760,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -9761,34 +9768,34 @@ "type": "github" } ], - "time": "2023-12-21T08:38:20+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "5.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9810,7 +9817,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -9818,32 +9826,32 @@ "type": "github" } ], - "time": "2023-02-03T07:08:32+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "3.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9865,7 +9873,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -9873,32 +9882,32 @@ "type": "github" } ], - "time": "2023-02-03T07:06:18+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9928,7 +9937,8 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" }, "funding": [ { @@ -9936,32 +9946,32 @@ "type": "github" } ], - "time": "2023-02-03T07:05:40+00:00" + "time": "2024-07-03T05:10:34+00:00" }, { "name": "sebastian/type", - "version": "4.0.0", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9984,7 +9994,8 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" }, "funding": [ { @@ -9992,29 +10003,29 @@ "type": "github" } ], - "time": "2023-02-03T07:10:45+00:00" + "time": "2024-09-17T13:12:04+00:00" }, { "name": "sebastian/version", - "version": "4.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10037,7 +10048,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -10045,7 +10057,7 @@ "type": "github" } ], - "time": "2023-02-07T11:34:05+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -10129,11 +10141,11 @@ }, { "name": "ssddanbrown/asserthtml", - "version": "v3.0.1", + "version": "v3.1.0", "source": { "type": "git", "url": "https://codeberg.org/danb/asserthtml", - "reference": "31b3035b5533ae80ab16c80d3774dd840dbfd079" + "reference": "cf8206171d667d43e1bdde17d67191f30e95c8a0" }, "dist": { "type": "zip", @@ -10143,12 +10155,12 @@ "ext-dom": "*", "ext-json": "*", "php": ">=8.1", - "phpunit/phpunit": "^10.0", - "symfony/css-selector": "^6.0", - "symfony/dom-crawler": "^6.0" + "phpunit/phpunit": "^10.0|^11.0", + "symfony/css-selector": "^6.0|^7.0", + "symfony/dom-crawler": "^6.0|^7.0" }, "require-dev": { - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.0" }, "type": "library", "autoload": { @@ -10169,30 +10181,82 @@ ], "description": "HTML Content Assertions for PHPUnit", "homepage": "https://codeberg.org/danb/asserthtml", - "time": "2024-12-28T15:15:02+00:00" + "time": "2025-01-11T13:35:55+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/dom-crawler", - "version": "v6.4.16", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d" + "reference": "b176e1f1f550ef44c94eb971bf92488de08f7c6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4304e6ad5c894a9c72831ad459f627bfd35d766d", - "reference": "4304e6ad5c894a9c72831ad459f627bfd35d766d", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b176e1f1f550ef44c94eb971bf92488de08f7c6b", + "reference": "b176e1f1f550ef44c94eb971bf92488de08f7c6b", "shasum": "" }, "require": { "masterminds/html5": "^2.6", - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" + "symfony/css-selector": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -10220,7 +10284,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.16" + "source": "https://github.com/symfony/dom-crawler/tree/v7.2.0" }, "funding": [ { @@ -10236,7 +10300,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T15:06:22+00:00" + "time": "2024-11-13T16:15:23+00:00" }, { "name": "theseer/tokenizer", From dbda82ef9275fcb791a27cd329d8383c6a676d17 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Jan 2025 15:05:10 +0000 Subject: [PATCH 286/800] Framework: Re-add updated patched symfony-mailer https://github.com/ssddanbrown/symfony-mailer/commit/e9de8dccd76a63fc23475016e6574da6f5f12a2 --- composer.json | 3 +- composer.lock | 159 ++++++++++++++++++++++++-------------------------- 2 files changed, 79 insertions(+), 83 deletions(-) diff --git a/composer.json b/composer.json index 9ffded1c587..6fcf26aaadf 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/okta": "^4.2", "socialiteproviders/twitch": "^5.3", - "ssddanbrown/htmldiff": "^1.0.2" + "ssddanbrown/htmldiff": "^1.0.2", + "ssddanbrown/symfony-mailer": "7.2.x-dev" }, "require-dev": { "fakerphp/faker": "^1.21", diff --git a/composer.lock b/composer.lock index 5d2b38215a7..1f3808cdcb8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f8d7d0be63e355bf8d39ebf2ced165b7", + "content-hash": "8e9b6e95d9222289ce51646e27c1f404", "packages": [ { "name": "aws/aws-crt-php", @@ -5351,6 +5351,79 @@ ], "time": "2024-12-12T16:45:37+00:00" }, + { + "name": "ssddanbrown/symfony-mailer", + "version": "7.2.x-dev", + "source": { + "type": "git", + "url": "https://github.com/ssddanbrown/symfony-mailer.git", + "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ssddanbrown/symfony-mailer/zipball/e9de8dccd76a63fc23475016e6574da6f5f12a2d", + "reference": "e9de8dccd76a63fc23475016e6574da6f5f12a2d", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "replace": { + "symfony/mailer": "^7.0" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dan Brown", + "homepage": "https://danb.me" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/ssddanbrown/symfony-mailer/tree/7.2" + }, + "time": "2025-01-11T14:57:07+00:00" + }, { "name": "symfony/clock", "version": "v7.2.0", @@ -6137,86 +6210,6 @@ ], "time": "2024-12-31T14:59:40+00:00" }, - { - "name": "symfony/mailer", - "version": "v7.2.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/mailer.git", - "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e4d358702fb66e4c8a2af08e90e7271a62de39cc", - "reference": "e4d358702fb66e4c8a2af08e90e7271a62de39cc", - "shasum": "" - }, - "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/mime": "^7.2", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Mailer\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps sending emails", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/mailer/tree/v7.2.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-11-25T15:21:05+00:00" - }, { "name": "symfony/mime", "version": "v7.2.1", @@ -10355,7 +10348,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "ssddanbrown/symfony-mailer": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { From ee88832f1a468af9f930807071da3a206eeb91c8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 13:26:04 +0000 Subject: [PATCH 287/800] Updated translations with latest Crowdin changes (#5399) --- lang/bn/auth.php | 18 +++++++++--------- lang/fr/activities.php | 12 ++++++------ lang/fr/editor.php | 4 ++-- lang/fr/entities.php | 42 ++++++++++++++++++++--------------------- lang/fr/errors.php | 20 ++++++++++---------- lang/fr/settings.php | 2 +- lang/fr/validation.php | 8 ++++---- lang/ko/activities.php | 6 +++--- lang/ko/common.php | 14 +++++++------- lang/ko/components.php | 2 +- lang/ko/entities.php | 26 ++++++++++++------------- lang/ko/errors.php | 6 +++--- lang/ko/settings.php | 36 +++++++++++++++++------------------ lang/ko/validation.php | 4 ++-- lang/pt_BR/entities.php | 38 ++++++++++++++++++------------------- 15 files changed, 119 insertions(+), 119 deletions(-) diff --git a/lang/bn/auth.php b/lang/bn/auth.php index aca585962cc..06ed376a71a 100644 --- a/lang/bn/auth.php +++ b/lang/bn/auth.php @@ -14,16 +14,16 @@ 'log_in' => 'লগ ইন করুন', 'log_in_with' => ':socialDriver দ্বারা লগইন করুন', 'sign_up_with' => ':socialDriver দ্বারা নিবন্ধিত হোন', - 'logout' => 'Logout', + 'logout' => 'লগআউট', - 'name' => 'Name', - 'username' => 'Username', - 'email' => 'Email', - 'password' => 'Password', - 'password_confirm' => 'Confirm Password', - 'password_hint' => 'Must be at least 8 characters', - 'forgot_password' => 'Forgot Password?', - 'remember_me' => 'Remember Me', + 'name' => 'নাম', + 'username' => 'ব্যবহারকারী', + 'email' => 'ই-মেইল', + 'password' => 'পাসওয়ার্ড', + 'password_confirm' => 'পাসওয়ার্ডের পুনরাবৃত্তি', + 'password_hint' => 'ন্যূনতম ৮ অক্ষরের হতে হবে', + 'forgot_password' => 'পাসওয়ার্ড ভুলে গেছেন?', + 'remember_me' => 'লগইন স্থায়িত্ব ধরে রাখুন', 'ldap_email_hint' => 'Please enter an email to use for this account.', 'create_account' => 'Create Account', 'already_have_account' => 'Already have an account?', diff --git a/lang/fr/activities.php b/lang/fr/activities.php index 871b886480c..34ec83ec34c 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -85,12 +85,12 @@ 'webhook_delete_notification' => 'Webhook supprimé avec succès', // Imports - 'import_create' => 'created import', - 'import_create_notification' => 'Import successfully uploaded', - 'import_run' => 'updated import', - 'import_run_notification' => 'Content successfully imported', - 'import_delete' => 'deleted import', - 'import_delete_notification' => 'Import successfully deleted', + 'import_create' => 'import créé', + 'import_create_notification' => 'Importation envoyée avec succès', + 'import_run' => 'importation mise à jour', + 'import_run_notification' => 'Contenu importé avec succès', + 'import_delete' => 'import supprimé', + 'import_delete_notification' => 'Importation supprimée avec succès', // Users 'user_create' => 'utilisateur créé', diff --git a/lang/fr/editor.php b/lang/fr/editor.php index 11edae10c9b..bdef1e8b1f5 100644 --- a/lang/fr/editor.php +++ b/lang/fr/editor.php @@ -163,8 +163,8 @@ 'about' => 'À propos de l\'éditeur', 'about_title' => 'À propos de l\'éditeur WYSIWYG', 'editor_license' => 'Licence d\'éditeur et droit d\'auteur', - 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', - 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_lexical_license' => 'Cet éditeur est construit comme un fork de :lexicalLink qui est distribué sous licence MIT.', + 'editor_lexical_license_link' => 'Vous trouverez ici tous les détails de la licence.', 'editor_tiny_license' => 'Cet éditeur est construit en utilisant :tinyLink qui est fourni sous la licence MIT.', 'editor_tiny_license_link' => 'Vous trouverez ici les détails sur les droits d\'auteur et les licences de TinyMCE.', 'save_continue' => 'Enregistrer et continuer', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index 6581587d4f7..2382a1d6ce2 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -39,30 +39,30 @@ 'export_pdf' => 'Fichier PDF', 'export_text' => 'Document texte', 'export_md' => 'Fichiers Markdown', - 'export_zip' => 'Portable ZIP', + 'export_zip' => 'Export ZIP', 'default_template' => 'Modèle de page par défaut', 'default_template_explain' => 'Sélectionnez un modèle de page qui sera utilisé comme contenu par défaut pour les nouvelles pages créées dans cet élément. Gardez à l\'esprit que le modèle ne sera utilisé que si le créateur de la page a accès au modèle sélectionné.', 'default_template_select' => 'Sélectionnez un modèle de page', - 'import' => 'Import', - 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', - 'import_zip_select' => 'Select ZIP file to upload', - 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', - 'import_pending' => 'Pending Imports', - 'import_pending_none' => 'No imports have been started.', - 'import_continue' => 'Continue Import', - 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', - 'import_details' => 'Import Details', - 'import_run' => 'Run Import', - 'import_size' => ':size Import ZIP Size', - 'import_uploaded_at' => 'Uploaded :relativeTime', - 'import_uploaded_by' => 'Uploaded by', - 'import_location' => 'Import Location', - 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', - 'import_delete_confirm' => 'Are you sure you want to delete this import?', - 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', - 'import_errors' => 'Import Errors', - 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + 'import' => 'Importation', + 'import_validate' => 'Valider l\'import', + 'import_desc' => 'Importez des livres, des chapitres et des pages à l\'aide d\'un export zip à partir de la même instance ou d\'une instance différente. Sélectionnez un fichier ZIP pour continuer. Une fois le fichier téléchargé et validé, vous pourrez configurer et confirmer l\'importation dans la vue suivante.', + 'import_zip_select' => 'Sélectionnez le fichier ZIP à télécharger', + 'import_zip_validation_errors' => 'Des erreurs ont été détectées lors de la validation du fichier ZIP fourni:', + 'import_pending' => 'Importations en attente', + 'import_pending_none' => 'Aucune importation n\'a été commencée.', + 'import_continue' => 'Continuer l\'importation', + 'import_continue_desc' => 'Examinez le contenu à importer à partir du fichier ZIP téléchargé. Lorsque vous êtes prêt, lancez l\'importation pour ajouter son contenu à ce système. Le fichier d\'importation ZIP téléchargé sera automatiquement supprimé si l\'importation est réussie.', + 'import_details' => 'Détails de l\'importation', + 'import_run' => 'Exécuter Importation', + 'import_size' => ':size taille du ZIP d\'import', + 'import_uploaded_at' => ':relativeTime téléchargé', + 'import_uploaded_by' => 'Téléchargé par', + 'import_location' => 'Emplacement de l\'importation', + 'import_location_desc' => 'Sélectionnez un emplacement cible pour votre contenu importé. Vous aurez besoin des autorisations appropriées pour créer dans l\'emplacement que vous choisissez.', + 'import_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer cette importation ?', + 'import_delete_desc' => 'Ceci supprimera le fichier ZIP importé et ne pourra pas être annulé.', + 'import_errors' => 'Erreurs d\'importation', + 'import_errors_desc' => 'Les erreurs suivantes se sont produites lors de la tentative d\'importation :', // Permissions and restrictions 'permissions' => 'Autorisations', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index d89926dac72..94d21e1dd14 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -106,16 +106,16 @@ 'back_soon' => 'Nous serons bientôt de retour.', // Import - 'import_zip_cant_read' => 'Could not read ZIP file.', - 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', - 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', - 'import_validation_failed' => 'Import ZIP failed to validate with errors:', - 'import_zip_failed_notification' => 'Failed to import ZIP file.', - 'import_perms_books' => 'You are lacking the required permissions to create books.', - 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', - 'import_perms_pages' => 'You are lacking the required permissions to create pages.', - 'import_perms_images' => 'You are lacking the required permissions to create images.', - 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', + 'import_zip_cant_read' => 'Impossible de lire le fichier ZIP.', + 'import_zip_cant_decode_data' => 'Impossible de trouver et de décoder le contenu ZIP data.json.', + 'import_zip_no_data' => 'Les données du fichier ZIP n\'ont pas de livre, de chapitre ou de page attendus.', + 'import_validation_failed' => 'L\'importation du ZIP n\'a pas été validée avec les erreurs :', + 'import_zip_failed_notification' => 'Impossible d\'importer le fichier ZIP.', + 'import_perms_books' => 'Vous n\'avez pas les permissions requises pour créer des livres.', + 'import_perms_chapters' => 'Vous n\'avez pas les permissions requises pour créer des chapitres.', + 'import_perms_pages' => 'Vous n\'avez pas les permissions requises pour créer des pages.', + 'import_perms_images' => 'Vous n\'avez pas les permissions requises pour créer des images.', + 'import_perms_attachments' => 'Vous n\'avez pas les permissions requises pour créer des pièces jointes.', // API errors 'api_no_authorization_found' => 'Aucun jeton d\'autorisation trouvé pour la demande', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index d113e89ab7c..58bce55e06c 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -162,7 +162,7 @@ 'role_access_api' => 'Accès à l\'API du système', 'role_manage_settings' => 'Gérer les préférences de l\'application', 'role_export_content' => 'Exporter le contenu', - 'role_import_content' => 'Import content', + 'role_import_content' => 'Importer le contenu', 'role_editor_change' => 'Changer l\'éditeur de page', 'role_notifications' => 'Recevoir et gérer les notifications', 'role_asset' => 'Permissions des ressources', diff --git a/lang/fr/validation.php b/lang/fr/validation.php index ed16776e72f..7db0493a165 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -105,10 +105,10 @@ 'url' => ':attribute a un format invalide.', 'uploaded' => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', - 'zip_model_expected' => 'Data object expected but ":type" found.', - 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', + 'zip_file' => 'L\'attribut :attribute doit référencer un fichier dans le ZIP.', + 'zip_file_mime' => ':attribute doit référencer un fichier de type :validTypes, trouvé :foundType.', + 'zip_model_expected' => 'Objet de données attendu, mais ":type" trouvé.', + 'zip_unique' => 'L\'attribut :attribute doit être unique pour le type d\'objet dans le ZIP.', // Custom validation lines 'custom' => [ diff --git a/lang/ko/activities.php b/lang/ko/activities.php index c3f35e44375..4e733b7b582 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -50,7 +50,7 @@ 'bookshelf_delete_notification' => '책장이 성공적으로 삭제되었습니다.', // Revisions - 'revision_restore' => '복구된 리비전', + 'revision_restore' => '복구된 버전', 'revision_delete' => '버전 삭제', 'revision_delete_notification' => '버전 삭제 성공', @@ -74,13 +74,13 @@ // Settings 'settings_update' => '설정 변경', 'settings_update_notification' => '설졍 변경 성공', - 'maintenance_action_run' => '유지 관리 작업 실행', + 'maintenance_action_run' => '유지관리 작업 실행', // Webhooks 'webhook_create' => '웹 훅 생성', 'webhook_create_notification' => '웹 훅 생성함', 'webhook_update' => '웹 훅 수정하기', - 'webhook_update_notification' => '웹훅 설정이 수정되었습니다.', + 'webhook_update_notification' => '웹 훅 설정이 수정되었습니다.', 'webhook_delete' => '웹 훅 지우기', 'webhook_delete_notification' => '웹 훅 삭제함', diff --git a/lang/ko/common.php b/lang/ko/common.php index a0751d4c020..00caf4f5498 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -18,12 +18,12 @@ // Form Labels 'name' => '이름', 'description' => '설명', - 'role' => '권한', + 'role' => '역할', 'cover_image' => '대표 이미지', - 'cover_image_description' => '이 이미지는 필요에 따라 다양한 시나리오에서 사용자 인터페이스에 맞게 크기가 조정되거나 잘려질 수 있기 때문에 실제 표시되는 크기가 다를 수는 있지만 대략 440x250px 이어야 합니다.', + 'cover_image_description' => '이 이미지는 대략 440x250px가 되어야 하지만, 필요에 따라 다양한 시나리오에서 사용자 인터페이스에 맞게 유연하게 크기 조절 및 자르기가 가능하므로 실제로 표시되는 크기는 다를 수 있습니다.', // Actions - 'actions' => '활동', + 'actions' => '동작', 'view' => '보기', 'view_all' => '모두 보기', 'new' => '신규', @@ -35,9 +35,9 @@ 'copy' => '복사', 'reply' => '답글', 'delete' => '삭제', - 'delete_confirm' => '삭제 요청 확인', + 'delete_confirm' => '삭제', 'search' => '검색', - 'search_clear' => '검색 지우기', + 'search_clear' => '검색창 비우기', 'reset' => '리셋', 'remove' => '제거', 'add' => '추가', @@ -73,8 +73,8 @@ 'toggle_details' => '내용 보기', 'toggle_thumbnails' => '썸네일 보기', 'details' => '정보', - 'grid_view' => '격자 보기', - 'list_view' => '목록 보기', + 'grid_view' => '격자 형식으로 보기', + 'list_view' => '리스트 형식으로 보기', 'default' => '기본 설정', 'breadcrumb' => '탐색 경로', 'status' => '상태', diff --git a/lang/ko/components.php b/lang/ko/components.php index cbe4d44c27d..0c0bc2e65ed 100644 --- a/lang/ko/components.php +++ b/lang/ko/components.php @@ -23,7 +23,7 @@ 'image_load_more' => '더 보기', 'image_image_name' => '이미지 이름', 'image_delete_used' => '이 이미지는 다음 문서들이 쓰고 있습니다.', - 'image_delete_confirm_text' => '이 이미지를 지울 건가요?', + 'image_delete_confirm_text' => '이 이미지를 정말 삭제하시겠습니까?', 'image_select_image' => '이미지 선택', 'image_dropzone' => '여기에 이미지를 드롭하거나 여기를 클릭하세요. 이미지를 올릴 수 있습니다.', 'image_dropzone_drop' => '업로드 할 이미지 파일을 여기에 놓으세요.', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index cfb905a8fb4..7402056efc2 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -39,22 +39,22 @@ 'export_pdf' => 'PDF 파일', 'export_text' => '일반 텍스트 파일', 'export_md' => '마크다운 파일', - 'export_zip' => '압축 파일', + 'export_zip' => '컨텐츠 ZIP 파일', 'default_template' => '기본 페이지 템플릿', 'default_template_explain' => '이 항목 내에서 생성되는 모든 페이지의 기본 콘텐츠로 사용할 페이지 템플릿을 지정합니다. 페이지 작성자가 선택한 템플릿 페이지를 볼 수 있는 권한이 있는 경우에만 이 항목이 사용된다는 점을 유의하세요.', 'default_template_select' => '템플릿 페이지 선택', - 'import' => '압축 파일 가져오기', - 'import_validate' => '압축 파일 검증하기', + 'import' => '컨텐츠 ZIP 파일 가져오기', + 'import_validate' => '컨텐츠 ZIP 파일 검증하기', 'import_desc' => '같은 인스턴스나 다른 인스턴스에서 휴대용 zip 내보내기를 사용하여 책, 장 및 페이지를 가져옵니다. 진행하려면 ZIP 파일을 선택합니다. 파일을 업로드하고 검증한 후 다음 보기에서 가져오기를 구성하고 확인할 수 있습니다.', 'import_zip_select' => '업로드할 휴대용 압축 파일 선택', - 'import_zip_validation_errors' => '제공된 Portable ZIP 파일을 검증하는 동안 오류가 감지되었습니다.', - 'import_pending' => 'Portable ZIP 파일 가져오기가 일시정지 되었습니다.', + 'import_zip_validation_errors' => '컨텐츠 ZIP 파일을 검증하는 동안 오류가 감지되었습니다.', + 'import_pending' => '컨텐츠 ZIP 파일 가져오기가 일시정지 되었습니다.', 'import_pending_none' => 'Portable ZIP 파일 가져오기가 시작되지 않았습니다.', - 'import_continue' => 'Portable ZIP 가져오기 계속하기', - 'import_continue_desc' => '업로드된 ZIP 파일에서 가져올 내용을 검토합니다. 준비가 되면 가져오기를 실행하여 이 시스템에 내용을 추가합니다. 업로드된 ZIP 가져오기 파일은 가져오기가 성공하면 자동으로 제거됩니다.', - 'import_details' => '압축된 ZIP 파일 상세', + 'import_continue' => '컨텐츠 ZIP 파일 가져오기가 시작되지 않았습니다.', + 'import_continue_desc' => '업로드된 컨텐츠 ZIP 파일에서 가져올 내용을 검토합니다. 준비가 되면 가져오기를 실행하여 이 시스템에 내용을 추가합니다. 업로드된 ZIP 가져오기 파일은 가져오기가 성공하면 자동으로 제거됩니다.', + 'import_details' => '컨텐츠 ZIP 파일 상세', 'import_run' => '가져오기 실행', - 'import_size' => '압축된 ZIP 파일 사이즈', + 'import_size' => '컨텐츠 ZIP 파일 사이즈', 'import_uploaded_at' => '업로드되었습니다. :relativeTime', 'import_uploaded_by' => '업로드되었습니다.', 'import_location' => '가져올 경로', @@ -87,7 +87,7 @@ 'search_terms' => '용어 검색', 'search_content_type' => '형식', 'search_exact_matches' => '정확히 일치', - 'search_tags' => '꼬리표 일치', + 'search_tags' => '태그 검색', 'search_options' => '선택', 'search_viewed_by_me' => '내가 읽음', 'search_not_viewed_by_me' => '내가 읽지 않음', @@ -162,7 +162,7 @@ 'books_empty_create_page' => '문서 만들기', 'books_empty_sort_current_book' => '현재 책 정렬', 'books_empty_add_chapter' => '챕터 만들기', - 'books_permissions_active' => '책 권한 허용함', + 'books_permissions_active' => '책 권한 적용됨', 'books_search_this' => '이 책에서 검색', 'books_navigation' => '목차', 'books_sort' => '책 내용 정렬', @@ -332,7 +332,7 @@ 'tags_index_desc' => '태그를 시스템 내의 콘텐츠에 적용하여 유연한 형태의 분류를 적용할 수 있습니다. 태그는 키와 값을 모두 가질 수 있으며 값은 선택 사항입니다. 태그가 적용되면 태그 이름과 값을 사용하여 콘텐츠를 쿼리할 수 있습니다.', 'tag_name' => '꼬리표 이름', 'tag_value' => '리스트 값 (선택 사항)', - 'tags_explain' => "꼬리표로 문서를 분류하세요.", + 'tags_explain' => "문서를 더 잘 분류하려면 태그를 추가하세요.\n태그에 값을 할당하여 더욱 체계적으로 구성할 수 있습니다.", 'tags_add' => '꼬리표 추가', 'tags_remove' => '꼬리표 삭제', 'tags_usages' => '모든 꼬리표', @@ -344,7 +344,7 @@ 'tags_all_values' => '모든 값', 'tags_view_tags' => '꼬리표 보기', 'tags_view_existing_tags' => '사용 중인 꼬리표 보기', - 'tags_list_empty_hint' => '꼬리표는 에디터 사이드바나 책, 챕터 또는 책꽂이 정보 편집에서 지정할 수 있습니다.', + 'tags_list_empty_hint' => '태그는 에디터 사이드바나 책, 챕터 또는 책꽂이 정보 편집에서 지정할 수 있습니다.', 'attachments' => '첨부 파일', 'attachments_explain' => '파일이나 링크를 첨부하세요. 정보 탭에 나타납니다.', 'attachments_explain_instant_save' => '여기에서 바꾼 내용은 바로 적용합니다.', diff --git a/lang/ko/errors.php b/lang/ko/errors.php index ea1fd7d61e7..9639a503613 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -108,9 +108,9 @@ // Import 'import_zip_cant_read' => 'ZIP 파일을 읽을 수 없습니다.', 'import_zip_cant_decode_data' => 'ZIP data.json 콘텐츠를 찾아서 디코딩할 수 없습니다.', - 'import_zip_no_data' => 'ZIP 파일 데이터에는 예상되는 책, 장 또는 페이지 콘텐츠가 없습니다.', - 'import_validation_failed' => 'ZIP 파일을 가져오려다 실패했습니다. 이유:', - 'import_zip_failed_notification' => 'ZIP 파일을 가져오지 못했습니다.', + 'import_zip_no_data' => '컨텐츠 ZIP 파일 데이터에 데이터가 비어있습니다.', + 'import_validation_failed' => '컨텐츠 ZIP 파일을 가져오려다 실패했습니다. 이유:', + 'import_zip_failed_notification' => '컨텐츠 ZIP 파일을 가져오지 못했습니다.', 'import_perms_books' => '책을 만드는 데 필요한 권한이 없습니다.', 'import_perms_chapters' => '챕터를 만드는 데 필요한 권한이 없습니다.', 'import_perms_pages' => '페이지를 만드는 데 필요한 권한이 없습니다.', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index c2f8fd780c6..169f7970fde 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -69,8 +69,8 @@ 'reg_enable_external_warning' => '외부 시스템이 LDAP나 SAML 인증이 활성화되어 있다면 설정과 관계없이 인증을 성공할 때 없는 계정을 만듭니다.', 'reg_email_confirmation' => '메일 주소 확인', 'reg_email_confirmation_toggle' => '메일 주소 확인', - 'reg_confirm_email_desc' => '도메인 차단을 활성화하면 설정과 관계없이 메일 주소 확인이 필요합니다.', - 'reg_confirm_restrict_domain' => '도메인 차단', + 'reg_confirm_email_desc' => '도메인 제한을 활성화하면 설정과 관계없이 메일 주소 확인이 필요합니다.', + 'reg_confirm_restrict_domain' => '도메인 제한', 'reg_confirm_restrict_domain_desc' => '가입을 차단할 도메인을 쉼표로 구분하여 입력하세요. 사용자가 메일 주소 확인에 성공하면 메일 주소를 바꿀 수 있습니다.', 'reg_confirm_restrict_domain_placeholder' => '차단한 도메인 없음', @@ -80,13 +80,13 @@ 'maint_image_cleanup_desc' => '중복인 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.', 'maint_delete_images_only_in_revisions' => '지난 버전에만 있는 이미지 지우기', 'maint_image_cleanup_run' => '실행', - 'maint_image_cleanup_warning' => '이미지 :count개를 지울 건가요?', + 'maint_image_cleanup_warning' => '이미지 :count개를 지우시겠습니까?', 'maint_image_cleanup_success' => '이미지 :count개 삭제함', 'maint_image_cleanup_nothing_found' => '삭제한 것 없음', 'maint_send_test_email' => '테스트 메일 보내기', 'maint_send_test_email_desc' => '메일 주소로 테스트 메일을 전송합니다.', 'maint_send_test_email_run' => '테스트 메일 보내기', - 'maint_send_test_email_success' => ':address로 보냈습니다.', + 'maint_send_test_email_success' => ':address 계정으로 이메일을 보냈습니다.', 'maint_send_test_email_mail_subject' => '테스트 메일', 'maint_send_test_email_mail_greeting' => '메일을 수신했습니다.', 'maint_send_test_email_mail_text' => '메일을 정상적으로 수신했습니다.', @@ -108,7 +108,7 @@ 'recycle_bin_restore' => '복원', 'recycle_bin_contents_empty' => '휴지통이 비었습니다.', 'recycle_bin_empty' => '비우기', - 'recycle_bin_empty_confirm' => '휴지통을 비울 건가요?', + 'recycle_bin_empty_confirm' => '이렇게 하면 각 항목에 포함된 콘텐츠를 포함하여 휴지통에 있는 모든 항목이 영구적으로 삭제됩니다. 휴지통을 비우시겠습니까?', 'recycle_bin_destroy_confirm' => '이 작업을 수행하면 이 항목이 아래에 나열된 모든 하위 요소와 함께 시스템에서 영구적으로 삭제되며, 복원할 수 없습니다. 이 항목을 영구 삭제하시겠어요?', 'recycle_bin_destroy_list' => '영구 삭제함', 'recycle_bin_restore_list' => '복원함', @@ -119,8 +119,8 @@ 'recycle_bin_restore_notification' => ':count항목 복원함', // Audit Log - 'audit' => '추적 기록', - 'audit_desc' => '시스템에서 추적한 작업입니다. 권한 필터가 작동하지 않습니다.', + 'audit' => '활동 기록', + 'audit_desc' => '이 활동 로그는 시스템에서 추적된 활동 목록을 표시합니다. 이 목록은 권한 필터가 적용되는 시스템의 유사한 활동 목록과 달리 필터링되지 않습니다.', 'audit_event_filter' => '이벤트 필터', 'audit_event_filter_no_filter' => '필터 없음', 'audit_deleted_item' => '삭제한 항목', @@ -134,8 +134,8 @@ 'audit_date_to' => 'To', // Role Settings - 'roles' => '권한', - 'role_user_roles' => '사용자 권한', + 'roles' => '역할', + 'role_user_roles' => '사용자 역할', 'roles_index_desc' => '역할은 사용자를 그룹화하고 구성원에게 시스템 권한을 제공하기 위해 사용됩니다. 사용자가 여러 역할의 구성원인 경우 부여된 권한이 중첩되며 모든 권한을 상속받게 됩니다.', 'roles_x_users_assigned' => ':count 명의 사용자가 할당됨|:count 명의 사용자가 할당됨', 'roles_x_permissions_provided' => ':count 개의 권한|:count 개의 권한', @@ -147,10 +147,10 @@ 'role_delete_users_assigned' => '이 권한을 가진 사용자 :userCount명에 할당할 권한을 고르세요.', 'role_delete_no_migration' => "할당하지 않음", 'role_delete_sure' => '이 권한을 지울 건가요?', - 'role_edit' => '권한 수정', - 'role_details' => '권한 정보', - 'role_name' => '권한 이름', - 'role_desc' => '설명', + 'role_edit' => '역할 수정', + 'role_details' => '역할 정보', + 'role_name' => '역할 이름', + 'role_desc' => '역할 설명', 'role_mfa_enforced' => '다중 인증 필요', 'role_external_auth_id' => '외부 인증 계정', 'role_system' => '시스템 권한', @@ -168,14 +168,14 @@ 'role_asset' => '권한 항목', 'roles_system_warning' => '위 세 권한은 자신의 권한이나 다른 유저의 권한을 바꿀 수 있습니다.', 'role_asset_desc' => '책, 챕터, 문서별 권한은 이 설정에 우선합니다.', - 'role_asset_admins' => 'Admin 권한은 어디든 접근할 수 있지만 이 설정은 사용자 인터페이스에서 해당 활동을 표시할지 결정합니다.', + 'role_asset_admins' => '관리자 권한은 어디든 접근할 수 있지만 이 설정은 사용자 인터페이스에서 해당 활동을 표시할지 결정합니다.', 'role_asset_image_view_note' => '이는 이미지 관리자 내 가시성과 관련이 있습니다. 업로드된 이미지 파일의 실제 접근은 시스템의 이미지 저장 설정에 따라 달라집니다.', 'role_all' => '모든 항목', 'role_own' => '직접 만든 항목', 'role_controlled_by_asset' => '저마다 다름', 'role_save' => '저장', - 'role_users' => '이 권한을 가진 사용자들', - 'role_users_none' => '그런 사용자가 없습니다.', + 'role_users' => '이 역할을 가진 사용자들', + 'role_users_none' => '역할이 부여된 사용자가 없습니다.', // Users 'users' => '사용자', @@ -200,7 +200,7 @@ 'users_delete' => '사용자 삭제', 'users_delete_named' => ':userName 삭제', 'users_delete_warning' => ':userName에 관한 데이터를 지웁니다.', - 'users_delete_confirm' => '이 사용자를 지울 건가요?', + 'users_delete_confirm' => '이 사용자를 삭제하시겠습니까?', 'users_migrate_ownership' => '소유자 바꾸기', 'users_migrate_ownership_desc' => '선택한 사용자가 소유하고 있는 모든 항목을 다른 유저가 소유하게 합니다.', 'users_none_selected' => '선택한 유저 없음', @@ -246,7 +246,7 @@ 'user_api_token_updated' => ':timeAgo 전에 토큰 갱신함', 'user_api_token_delete' => '토큰 삭제', 'user_api_token_delete_warning' => '\':tokenName\'을 시스템에서 삭제합니다.', - 'user_api_token_delete_confirm' => '이 API 토큰을 지울 건가요?', + 'user_api_token_delete_confirm' => '이 API 토큰을 삭제하시겠습니까?', // Webhooks 'webhooks' => '웹 훅', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index df766740342..ef7361ff9ac 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -105,10 +105,10 @@ 'url' => ':attribute(은)는 유효하지 않은 형식입니다.', 'uploaded' => '파일 크기가 서버에서 허용하는 수치를 넘습니다.', - 'zip_file' => ':attribute은(는) ZIP 파일 내의 파일을 참조해야 합니다.', + 'zip_file' => ':attribute은(는) 컨텐츠 ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', 'zip_file_mime' => ':attribute은(는) :validTypes, found :foundType 유형의 파일을 참조해야 합니다.', 'zip_model_expected' => '데이터 객체가 필요하지만 ":type" 타입이 발견되었습니다.', - 'zip_unique' => ':attribute은(는) ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', + 'zip_unique' => ':attribute은(는) 컨텐츠 ZIP 파일 내의 객체 유형에 대해 고유해야 합니다.', // Custom validation lines 'custom' => [ diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index fe73c35b810..f947b304087 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -87,7 +87,7 @@ 'search_terms' => 'Termos da pesquisa', 'search_content_type' => 'Categoria de conteúdo', 'search_exact_matches' => 'Correspondências exatas', - 'search_tags' => 'Etiqueta de buscas', + 'search_tags' => 'Pesquisar marcadores', 'search_options' => 'Opções', 'search_viewed_by_me' => 'Visto por mim', 'search_not_viewed_by_me' => 'Não visto por mim', @@ -255,7 +255,7 @@ 'pages_editor_switch_consider_following' => 'Considere o seguinte ao alterar editores:', 'pages_editor_switch_consideration_a' => 'Uma vez salva, a nova opção do editor será usada por quaisquer editores futuros, incluindo aqueles que podem não ser capazes de mudar o tipo do editor.', 'pages_editor_switch_consideration_b' => 'Isso pode levar a uma perda de detalhes e sintaxe em certas circunstâncias.', - 'pages_editor_switch_consideration_c' => 'Etiqueta ou alterações no log de mudanças, feitas desde o último salvamento, não persistem nesta alteração.', + 'pages_editor_switch_consideration_c' => 'Marcadores ou alterações no log de mudanças, feitas desde o último salvamento, não persistem nesta alteração.', 'pages_save' => 'Salvar Página', 'pages_title' => 'Título da Página', 'pages_name' => 'Nome da Página', @@ -299,9 +299,9 @@ 'pages_pointer_enter_mode' => 'Entrar em modo de seleção de seção', 'pages_pointer_label' => 'Opções de Seção de Página', 'pages_pointer_permalink' => 'Seção de Página Permalink', - 'pages_pointer_include_tag' => 'Seção de Página Incluir Tag', - 'pages_pointer_toggle_link' => 'Modo permalink, pressione para mostrar a tag incluída', - 'pages_pointer_toggle_include' => 'Incluir o modo tag, pressione para mostrar permalink', + 'pages_pointer_include_tag' => 'Marcador de inclusão de seção de página', + 'pages_pointer_toggle_link' => 'Modo permalink, pressione para mostrar a marcação incluída', + 'pages_pointer_toggle_include' => 'Incluir o modo de marcação, pressione para mostrar o permalink', 'pages_permissions_active' => 'Permissões de Página Ativas', 'pages_initial_revision' => 'Publicação Inicial', 'pages_references_update_revision' => 'Atualização automática do sistema de links internos', @@ -323,18 +323,18 @@ // Editor Sidebar 'toggle_sidebar' => '', - 'page_tags' => 'Tags de Página', - 'chapter_tags' => 'Tags de Capítulo', - 'book_tags' => 'Tags de Livro', - 'shelf_tags' => 'Tags de Prateleira', - 'tag' => 'Tag', - 'tags' => 'Tags', - 'tags_index_desc' => 'As tags podem ser aplicadas ao conteúdo dentro do sistema para aplicar uma forma flexível de categorização. As tags podem ter uma chave e um valor, sendo o valor opcional. Depois de aplicado, o conteúdo pode ser consultado usando o nome e o valor da tag.', - 'tag_name' => 'Nome da Tag', - 'tag_value' => 'Valor da Tag (Opcional)', - 'tags_explain' => "Adicione algumas tags para melhor categorizar seu conteúdo. \n Você pode atribuir valores às tags para uma organização mais complexa.", - 'tags_add' => 'Adicionar outra tag', - 'tags_remove' => 'Remover essa tag', + 'page_tags' => 'Marcadores de Página', + 'chapter_tags' => 'Marcadores de Capítulo', + 'book_tags' => 'Marcadores de Livro', + 'shelf_tags' => 'Marcadores de Estante', + 'tag' => 'Marcador', + 'tags' => 'Marcadores', + 'tags_index_desc' => 'Os marcadores podem ser aplicadas ao conteúdo dentro do sistema para aplicar uma forma flexível de categorização. Os marcadores podem ter uma chave e um valor, sendo o valor opcional. Depois de aplicado, o conteúdo pode ser consultado usando o nome e o valor do marcador.', + 'tag_name' => 'Nome do marcador', + 'tag_value' => 'Valor do marcador (Opcional)', + 'tags_explain' => "Adicione alguns marcadores para melhor categorizar seu conteúdo. \n Você pode atribuir valores aos marcadores para uma organização mais complexa.", + 'tags_add' => 'Adicionar outro marcador', + 'tags_remove' => 'Remover esse marcador', 'tags_usages' => 'Total de marcadores usados', 'tags_assigned_pages' => 'Atribuído às páginas', 'tags_assigned_chapters' => 'Atribuído aos Capítulos', @@ -343,8 +343,8 @@ 'tags_x_unique_values' => ':count valores únicos', 'tags_all_values' => 'Todos os valores', 'tags_view_tags' => 'Ver Marcadores', - 'tags_view_existing_tags' => 'Ver tags existentes', - 'tags_list_empty_hint' => 'As tags podem ser atribuídas através da barra lateral do editor de página ou ao editar os detalhes de um livro, capítulo ou prateleira.', + 'tags_view_existing_tags' => 'Ver marcadores existentes', + 'tags_list_empty_hint' => 'Os marcadores podem ser atribuídos através da barra lateral do editor de página ou ao editar os detalhes de um livro, capítulo ou prateleira.', 'attachments' => 'Anexos', 'attachments_explain' => 'Faça o upload de alguns arquivos ou anexe links para serem exibidos na sua página. Eles estarão visíveis na barra lateral à direita.', 'attachments_explain_instant_save' => 'Mudanças são salvas instantaneamente.', From 593645acfe8521db97d7469c92546c8529703969 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 14:30:53 +0000 Subject: [PATCH 288/800] Themes: Added route to serve public theme files Allows files to be placed within a "public" folder within a theme directory which the contents of will served by BookStack for access. - Only "web safe" content-types are provided. - A static 1 day cache time it set on served files. For #3904 --- app/App/helpers.php | 4 +-- .../Controllers/BookExportController.php | 2 +- .../Controllers/ChapterExportController.php | 2 +- .../Controllers/PageExportController.php | 2 +- app/Http/DownloadResponseFactory.php | 19 +++++++++++- .../Middleware/PreventResponseCaching.php | 14 +++++++++ app/Theming/ThemeController.php | 31 +++++++++++++++++++ app/Theming/ThemeService.php | 9 ++++++ app/Uploads/FileStorage.php | 9 +++--- app/Uploads/ImageStorageDisk.php | 9 +++--- app/Util/FilePathNormalizer.php | 17 ++++++++++ routes/web.php | 8 ++++- 12 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 app/Theming/ThemeController.php create mode 100644 app/Util/FilePathNormalizer.php diff --git a/app/App/helpers.php b/app/App/helpers.php index af6dbcfc397..941c267d6cd 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -1,6 +1,7 @@ queries->findVisibleBySlugOrFail($bookSlug); $zip = $builder->buildForBook($book); - return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $bookSlug . '.zip', true); } } diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index de2385bb11f..8490243439a 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -82,6 +82,6 @@ public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $bui $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $zip = $builder->buildForChapter($chapter); - return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $chapterSlug . '.zip', true); } } diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index d7145411eaa..145dce9dd0f 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -86,6 +86,6 @@ public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builde $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $zip = $builder->buildForPage($page); - return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', filesize($zip), true); + return $this->download()->streamedFileDirectly($zip, $pageSlug . '.zip', true); } } diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index d06e2bac44d..01b3502d4b1 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -39,8 +39,9 @@ public function streamedDirectly($stream, string $fileName, int $fileSize): Stre * Create a response that downloads the given file via a stream. * Has the option to delete the provided file once the stream is closed. */ - public function streamedFileDirectly(string $filePath, string $fileName, int $fileSize, bool $deleteAfter = false): StreamedResponse + public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse { + $fileSize = filesize($filePath); $stream = fopen($filePath, 'r'); if ($deleteAfter) { @@ -79,6 +80,22 @@ public function streamedInline($stream, string $fileName, int $fileSize): Stream ); } + /** + * Create a response that provides the given file via a stream with detected content-type. + * Has the option to delete the provided file once the stream is closed. + */ + public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse + { + $fileSize = filesize($filePath); + $stream = fopen($filePath, 'r'); + + if ($fileName === null) { + $fileName = basename($filePath); + } + + return $this->streamedInline($stream, $fileName, $fileSize); + } + /** * Get the common headers to provide for a download response. */ diff --git a/app/Http/Middleware/PreventResponseCaching.php b/app/Http/Middleware/PreventResponseCaching.php index c763b5fc1bb..a40150444b5 100644 --- a/app/Http/Middleware/PreventResponseCaching.php +++ b/app/Http/Middleware/PreventResponseCaching.php @@ -7,6 +7,13 @@ class PreventResponseCaching { + /** + * Paths to ignore when preventing response caching. + */ + protected array $ignoredPathPrefixes = [ + 'theme/', + ]; + /** * Handle an incoming request. * @@ -20,6 +27,13 @@ public function handle($request, Closure $next) /** @var Response $response */ $response = $next($request); + $path = $request->path(); + foreach ($this->ignoredPathPrefixes as $ignoredPath) { + if (str_starts_with($path, $ignoredPath)) { + return $response; + } + } + $response->headers->set('Cache-Control', 'no-cache, no-store, private'); $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT'); diff --git a/app/Theming/ThemeController.php b/app/Theming/ThemeController.php new file mode 100644 index 00000000000..1eecc697428 --- /dev/null +++ b/app/Theming/ThemeController.php @@ -0,0 +1,31 @@ +download()->streamedFileInline($filePath); + $response->setMaxAge(86400); + + return $response; + } +} diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php index 94e4712176b..639854d6ad1 100644 --- a/app/Theming/ThemeService.php +++ b/app/Theming/ThemeService.php @@ -15,6 +15,15 @@ class ThemeService */ protected array $listeners = []; + /** + * Get the currently configured theme. + * Returns an empty string if not configured. + */ + public function getTheme(): string + { + return config('view.theme') ?? ''; + } + /** * Listen to a given custom theme event, * setting up the action to be ran when the event occurs. diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index 6e4a210a162..70040725a3d 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -3,12 +3,12 @@ namespace BookStack\Uploads; use BookStack\Exceptions\FileUploadException; +use BookStack\Util\FilePathNormalizer; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class FileStorage @@ -120,12 +120,13 @@ protected function getStorageDiskName(): string */ protected function adjustPathForStorageDisk(string $path): string { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + $trimmed = str_replace('uploads/files/', '', $path); + $normalized = FilePathNormalizer::normalize($trimmed); if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; + return $normalized; } - return 'uploads/files/' . $path; + return 'uploads/files/' . $normalized; } } diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 8df702e0d94..da8bacb3447 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -2,9 +2,9 @@ namespace BookStack\Uploads; +use BookStack\Util\FilePathNormalizer; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemAdapter; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\StreamedResponse; class ImageStorageDisk @@ -30,13 +30,14 @@ public function usingSecureImages(): bool */ protected function adjustPathForDisk(string $path): string { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path)); + $trimmed = str_replace('uploads/images/', '', $path); + $normalized = FilePathNormalizer::normalize($trimmed); if ($this->usingSecureImages()) { - return $path; + return $normalized; } - return 'uploads/images/' . $path; + return 'uploads/images/' . $normalized; } /** diff --git a/app/Util/FilePathNormalizer.php b/app/Util/FilePathNormalizer.php new file mode 100644 index 00000000000..d55fb74f879 --- /dev/null +++ b/app/Util/FilePathNormalizer.php @@ -0,0 +1,17 @@ +normalizePath($path); + } +} diff --git a/routes/web.php b/routes/web.php index 318147ef518..5bb9622e737 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,12 +13,14 @@ use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; use Illuminate\View\Middleware\ShareErrorsFromSession; +// Status & Meta routes Route::get('/status', [SettingControllers\StatusController::class, 'show']); Route::get('/robots.txt', [MetaController::class, 'robots']); Route::get('/favicon.ico', [MetaController::class, 'favicon']); @@ -360,8 +362,12 @@ Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']); Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); -// Metadata routes +// Help & Info routes Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); +// Theme Routes +Route::get('/theme/{theme}/{path}', [ThemeController::class, 'publicFile']) + ->where('path', '.*$'); + Route::fallback([MetaController::class, 'notFound'])->name('fallback'); From 481580be172a4813ee98ad1b945d12d731e71cdb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Jan 2025 16:51:07 +0000 Subject: [PATCH 289/800] Themes: Added testing and better mime sniffing for public serving Existing mime sniffer wasn't great at distinguishing between plaintext file types, so added a custom extension based mapping for common web formats that may be expected to be used with this. --- app/Http/DownloadResponseFactory.php | 2 +- app/Http/RangeSupportedStream.php | 4 ++-- app/Util/WebSafeMimeSniffer.php | 16 ++++++++++++++-- tests/ThemeTest.php | 28 ++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/Http/DownloadResponseFactory.php b/app/Http/DownloadResponseFactory.php index 01b3502d4b1..8384484ad62 100644 --- a/app/Http/DownloadResponseFactory.php +++ b/app/Http/DownloadResponseFactory.php @@ -70,7 +70,7 @@ public function streamedFileDirectly(string $filePath, string $fileName, bool $d public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse { $rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request); - $mime = $rangeStream->sniffMime(); + $mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION)); $headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders()); return response()->stream( diff --git a/app/Http/RangeSupportedStream.php b/app/Http/RangeSupportedStream.php index fce1e9acce3..c4b00778939 100644 --- a/app/Http/RangeSupportedStream.php +++ b/app/Http/RangeSupportedStream.php @@ -32,12 +32,12 @@ public function __construct( /** * Sniff a mime type from the stream. */ - public function sniffMime(): string + public function sniffMime(string $extension = ''): string { $offset = min(2000, $this->fileSize); $this->sniffContent = fread($this->stream, $offset); - return (new WebSafeMimeSniffer())->sniff($this->sniffContent); + return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension); } /** diff --git a/app/Util/WebSafeMimeSniffer.php b/app/Util/WebSafeMimeSniffer.php index b182d8ac19b..4a82de85d25 100644 --- a/app/Util/WebSafeMimeSniffer.php +++ b/app/Util/WebSafeMimeSniffer.php @@ -13,7 +13,7 @@ class WebSafeMimeSniffer /** * @var string[] */ - protected $safeMimes = [ + protected array $safeMimes = [ 'application/json', 'application/octet-stream', 'application/pdf', @@ -48,16 +48,28 @@ class WebSafeMimeSniffer 'video/av1', ]; + protected array $textTypesByExtension = [ + 'css' => 'text/css', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'csv' => 'text/csv', + ]; + /** * Sniff the mime-type from the given file content while running the result * through an allow-list to ensure a web-safe result. * Takes the content as a reference since the value may be quite large. + * Accepts an optional $extension which can be used for further guessing. */ - public function sniff(string &$content): string + public function sniff(string &$content, string $extension = ''): string { $fInfo = new finfo(FILEINFO_MIME_TYPE); $mime = $fInfo->buffer($content) ?: 'application/octet-stream'; + if ($mime === 'text/plain' && $extension) { + $mime = $this->textTypesByExtension[$extension] ?? 'text/plain'; + } + if (in_array($mime, $this->safeMimes)) { return $mime; } diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 837b94eee72..b3c85d8f724 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -464,6 +464,34 @@ public function test_custom_settings_category_page_can_be_added_via_view_file() }); } + public function test_public_folder_contents_accessible_via_route() + { + $this->usingThemeFolder(function (string $themeFolderName) { + $publicDir = theme_path('public'); + mkdir($publicDir, 0777, true); + + $text = 'some-text ' . md5(random_bytes(5)); + $css = "body { background-color: tomato !important; }"; + file_put_contents("{$publicDir}/file.txt", $text); + file_put_contents("{$publicDir}/file.css", $css); + copy($this->files->testFilePath('test-image.png'), "{$publicDir}/image.png"); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt"); + $resp->assertStreamedContent($text); + $resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png"); + $resp->assertHeader('Content-Type', 'image/png'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + + $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css"); + $resp->assertStreamedContent($css); + $resp->assertHeader('Content-Type', 'text/css; charset=UTF-8'); + $resp->assertHeader('Cache-Control', 'max-age=86400, private'); + }); + } + protected function usingThemeFolder(callable $callback) { // Create a folder and configure a theme From 25c4f4b02ba06f66f5239de48ae005f895146f8d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Jan 2025 14:53:10 +0000 Subject: [PATCH 290/800] Themes: Documented public file serving --- dev/docs/logical-theme-system.md | 4 +++- dev/docs/visual-theme-system.md | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md index 139055b3db2..84bd26a5387 100644 --- a/dev/docs/logical-theme-system.md +++ b/dev/docs/logical-theme-system.md @@ -2,7 +2,9 @@ BookStack allows logical customization via the theme system which enables you to add, or extend, functionality within the PHP side of the system without needing to alter the core application files. -WARNING: This system is currently in alpha so may incur changes. Once we've gathered some feedback on usage we'll look to removing this warning. This system will be considered semi-stable in the future. The `Theme::` system will be kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates. +This is part of the theme system alongside the [visual theme system](./visual-theme-system.md). + +**Note:** This system is considered semi-stable. The `Theme::` system is kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates. ## Getting Started diff --git a/dev/docs/visual-theme-system.md b/dev/docs/visual-theme-system.md index 6e7105a9ed0..8a76ddb00e0 100644 --- a/dev/docs/visual-theme-system.md +++ b/dev/docs/visual-theme-system.md @@ -2,7 +2,9 @@ BookStack allows visual customization via the theme system which enables you to extensively customize views, translation text & icons. -This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates. +This is part of the theme system alongside the [logical theme system](./logical-theme-system.md). + +**Note:** This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates. ## Getting Started @@ -32,3 +34,24 @@ return [ 'search' => 'find', ]; ``` + +## Publicly Accessible Files + +As part of deeper customizations you may want to expose additional files +(images, scripts, styles, etc...) as part of your theme, in a way so they're +accessible in public web-space to browsers. + +To achieve this, you can put files within a `themes//public` folder. +BookStack will serve any files within this folder from a `/theme/` base path. + +As an example, if I had an image located at `themes/custom/public/cat.jpg`, I could access +that image via the URL path `/theme/custom/cat.jpg`. That's assuming that `custom` is the currently +configured application theme. + +There are some considerations to these publicly served files: + +- Only a predetermined range "web safe" content-types are currently served. + - This limits running into potential insecure scenarios in serving problematic file types. +- A static 1-day cache time it set on files served from this folder. + - You can use alternative cache-breaking techniques (change of query string) upon changes if needed. + - If required, you could likely override caching at the webserver level. From 0d1a237f8150ed103349ce0485472fb247c3251e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 15 Jan 2025 14:15:58 +0000 Subject: [PATCH 291/800] Lexical: Fixed auto-link issue Added extra test helper to check the editor state directly via string notation access rather than juggling types/objects to access deep properties. --- .../lexical/core/__tests__/utils/index.ts | 29 ++++- .../services/__tests__/auto-links.test.ts | 121 ++++++++---------- resources/js/wysiwyg/services/auto-links.ts | 2 +- resources/js/wysiwyg/todo.md | 6 +- 4 files changed, 82 insertions(+), 76 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index b13bba6977e..d54a64ce89a 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -37,8 +37,6 @@ import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; -import {turtle} from "@codemirror/legacy-modes/mode/turtle"; - type TestEnv = { readonly container: HTMLDivElement; @@ -47,6 +45,9 @@ type TestEnv = { readonly innerHTML: string; }; +/** + * @deprecated - Consider using `createTestContext` instead within the test case. + */ export function initializeUnitTest( runTests: (testEnv: TestEnv) => void, editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}}, @@ -795,6 +796,30 @@ export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShap expect(shape.children).toMatchObject(expected); } +/** + * Expect a given prop within the JSON editor state structure to be the given value. + * Uses dot notation for the provided `propPath`. Example: + * 0.5.cat => First child, Sixth child, cat property + */ +export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) { + let currentItem: any = editor.getEditorState().toJSON().root; + let currentPath = []; + const pathParts = propPath.split('.'); + + for (const part of pathParts) { + currentPath.push(part); + const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children); + const target = childAccess ? currentItem.children : currentItem; + + if (typeof target[part] === 'undefined') { + throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`) + } + currentItem = target[part]; + } + + expect(currentItem).toBe(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index 30dc925659e..add61c495a2 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -1,91 +1,76 @@ -import {initializeUnitTest} from "lexical/__tests__/utils"; -import {SerializedLinkNode} from "@lexical/link"; +import { + createTestContext, + dispatchKeydownEventForNode, expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; import { $getRoot, ParagraphNode, - SerializedParagraphNode, - SerializedTextNode, TextNode } from "lexical"; import {registerAutoLinks} from "../auto-links"; describe('Auto-link service tests', () => { - initializeUnitTest((testEnv) => { - - test('space after link in text', async () => { - const {editor} = testEnv; - - registerAutoLinks(editor); - let pNode!: ParagraphNode; - - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); - - text.select(34, 34); - }); + test('space after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(34, 34); + }); - editor.commitUpdates(); + dispatchKeydownEventForNode(pNode, editor, ' '); - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: ' ', - keyCode: 62, - }); - pDomEl?.dispatchEvent(event); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); + }); - editor.commitUpdates(); + test('space after link at end of line', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true'); + pNode.append(text); + $getRoot().append(pNode); - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); + text.selectEnd(); }); - test('enter after link in text', async () => { - const {editor} = testEnv; - - registerAutoLinks(editor); - let pNode!: ParagraphNode; - - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); + dispatchKeydownEventForNode(pNode, editor, ' '); - text.select(34, 34); - }); + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Some '}, + {type: 'link', children: [{text: 'https://example.com?test=true'}]} + ]}]); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + }); - editor.commitUpdates(); + test('enter after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 'Enter', - keyCode: 66, - }); - pDomEl?.dispatchEvent(event); + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); - editor.commitUpdates(); + text.select(34, 34); + }); - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); + dispatchKeydownEventForNode(pNode, editor, 'Enter'); - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); - }); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 1c3b1c73010..62cd459940c 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -43,7 +43,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit linkNode.append(new TextNode(textSegment)); const splits = node.splitText(startIndex, cursorPoint); - const targetIndex = splits.length === 3 ? 1 : 0; + const targetIndex = startIndex > 0 ? 1 : 0; const targetText = splits[targetIndex]; if (targetText) { targetText.replace(linkNode); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 817a235a712..a49cccd26dc 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,11 +2,7 @@ ## In progress -Reorg - - Merge custom nodes into original nodes - - Reduce down to use CommonBlockNode where possible - - Remove existing formatType/ElementFormatType references (replaced with alignment). - - Remove existing indent references (replaced with inset). +// ## Main Todo From 7f5fd16dc601039a0ff14749c98d8ea35902ec4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 15 Jan 2025 14:31:09 +0000 Subject: [PATCH 292/800] Lexical: Added some general test guidance Just to help remember the general layout/methods that we've added to make testing easier. --- resources/js/wysiwyg/testing.md | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 resources/js/wysiwyg/testing.md diff --git a/resources/js/wysiwyg/testing.md b/resources/js/wysiwyg/testing.md new file mode 100644 index 00000000000..7b272c60696 --- /dev/null +++ b/resources/js/wysiwyg/testing.md @@ -0,0 +1,55 @@ +# Testing Guidance + +This is testing guidance specific for this Lexical-based WYSIWYG editor. +There is a lot of pre-existing test code carried over form the fork of lexical, but since there we've added a range of helpers and altered how testing can be done to make things a bit simpler and aligned with how we run tests. + +This document is an attempt to document the new best options for added tests with an aim for standardisation on these approaches going forward. + +## Utils Location + +Most core test utils can be found in the file at path: resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts + +## Test Example + +This is an example of a typical test using the common modern utilities to help perform actions or assertions. Comments are for this example only, and are not expected in actual test files. + +```ts +import { + createTestContext, + dispatchKeydownEventForNode, + expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; +import { + $getRoot, + ParagraphNode, + TextNode +} from "lexical"; + +describe('A specific service or file or function', () => { + test('it does thing', async () => { + // Create the editor context and get an editor reference + const {editor} = createTestContext(); + + // Run an action within the editor. + let pNode: ParagraphNode; + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Hello!'); + pNode.append(text); + $getRoot().append(pNode); + }); + + // Dispatch key events via the DOM + dispatchKeydownEventForNode(pNode!, editor, ' '); + + // Check the shape (and text) of the resulting state + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Hello!'}, + ]}]); + + // Check specific props in the resulting JSON state + expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello!'); + }); +}); +``` \ No newline at end of file From c091f67db334024bd6b4c65d1833b2c60e3e0a45 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 17 Jan 2025 11:15:14 +0000 Subject: [PATCH 293/800] Lexical: Added color format custom color select Includes tracking of selected colors via localstorage for display. --- resources/icons/editor/color-select.svg | 1 + .../ui/framework/blocks/color-picker.ts | 49 ++++++++++++++++++- resources/js/wysiwyg/ui/framework/core.ts | 7 +++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 resources/icons/editor/color-select.svg diff --git a/resources/icons/editor/color-select.svg b/resources/icons/editor/color-select.svg new file mode 100644 index 00000000000..cef6866558b --- /dev/null +++ b/resources/icons/editor/color-select.svg @@ -0,0 +1 @@ + \ 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 index b068fb4f0bf..65623e1b21d 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -4,6 +4,8 @@ import {$patchStyleText} from "@lexical/selection"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; +import selectIcon from "@icons/editor/color-select.svg"; +import {uniqueIdSmall} from "../../../../services/util"; const colorChoices = [ '#000000', @@ -34,6 +36,8 @@ const colorChoices = [ '#34495E', ]; +const storageKey = 'bs-lexical-custom-colors'; + export class EditorColorPicker extends EditorUiElement { protected styleProperty: string; @@ -44,8 +48,10 @@ export class EditorColorPicker extends EditorUiElement { } buildDOM(): HTMLElement { + const id = uniqueIdSmall(); - const colorOptions = colorChoices.map(choice => { + const allChoices = [...colorChoices, ...this.getCustomColorChoices()]; + const colorOptions = allChoices.map(choice => { return el('div', { class: 'editor-color-select-option', style: `background-color: ${choice}`, @@ -62,6 +68,25 @@ export class EditorColorPicker extends EditorUiElement { removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); + const selectButton = el('label', { + class: 'editor-color-select-option', + for: `color-select-${id}`, + 'data-color': '', + title: 'Custom color', + }, []); + selectButton.innerHTML = selectIcon; + colorOptions.push(selectButton); + + const input = el('input', {type: 'color', hidden: 'true', id: `color-select-${id}`}) as HTMLInputElement; + colorOptions.push(input); + input.addEventListener('change', e => { + if (input.value) { + this.storeCustomColorChoice(input.value); + this.setColor(input.value); + this.rebuildDOM(); + } + }); + const colorRows = []; for (let i = 0; i < colorOptions.length; i+=5) { const options = colorOptions.slice(i, i + 5); @@ -79,11 +104,33 @@ export class EditorColorPicker extends EditorUiElement { return wrapper; } + storeCustomColorChoice(color: string) { + if (colorChoices.includes(color)) { + return; + } + + const customColors: string[] = this.getCustomColorChoices(); + if (customColors.includes(color)) { + return; + } + + customColors.push(color); + window.localStorage.setItem(storageKey, JSON.stringify(customColors)); + } + + getCustomColorChoices(): string[] { + return JSON.parse(window.localStorage.getItem(storageKey) || '[]'); + } + 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.setColor(color); + } + + setColor(color: string) { this.getContext().editor.update(() => { const selection = $getSelection(); if (selection) { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 3433b96e8d7..90ce4ebf93c 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -53,6 +53,13 @@ export abstract class EditorUiElement { return this.dom; } + rebuildDOM(): HTMLElement { + const newDOM = this.buildDOM(); + this.dom?.replaceWith(newDOM); + this.dom = newDOM; + return this.dom; + } + trans(text: string) { return this.getContext().translate(text); } From 04cca77ae6d84e0f7c3aceef6c0bc3682258c5c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Jan 2025 11:12:43 +0000 Subject: [PATCH 294/800] Lexical: Added color picker/indicator to form fields --- resources/icons/editor/color-display.svg | 10 ++++ resources/js/wysiwyg/todo.md | 4 -- .../ui/defaults/buttons/inline-formats.ts | 14 +++++ .../js/wysiwyg/ui/defaults/forms/tables.ts | 21 +++---- resources/js/wysiwyg/ui/defaults/toolbars.ts | 8 +-- .../ui/framework/blocks/color-field.ts | 56 +++++++++++++++++++ .../ui/framework/blocks/color-picker.ts | 19 +++---- .../wysiwyg/ui/framework/blocks/link-field.ts | 2 - resources/js/wysiwyg/ui/framework/forms.ts | 17 ++++-- resources/sass/_editor.scss | 10 ++++ 10 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 resources/icons/editor/color-display.svg create mode 100644 resources/js/wysiwyg/ui/framework/blocks/color-field.ts diff --git a/resources/icons/editor/color-display.svg b/resources/icons/editor/color-display.svg new file mode 100644 index 00000000000..86be9a7bfa7 --- /dev/null +++ b/resources/icons/editor/color-display.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index a49cccd26dc..695e8cb69ed 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,13 +10,9 @@ ## 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) - Deep check of translation coverage -- About button & view -- Mobile display and handling ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index c3726acf001..c5b7ad29ad9 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -12,6 +12,8 @@ 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"; +import {$patchStyleText} from "@lexical/selection"; +import {context} from "esbuild"; function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { @@ -32,6 +34,18 @@ export const underline: EditorButtonDefinition = buildFormatButton('Underline', export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; +function colorAction(context: EditorUiContext, property: string, color: string): void { + context.editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[property]: color || null}); + } + }); +} + +export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); +export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); + export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 63fa24c800f..b592d7c67e6 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -1,6 +1,6 @@ import { EditorFormDefinition, - EditorFormFieldDefinition, + EditorFormFieldDefinition, EditorFormFields, EditorFormTabs, EditorSelectFormFieldDefinition } from "../../framework/forms"; @@ -17,6 +17,7 @@ import { import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; +import {colorFieldBuilder} from "../../framework/blocks/color-field"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -145,15 +146,15 @@ export const cellProperties: EditorFormDefinition = { } as EditorSelectFormFieldDefinition, ]; - const advancedFields: EditorFormFieldDefinition[] = [ + const advancedFields: EditorFormFields = [ { label: 'Border width', // inline-style: border-width name: 'border_width', type: 'text', }, borderStyleInput, // inline-style: border-style - borderColorInput, // inline-style: border-color - backgroundColorInput, // inline-style: background-color + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ @@ -210,8 +211,8 @@ export const rowProperties: EditorFormDefinition = { type: 'text', }, borderStyleInput, // style on tr: height - borderColorInput, // style on tr: height - backgroundColorInput, // style on tr: height + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ], }; @@ -305,10 +306,10 @@ export const tableProperties: EditorFormDefinition = { alignmentInput, // alignment class ]; - const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, // Style - border-style - borderColorInput, // Style - border-color - backgroundColorInput, // Style - background-color + const advancedFields: EditorFormFields = [ + borderStyleInput, + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index 61baa3c3260..b09a7530f1f 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -44,11 +44,11 @@ import { } from "./buttons/block-formats"; import { bold, clearFormating, code, - highlightColor, + highlightColor, highlightColorAction, italic, strikethrough, subscript, superscript, - textColor, + textColor, textColorAction, underline } from "./buttons/inline-formats"; import { @@ -114,10 +114,10 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorButton(italic), new EditorButton(underline), new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [ - new EditorColorPicker('color'), + new EditorColorPicker(textColorAction), ]), new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [ - new EditorColorPicker('background-color'), + new EditorColorPicker(highlightColorAction), ]), new EditorButton(strikethrough), new EditorButton(superscript), diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-field.ts b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts new file mode 100644 index 00000000000..8c8f167d9f7 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts @@ -0,0 +1,56 @@ +import {EditorContainerUiElement, EditorUiBuilderDefinition, EditorUiContext} from "../core"; +import {EditorFormField, EditorFormFieldDefinition} from "../forms"; +import {EditorColorPicker} from "./color-picker"; +import {EditorDropdownButton} from "./dropdown-button"; + +import colorDisplayIcon from "@icons/editor/color-display.svg" + +export class EditorColorField extends EditorContainerUiElement { + protected input: EditorFormField; + protected pickerButton: EditorDropdownButton; + + constructor(input: EditorFormField) { + super([]); + + this.input = input; + + this.pickerButton = new EditorDropdownButton({ + button: { icon: colorDisplayIcon, label: 'Select color'} + }, [ + new EditorColorPicker(this.onColorSelect.bind(this)) + ]); + this.addChildren(this.pickerButton, this.input); + } + + protected buildDOM(): HTMLElement { + const dom = this.input.getDOMElement(); + dom.append(this.pickerButton.getDOMElement()); + dom.classList.add('editor-color-field-container'); + + const field = dom.querySelector('input') as HTMLInputElement; + field.addEventListener('change', () => { + this.setIconColor(field.value); + }); + + return dom; + } + + onColorSelect(color: string, context: EditorUiContext): void { + this.input.setValue(color); + } + + setIconColor(color: string) { + const icon = this.getDOMElement().querySelector('svg .editor-icon-color-display'); + if (icon) { + icon.setAttribute('fill', color || 'url(#pattern2)'); + } + } +} + +export function colorFieldBuilder(field: EditorFormFieldDefinition): EditorUiBuilderDefinition { + return { + build() { + return new EditorColorField(new EditorFormField(field)); + } + } +} \ 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 index 65623e1b21d..c742ddc7723 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -1,6 +1,4 @@ -import {EditorUiElement} from "../core"; -import {$getSelection} from "lexical"; -import {$patchStyleText} from "@lexical/selection"; +import {EditorUiContext, EditorUiElement} from "../core"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; @@ -38,13 +36,15 @@ const colorChoices = [ const storageKey = 'bs-lexical-custom-colors'; +export type EditorColorPickerCallback = (color: string, context: EditorUiContext) => void; + export class EditorColorPicker extends EditorUiElement { - protected styleProperty: string; + protected callback: EditorColorPickerCallback; - constructor(styleProperty: string) { + constructor(callback: EditorColorPickerCallback) { super(); - this.styleProperty = styleProperty; + this.callback = callback; } buildDOM(): HTMLElement { @@ -131,11 +131,6 @@ export class EditorColorPicker extends EditorUiElement { } setColor(color: string) { - this.getContext().editor.update(() => { - const selection = $getSelection(); - if (selection) { - $patchStyleText(selection, {[this.styleProperty]: color || null}); - } - }); + this.callback(color, this.getContext()); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts index f88b22c3f05..880238a9a4c 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -44,7 +44,6 @@ export class LinkField extends EditorContainerUiElement { updateFormFromHeader(header: HeadingNode) { this.getHeaderIdAndText(header).then(({id, text}) => { - console.log('updating form', id, text); const modal = this.getContext().manager.getActiveModal('link'); if (modal) { modal.getForm().setValues({ @@ -60,7 +59,6 @@ export class LinkField extends EditorContainerUiElement { 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); diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 36371e30238..771ab0bdfe5 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -19,15 +19,17 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti valuesByLabel: Record } +export type EditorFormFields = (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + interface EditorFormTabDefinition { label: string; - contents: EditorFormFieldDefinition[]; + contents: EditorFormFields; } export interface EditorFormDefinition { submitText: string; action: (formData: FormData, context: EditorUiContext) => Promise; - fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + fields: EditorFormFields; } export class EditorFormField extends EditorUiElement { @@ -41,6 +43,7 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; input.value = value; + input.dispatchEvent(new Event('change')); } getName(): string { @@ -155,11 +158,17 @@ export class EditorForm extends EditorContainerUiElement { export class EditorFormTab extends EditorContainerUiElement { protected definition: EditorFormTabDefinition; - protected fields: EditorFormField[]; + protected fields: EditorUiElement[]; protected id: string; constructor(definition: EditorFormTabDefinition) { - const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef)); + const fields = definition.contents.map(fieldDef => { + if (isUiBuilderDefinition(fieldDef)) { + return fieldDef.build(); + } + return new EditorFormField(fieldDef) + }); + super(fields); this.definition = definition; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 2446c141670..9f7694e858c 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -649,6 +649,16 @@ textarea.editor-form-field-input { width: $inputWidth - 40px; } } +.editor-color-field-container { + position: relative; + input { + padding-left: 36px; + } + .editor-dropdown-menu-container { + position: absolute; + bottom: 0; + } +} // Editor theme styles .editor-theme-bold { From da82e70ca3cdd075f7ae148cb2f58fddb0d93627 Mon Sep 17 00:00:00 2001 From: Talstra Ruben SRSNL Date: Mon, 20 Jan 2025 17:21:46 +0100 Subject: [PATCH 295/800] =?UTF-8?q?Add=20optional=20OIDC=20avatar=20fetchi?= =?UTF-8?q?ng=20from=20the=20=E2=80=9Cpicture=E2=80=9D=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Access/Oidc/OidcService.php | 8 ++++++- app/Access/Oidc/OidcUserDetails.php | 2 ++ app/Config/oidc.php | 3 +++ app/Uploads/UserAvatars.php | 37 ++++++++++++++++++++++++++--- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index 7c1760649b5..660885e8b2a 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -11,6 +11,7 @@ use BookStack\Facades\Theme; use BookStack\Http\HttpRequestService; use BookStack\Theming\ThemeEvents; +use BookStack\Uploads\UserAvatars; use BookStack\Users\Models\User; use Illuminate\Support\Facades\Cache; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; @@ -26,7 +27,8 @@ public function __construct( protected RegistrationService $registrationService, protected LoginService $loginService, protected HttpRequestService $http, - protected GroupSyncService $groupService + protected GroupSyncService $groupService, + protected UserAvatars $userAvatars ) { } @@ -227,6 +229,10 @@ protected function processAccessTokenCallback(OidcAccessToken $accessToken, Oidc $this->loginService->login($user, 'oidc'); + if ($this->config()['fetch_avatars'] && $userDetails->picture) { + $this->userAvatars->assignToUserFromUrl($user, $userDetails->picture, $accessToken->getToken()); + } + return $user; } diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php index fae20de0b62..10595d1e0dd 100644 --- a/app/Access/Oidc/OidcUserDetails.php +++ b/app/Access/Oidc/OidcUserDetails.php @@ -11,6 +11,7 @@ public function __construct( public ?string $email = null, public ?string $name = null, public ?array $groups = null, + public ?string $picture = null, ) { } @@ -40,6 +41,7 @@ public function populate( $this->email = $claims->getClaim('email') ?? $this->email; $this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name; $this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups; + $this->picture = $claims->getClaim('picture') ?: $this->picture; } protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 8b5470931d0..62f19a119c0 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -54,4 +54,7 @@ 'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), // When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups. 'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false), + + // When enabled, BookStack will fetch the user’s avatar from the 'picture' claim (SSRF risk if URLs are untrusted). + 'fetch_avatars' => env('OIDC_FETCH_AVATARS', false), ]; diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php index c623247352b..af91dfe70f1 100644 --- a/app/Uploads/UserAvatars.php +++ b/app/Uploads/UserAvatars.php @@ -53,6 +53,31 @@ public function assignToUserFromExistingData(User $user, string $imageData, stri } } + /** + * Assign a new avatar image to the given user by fetching from a remote URL. + */ + public function assignToUserFromUrl(User $user, string $avatarUrl, ?string $accessToken = null): void + { + // Quickly skip invalid or non-HTTP URLs + if (!$avatarUrl || !str_starts_with($avatarUrl, 'http')) { + return; + } + + try { + $this->destroyAllForUser($user); + $imageData = $this->getAvatarImageData($avatarUrl, $accessToken); + $avatar = $this->createAvatarImageFromData($user, $imageData, 'png'); + $user->avatar()->associate($avatar); + $user->save(); + } catch (Exception $e) { + Log::error('Failed to save user avatar image from URL', [ + 'exception' => $e, + 'url' => $avatarUrl, + 'user_id' => $user->id, + ]); + } + } + /** * Destroy all user avatars uploaded to the given user. */ @@ -105,15 +130,21 @@ protected function createAvatarImageFromData(User $user, string $imageData, stri } /** - * Gets an image from url and returns it as a string of image data. + * Gets an image from a URL (public or private) and returns it as a string of image data. * * @throws HttpFetchException */ - protected function getAvatarImageData(string $url): string + protected function getAvatarImageData(string $url, ?string $accessToken = null): string { try { + $headers = []; + if (!empty($accessToken)) { + $headers['Authorization'] = 'Bearer ' . $accessToken; + } + $client = $this->http->buildClient(5); - $response = $client->sendRequest(new Request('GET', $url)); + $response = $client->sendRequest(new Request('GET', $url, $headers)); + if ($response->getStatusCode() !== 200) { throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url])); } From 8a66365d48f8c1b4a8926dd632fe0fb1868cdc43 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Jan 2025 12:54:13 +0000 Subject: [PATCH 296/800] Lexical: Added support for table caption nodes Needs linking up to the table form still. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 10 +++ .../wysiwyg/lexical/core/LexicalReconciler.ts | 21 ++++-- .../lexical/table/LexicalCaptionNode.ts | 74 +++++++++++++++++++ .../wysiwyg/lexical/table/LexicalTableNode.ts | 2 + resources/js/wysiwyg/nodes.ts | 2 + 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index a6c9b6023e5..163bb8c31c8 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -1165,6 +1165,16 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** + * Insert the DOM of this node into that of the parent. + * Allows this node to implement custom DOM attachment logic. + * Boolean result indicates if the insertion was handled by the function. + * A true return value prevents default insertion logic from taking place. + */ + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + return false; + } } function errorOnTypeKlassMismatch( diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index fccf1ae23a8..297e96ce018 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -171,16 +171,21 @@ function $createNode( } 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); + const inserted = node?.insertDOMIntoParent(dom, parentDOM); + + if (!inserted) { + if (insertDOM != null) { + parentDOM.insertBefore(dom, insertDOM); } else { - parentDOM.appendChild(dom); + // @ts-expect-error: internal field + const possibleLineBreak = parentDOM.__lexicalLineBreak; + + if (possibleLineBreak != null) { + parentDOM.insertBefore(dom, possibleLineBreak); + } else { + parentDOM.appendChild(dom); + } } } } diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts new file mode 100644 index 00000000000..08c6870e69f --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -0,0 +1,74 @@ +import { + DOMConversionMap, + DOMExportOutput, + EditorConfig, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode +} from "lexical"; + + +export class CaptionNode extends ElementNode { + static getType(): string { + return 'caption'; + } + + static clone(node: CaptionNode): CaptionNode { + return new CaptionNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + return document.createElement('caption'); + } + + updateDOM(_prevNode: unknown, _dom: HTMLElement, _config: EditorConfig): boolean { + return false; + } + + isParentRequired(): true { + return true; + } + + canBeEmpty(): boolean { + return false; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'caption', + version: 1, + }; + } + + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + parentDOM.insertBefore(nodeDOM, parentDOM.firstChild); + return true; + } + + static importJSON(serializedNode: SerializedElementNode): CaptionNode { + return $createCaptionNode(); + } + + static importDOM(): DOMConversionMap | null { + return { + caption: (node: Node) => ({ + conversion(domNode: Node) { + return { + node: $createCaptionNode(), + } + }, + priority: 0, + }), + }; + } +} + +export function $createCaptionNode(): CaptionNode { + return new CaptionNode(); +} + +export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { + return node instanceof CaptionNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 9443747a6f7..a103614753e 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -139,6 +139,8 @@ export class TableNode extends CommonBlockNode { for (const child of Array.from(tableElement.children)) { if (child.nodeName === 'TR') { tBody.append(child); + } else if (child.nodeName === 'CAPTION') { + newElement.insertBefore(child, newElement.firstChild); } else { newElement.append(child); } diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index 8a47f322d6d..c1db0f0869f 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -18,6 +18,7 @@ import {EditorUiContext} from "./ui/framework/core"; import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; /** * Load the nodes for lexical. @@ -32,6 +33,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableNode, TableRowNode, TableCellNode, + CaptionNode, ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, From 958b537a49c442699ec1834d437ce55c8db6394a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 22 Jan 2025 20:39:15 +0000 Subject: [PATCH 297/800] Lexical: Linked table form to have caption toggle option --- .../lexical/table/LexicalCaptionNode.ts | 18 +++++++++++++++++ resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/forms/tables.ts | 20 +++++++++++++++---- resources/js/wysiwyg/ui/framework/forms.ts | 10 ++++++++-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts index 08c6870e69f..d9d83562c29 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -1,4 +1,5 @@ import { + $createTextNode, DOMConversionMap, DOMExportOutput, EditorConfig, @@ -7,6 +8,7 @@ import { LexicalNode, SerializedElementNode } from "lexical"; +import {TableNode} from "@lexical/table/LexicalTableNode"; export class CaptionNode extends ElementNode { @@ -71,4 +73,20 @@ export function $createCaptionNode(): CaptionNode { export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { return node instanceof CaptionNode; +} + +export function $tableHasCaption(table: TableNode): boolean { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + return true; + } + } + return false; +} + +export function $addCaptionToTable(table: TableNode, text: string = ''): void { + const caption = $createCaptionNode(); + const textNode = $createTextNode(text || ' '); + caption.append(textNode); + table.append(caption); } \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 695e8cb69ed..1d42ba3e447 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ ## Secondary Todo -- 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 diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index b592d7c67e6..5b484310d9a 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -18,6 +18,7 @@ import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; import {colorFieldBuilder} from "../../framework/blocks/color-field"; +import {$addCaptionToTable, $isCaptionNode, $tableHasCaption} from "@lexical/table/LexicalCaptionNode"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -219,6 +220,7 @@ export const rowProperties: EditorFormDefinition = { export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal { const styles = table.getStyles(); const modalForm = context.manager.createModal('table_properties'); + modalForm.show({ width: styles.get('width') || '', height: styles.get('height') || '', @@ -228,7 +230,7 @@ export function $showTablePropertiesForm(table: TableNode, context: EditorUiCont border_style: styles.get('border-style') || '', border_color: styles.get('border-color') || '', background_color: styles.get('background-color') || '', - // caption: '', TODO + caption: $tableHasCaption(table) ? 'true' : '', align: table.getAlignment(), }); return modalForm; @@ -265,7 +267,17 @@ export const tableProperties: EditorFormDefinition = { }); } - // TODO - cell caption + const showCaption = Boolean(formData.get('caption')?.toString() || ''); + const hasCaption = $tableHasCaption(table); + if (showCaption && !hasCaption) { + $addCaptionToTable(table, context.translate('Caption')); + } else if (!showCaption && hasCaption) { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + child.remove(); + } + } + } }); return true; }, @@ -299,9 +311,9 @@ export const tableProperties: EditorFormDefinition = { type: 'text', }, { - label: 'caption', // Caption element + label: 'Show caption', // Caption element name: 'caption', - type: 'text', // TODO - + type: 'checkbox', }, alignmentInput, // alignment class ]; diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 771ab0bdfe5..08edb214e2a 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -11,7 +11,7 @@ import {el} from "../../utils/dom"; export interface EditorFormFieldDefinition { label: string; name: string; - type: 'text' | 'select' | 'textarea'; + type: 'text' | 'select' | 'textarea' | 'checkbox'; } export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { @@ -42,7 +42,11 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; - input.value = value; + if (this.definition.type === 'checkbox') { + input.checked = Boolean(value); + } else { + input.value = value; + } input.dispatchEvent(new Event('change')); } @@ -61,6 +65,8 @@ export class EditorFormField extends EditorUiElement { 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 if (this.definition.type === 'checkbox') { + input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'}); } else { input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); } From d89a2fdb150880bf98292bff3e16083179709ffb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jan 2025 14:28:27 +0000 Subject: [PATCH 298/800] Lexical: Added media src conversions Only actuall added YT in the end. Google had changed URL scheme, and Vimeo seems to just be something else now, can't really browse video pages like before. --- .../lexical/rich-text/LexicalMediaNode.ts | 49 ++++++++++++++++++- resources/js/wysiwyg/todo.md | 1 - .../js/wysiwyg/ui/defaults/buttons/objects.ts | 17 +------ .../js/wysiwyg/ui/defaults/forms/objects.ts | 34 +++++++++++-- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts index a675665ac14..81fb96a936b 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts @@ -16,6 +16,7 @@ import { } from "lexical/nodes/common"; import {$selectSingleNode} from "../../utils/selection"; import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; +import * as url from "node:url"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -343,11 +344,55 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { return domElementToNode(tag as MediaNodeTag, el); } +interface UrlPattern { + readonly regex: RegExp; + readonly w: number; + readonly h: number; + readonly url: string; +} + +/** + * These patterns originate from the tinymce/tinymce project. + * https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts + * License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. + * License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT + */ +const urlPatterns: UrlPattern[] = [ + { + regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, + { + regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$2?$4', + }, + { + regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, +]; + 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 { + + for (const pattern of urlPatterns) { + const match = src.match(pattern.regex); + if (match) { + const newSrc = src.replace(pattern.regex, pattern.url); + const node = new MediaNode('iframe'); + node.setSrc(newSrc); + node.setHeight(pattern.h); + node.setWidth(pattern.w); + return node; + } + } + let nodeTag: MediaNodeTag = 'iframe'; const srcEnd = src.split('?')[0].split('/').pop() || ''; const srcEndSplit = srcEnd.split('.'); @@ -360,7 +405,9 @@ export function $createMediaNodeFromSrc(src: string): MediaNode { nodeTag = 'embed'; } - return new MediaNode(nodeTag); + const node = new MediaNode(nodeTag); + node.setSrc(src); + return node; } export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode { diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 1d42ba3e447..94ae0e144ba 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -10,7 +10,6 @@ ## Secondary Todo -- 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 ## Bugs diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 6612c0dc451..63df4fea8cd 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -32,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -168,24 +168,11 @@ 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); + $showMediaForm(selectedNode, 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 21d333c3aa2..0effdc1715e 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -186,6 +186,23 @@ export const link: EditorFormDefinition = { ], }; +export function $showMediaForm(media: MediaNode|null, context: EditorUiContext): void { + const mediaModal = context.manager.createModal('media'); + + let formDefaults = {}; + if (media) { + const nodeAttrs = media.getAttributes(); + formDefaults = { + src: nodeAttrs.src || nodeAttrs.data || '', + width: nodeAttrs.width, + height: nodeAttrs.height, + embed: '', + } + } + + mediaModal.show(formDefaults); +} + export const media: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { @@ -215,12 +232,19 @@ export const media: EditorFormDefinition = { const height = (formData.get('height') || '').toString().trim(); const width = (formData.get('width') || '').toString().trim(); - const updateNode = selectedNode || $createMediaNodeFromSrc(src); - updateNode.setSrc(src); - updateNode.setWidthAndHeight(width, height); - if (!selectedNode) { - $insertNodes([updateNode]); + // Update existing + if (selectedNode) { + selectedNode.setSrc(src); + selectedNode.setWidthAndHeight(width, height); + return; + } + + // Insert new + const node = $createMediaNodeFromSrc(src); + if (width || height) { + node.setWidthAndHeight(width, height); } + $insertNodes([node]); }); return true; From 7e03a973d88999f1e22b601d0b2f6c947d0bd5fc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 27 Jan 2025 16:40:41 +0000 Subject: [PATCH 299/800] Lexical: Ran a deeper check on translation use --- lang/en/editor.php | 2 ++ resources/js/wysiwyg/todo.md | 17 ----------------- .../js/wysiwyg/ui/defaults/buttons/controls.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 2 +- .../wysiwyg/ui/framework/blocks/color-picker.ts | 4 ++-- 5 files changed, 6 insertions(+), 21 deletions(-) delete mode 100644 resources/js/wysiwyg/todo.md diff --git a/lang/en/editor.php b/lang/en/editor.php index a61b460427e..752c6f3f702 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -13,6 +13,7 @@ 'cancel' => 'Cancel', 'save' => 'Save', 'close' => 'Close', + 'apply' => 'Apply', 'undo' => 'Undo', 'redo' => 'Redo', 'left' => 'Left', @@ -147,6 +148,7 @@ 'url' => 'URL', 'text_to_display' => 'Text to display', 'title' => 'Title', + 'browse_links' => 'Browse links', 'open_link' => 'Open link', 'open_link_in' => 'Open link in...', 'open_link_current' => 'Current window', diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md deleted file mode 100644 index 94ae0e144ba..00000000000 --- a/resources/js/wysiwyg/todo.md +++ /dev/null @@ -1,17 +0,0 @@ -# Lexical based editor todo - -## In progress - -// - -## Main Todo - -// - -## Secondary Todo - -- Deep check of translation coverage - -## Bugs - -// \ 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 5e32005393e..6c22d3faaf8 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -57,7 +57,7 @@ export const redo: EditorButtonDefinition = { export const source: EditorButtonDefinition = { - label: 'Source', + label: 'Source code', icon: sourceIcon, async action(context: EditorUiContext) { const modal = context.manager.createModal('source'); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 63df4fea8cd..4eb4c5a4e68 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -165,7 +165,7 @@ export const diagramManager: EditorButtonDefinition = { }; export const media: EditorButtonDefinition = { - label: 'Insert/edit Media', + label: 'Insert/edit media', icon: mediaIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index c742ddc7723..8e62a0e5e7d 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -63,7 +63,7 @@ export class EditorColorPicker extends EditorUiElement { const removeButton = el('div', { class: 'editor-color-select-option', 'data-color': '', - title: 'Clear color', + title: this.getContext().translate('Remove color'), }, []); removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); @@ -72,7 +72,7 @@ export class EditorColorPicker extends EditorUiElement { class: 'editor-color-select-option', for: `color-select-${id}`, 'data-color': '', - title: 'Custom color', + title: this.getContext().translate('Custom color'), }, []); selectButton.innerHTML = selectIcon; colorOptions.push(selectButton); From ac0cd9995d8b420e33e392ba82d40bde8df94205 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 16:40:11 +0000 Subject: [PATCH 300/800] Sorting: Reorganised book sort code to its own directory --- app/Entities/Tools/BookContents.php | 209 +--------------- .../BookSortController.php | 8 +- .../Tools => Sorting}/BookSortMap.php | 2 +- .../Tools => Sorting}/BookSortMapItem.php | 2 +- app/Sorting/BookSorter.php | 226 ++++++++++++++++++ routes/web.php | 7 +- 6 files changed, 237 insertions(+), 217 deletions(-) rename app/{Entities/Controllers => Sorting}/BookSortController.php (88%) rename app/{Entities/Tools => Sorting}/BookSortMap.php (96%) rename app/{Entities/Tools => Sorting}/BookSortMapItem.php (94%) create mode 100644 app/Sorting/BookSorter.php diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7fa2134b7fa..7dd3f3e11ad 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -8,6 +8,8 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Sorting\BookSortMap; +use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -103,211 +105,4 @@ protected function getPages(bool $showDrafts = false, bool $getPageContent = fal return $query->where('book_id', '=', $this->book->id)->get(); } - - /** - * Sort the books content using the given sort map. - * Returns a list of books that were involved in the operation. - * - * @returns Book[] - */ - public function sortUsingMap(BookSortMap $sortMap): array - { - // Load models into map - $modelMap = $this->loadModelsFromSortMap($sortMap); - - // Sort our changes from our map to be chapters first - // Since they need to be process to ensure book alignment for child page changes. - $sortMapItems = $sortMap->all(); - usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { - $aScore = $itemA->type === 'page' ? 2 : 1; - $bScore = $itemB->type === 'page' ? 2 : 1; - - return $aScore - $bScore; - }); - - // Perform the sort - foreach ($sortMapItems as $item) { - $this->applySortUpdates($item, $modelMap); - } - - /** @var Book[] $booksInvolved */ - $booksInvolved = array_values(array_filter($modelMap, function (string $key) { - return str_starts_with($key, 'book:'); - }, ARRAY_FILTER_USE_KEY)); - - // Update permissions of books involved - foreach ($booksInvolved as $book) { - $book->rebuildPermissions(); - } - - return $booksInvolved; - } - - /** - * Using the given sort map item, detect changes for the related model - * and update it if required. Changes where permissions are lacking will - * be skipped and not throw an error. - * - * @param array $modelMap - */ - protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void - { - /** @var BookChild $model */ - $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; - if (!$model) { - return; - } - - $priorityChanged = $model->priority !== $sortMapItem->sort; - $bookChanged = $model->book_id !== $sortMapItem->parentBookId; - $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; - - // Stop if there's no change - if (!$priorityChanged && !$bookChanged && !$chapterChanged) { - return; - } - - $currentParentKey = 'book:' . $model->book_id; - if ($model instanceof Page && $model->chapter_id) { - $currentParentKey = 'chapter:' . $model->chapter_id; - } - - $currentParent = $modelMap[$currentParentKey] ?? null; - /** @var Book $newBook */ - $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; - /** @var ?Chapter $newChapter */ - $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; - - if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { - return; - } - - // Action the required changes - if ($bookChanged) { - $model->changeBook($newBook->id); - } - - if ($model instanceof Page && $chapterChanged) { - $model->chapter_id = $newChapter->id ?? 0; - } - - if ($priorityChanged) { - $model->priority = $sortMapItem->sort; - } - - if ($chapterChanged || $priorityChanged) { - $model->save(); - } - } - - /** - * Check if the current user has permissions to apply the given sorting change. - * Is quite complex since items can gain a different parent change. Acts as a: - * - Update of old parent element (Change of content/order). - * - Update of sorted/moved element. - * - Deletion of element (Relative to parent upon move). - * - Creation of element within parent (Upon move to new parent). - */ - protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool - { - // Stop if we can't see the current parent or new book. - if (!$currentParent || !$newBook) { - return false; - } - - $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); - if ($model instanceof Chapter) { - $hasPermission = userCan('book-update', $currentParent) - && userCan('book-update', $newBook) - && userCan('chapter-update', $model) - && (!$hasNewParent || userCan('chapter-create', $newBook)) - && (!$hasNewParent || userCan('chapter-delete', $model)); - - if (!$hasPermission) { - return false; - } - } - - if ($model instanceof Page) { - $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; - $hasCurrentParentPermission = userCan($parentPermission, $currentParent); - - // This needs to check if there was an intended chapter location in the original sort map - // rather than inferring from the $newChapter since that variable may be null - // due to other reasons (Visibility). - $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; - if (!$newParent) { - return false; - } - - $hasPageEditPermission = userCan('page-update', $model); - $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); - $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; - $hasNewParentPermission = userCan($newParentPermission, $newParent); - - $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); - $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); - - $hasPermission = $hasCurrentParentPermission - && $newParentInRightLocation - && $hasNewParentPermission - && $hasPageEditPermission - && $hasDeletePermissionIfMoving - && $hasCreatePermissionIfMoving; - - if (!$hasPermission) { - return false; - } - } - - return true; - } - - /** - * Load models from the database into the given sort map. - * - * @return array - */ - protected function loadModelsFromSortMap(BookSortMap $sortMap): array - { - $modelMap = []; - $ids = [ - 'chapter' => [], - 'page' => [], - 'book' => [], - ]; - - foreach ($sortMap->all() as $sortMapItem) { - $ids[$sortMapItem->type][] = $sortMapItem->id; - $ids['book'][] = $sortMapItem->parentBookId; - if ($sortMapItem->parentChapterId) { - $ids['chapter'][] = $sortMapItem->parentChapterId; - } - } - - $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); - /** @var Page $page */ - foreach ($pages as $page) { - $modelMap['page:' . $page->id] = $page; - $ids['book'][] = $page->book_id; - if ($page->chapter_id) { - $ids['chapter'][] = $page->chapter_id; - } - } - - $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); - /** @var Chapter $chapter */ - foreach ($chapters as $chapter) { - $modelMap['chapter:' . $chapter->id] = $chapter; - $ids['book'][] = $chapter->book_id; - } - - $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); - /** @var Book $book */ - foreach ($books as $book) { - $modelMap['book:' . $book->id] = $book; - } - - return $modelMap; - } } diff --git a/app/Entities/Controllers/BookSortController.php b/app/Sorting/BookSortController.php similarity index 88% rename from app/Entities/Controllers/BookSortController.php rename to app/Sorting/BookSortController.php index 5aefc583279..feed5db4fde 100644 --- a/app/Entities/Controllers/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -1,11 +1,10 @@ queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); @@ -58,8 +57,7 @@ public function update(Request $request, string $bookSlug) } $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $bookContents = new BookContents($book); - $booksInvolved = $bookContents->sortUsingMap($sortMap); + $booksInvolved = $sorter->sortUsingMap($sortMap); // Rebuild permissions and add activity for involved books. foreach ($booksInvolved as $bookInvolved) { diff --git a/app/Entities/Tools/BookSortMap.php b/app/Sorting/BookSortMap.php similarity index 96% rename from app/Entities/Tools/BookSortMap.php rename to app/Sorting/BookSortMap.php index ff1ec767f78..96c9d342a27 100644 --- a/app/Entities/Tools/BookSortMap.php +++ b/app/Sorting/BookSortMap.php @@ -1,6 +1,6 @@ loadModelsFromSortMap($sortMap); + + // Sort our changes from our map to be chapters first + // Since they need to be process to ensure book alignment for child page changes. + $sortMapItems = $sortMap->all(); + usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) { + $aScore = $itemA->type === 'page' ? 2 : 1; + $bScore = $itemB->type === 'page' ? 2 : 1; + + return $aScore - $bScore; + }); + + // Perform the sort + foreach ($sortMapItems as $item) { + $this->applySortUpdates($item, $modelMap); + } + + /** @var Book[] $booksInvolved */ + $booksInvolved = array_values(array_filter($modelMap, function (string $key) { + return str_starts_with($key, 'book:'); + }, ARRAY_FILTER_USE_KEY)); + + // Update permissions of books involved + foreach ($booksInvolved as $book) { + $book->rebuildPermissions(); + } + + return $booksInvolved; + } + + /** + * Using the given sort map item, detect changes for the related model + * and update it if required. Changes where permissions are lacking will + * be skipped and not throw an error. + * + * @param array $modelMap + */ + protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void + { + /** @var BookChild $model */ + $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null; + if (!$model) { + return; + } + + $priorityChanged = $model->priority !== $sortMapItem->sort; + $bookChanged = $model->book_id !== $sortMapItem->parentBookId; + $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId; + + // Stop if there's no change + if (!$priorityChanged && !$bookChanged && !$chapterChanged) { + return; + } + + $currentParentKey = 'book:' . $model->book_id; + if ($model instanceof Page && $model->chapter_id) { + $currentParentKey = 'chapter:' . $model->chapter_id; + } + + $currentParent = $modelMap[$currentParentKey] ?? null; + /** @var Book $newBook */ + $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null; + /** @var ?Chapter $newChapter */ + $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null; + + if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) { + return; + } + + // Action the required changes + if ($bookChanged) { + $model->changeBook($newBook->id); + } + + if ($model instanceof Page && $chapterChanged) { + $model->chapter_id = $newChapter->id ?? 0; + } + + if ($priorityChanged) { + $model->priority = $sortMapItem->sort; + } + + if ($chapterChanged || $priorityChanged) { + $model->save(); + } + } + + /** + * Check if the current user has permissions to apply the given sorting change. + * Is quite complex since items can gain a different parent change. Acts as a: + * - Update of old parent element (Change of content/order). + * - Update of sorted/moved element. + * - Deletion of element (Relative to parent upon move). + * - Creation of element within parent (Upon move to new parent). + */ + protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool + { + // Stop if we can't see the current parent or new book. + if (!$currentParent || !$newBook) { + return false; + } + + $hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0)); + if ($model instanceof Chapter) { + $hasPermission = userCan('book-update', $currentParent) + && userCan('book-update', $newBook) + && userCan('chapter-update', $model) + && (!$hasNewParent || userCan('chapter-create', $newBook)) + && (!$hasNewParent || userCan('chapter-delete', $model)); + + if (!$hasPermission) { + return false; + } + } + + if ($model instanceof Page) { + $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasCurrentParentPermission = userCan($parentPermission, $currentParent); + + // This needs to check if there was an intended chapter location in the original sort map + // rather than inferring from the $newChapter since that variable may be null + // due to other reasons (Visibility). + $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook; + if (!$newParent) { + return false; + } + + $hasPageEditPermission = userCan('page-update', $model); + $newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id)); + $newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update'; + $hasNewParentPermission = userCan($newParentPermission, $newParent); + + $hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model)); + $hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent)); + + $hasPermission = $hasCurrentParentPermission + && $newParentInRightLocation + && $hasNewParentPermission + && $hasPageEditPermission + && $hasDeletePermissionIfMoving + && $hasCreatePermissionIfMoving; + + if (!$hasPermission) { + return false; + } + } + + return true; + } + + /** + * Load models from the database into the given sort map. + * + * @return array + */ + protected function loadModelsFromSortMap(BookSortMap $sortMap): array + { + $modelMap = []; + $ids = [ + 'chapter' => [], + 'page' => [], + 'book' => [], + ]; + + foreach ($sortMap->all() as $sortMapItem) { + $ids[$sortMapItem->type][] = $sortMapItem->id; + $ids['book'][] = $sortMapItem->parentBookId; + if ($sortMapItem->parentChapterId) { + $ids['chapter'][] = $sortMapItem->parentChapterId; + } + } + + $pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get(); + /** @var Page $page */ + foreach ($pages as $page) { + $modelMap['page:' . $page->id] = $page; + $ids['book'][] = $page->book_id; + if ($page->chapter_id) { + $ids['chapter'][] = $page->chapter_id; + } + } + + $chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get(); + /** @var Chapter $chapter */ + foreach ($chapters as $chapter) { + $modelMap['chapter:' . $chapter->id] = $chapter; + $ids['book'][] = $chapter->book_id; + } + + $books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get(); + /** @var Book $book */ + foreach ($books as $book) { + $modelMap['book:' . $book->id] = $book; + } + + return $modelMap; + } +} diff --git a/routes/web.php b/routes/web.php index 5bb9622e737..e1e819dd03e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; +use BookStack\Sorting\BookSortController; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -66,7 +67,7 @@ Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [EntityControllers\BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -74,8 +75,8 @@ Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); From 5b0cb3dd506c108b5d5d13c5c07c4f02e6107608 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 17:02:34 +0000 Subject: [PATCH 301/800] Sorting: Extracted URL sort helper to own class Was only used in one place, so didn't make sense to have extra global helper clutter. --- .../Controllers/AuditLogController.php | 2 + app/App/helpers.php | 32 ------------ app/Sorting/SortUrl.php | 49 +++++++++++++++++++ resources/views/settings/audit.blade.php | 4 +- 4 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 app/Sorting/SortUrl.php diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index 641106d7f45..66ca301977c 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -5,6 +5,7 @@ use BookStack\Activity\ActivityType; use BookStack\Activity\Models\Activity; use BookStack\Http\Controller; +use BookStack\Sorting\SortUrl; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -65,6 +66,7 @@ public function index(Request $request) 'filters' => $filters, 'listOptions' => $listOptions, 'activityTypes' => $types, + 'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page'))) ]); } } diff --git a/app/App/helpers.php b/app/App/helpers.php index 941c267d6cd..204b3f06a72 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -96,35 +96,3 @@ function theme_path(string $path = ''): ?string return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path)); } - -/** - * Generate a URL with multiple parameters for sorting purposes. - * Works out the logic to set the correct sorting direction - * Discards empty parameters and allows overriding. - */ -function sortUrl(string $path, array $data, array $overrideData = []): string -{ - $queryStringSections = []; - $queryData = array_merge($data, $overrideData); - - // Change sorting direction is already sorted on current attribute - if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) { - $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc'; - } elseif (isset($overrideData['sort'])) { - $queryData['order'] = 'asc'; - } - - foreach ($queryData as $name => $value) { - $trimmedVal = trim($value); - if ($trimmedVal === '') { - continue; - } - $queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal); - } - - if (count($queryStringSections) === 0) { - return url($path); - } - - return url($path . '?' . implode('&', $queryStringSections)); -} diff --git a/app/Sorting/SortUrl.php b/app/Sorting/SortUrl.php new file mode 100644 index 00000000000..f01df2c36a0 --- /dev/null +++ b/app/Sorting/SortUrl.php @@ -0,0 +1,49 @@ +path, $this->data, $overrideData); + } + + public function build(): string + { + $queryStringSections = []; + $queryData = array_merge($this->data, $this->overrideData); + + // Change sorting direction if already sorted on current attribute + if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) { + $queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc'; + } elseif (isset($this->overrideData['sort'])) { + $queryData['order'] = 'asc'; + } + + foreach ($queryData as $name => $value) { + $trimmedVal = trim($value); + if ($trimmedVal !== '') { + $queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal); + } + } + + if (count($queryStringSections) === 0) { + return url($this->path); + } + + return url($this->path . '?' . implode('&', $queryStringSections)); + } +} diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 28cdeb8a5a7..8e477668040 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -26,11 +26,11 @@ class="flex-container-row wrap justify-flex-start gap-x-m gap-y-xs"> class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }} From b2ac3e0834172e2eaf70d4c893c90704b5aa9bf8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 29 Jan 2025 17:34:07 +0000 Subject: [PATCH 302/800] Sorting: Added SortSet model & migration --- app/Sorting/SortSetOption.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/Sorting/SortSetOption.php diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOption.php new file mode 100644 index 00000000000..0a78e99c7eb --- /dev/null +++ b/app/Sorting/SortSetOption.php @@ -0,0 +1,16 @@ + Date: Thu, 30 Jan 2025 17:49:19 +0000 Subject: [PATCH 303/800] Sorting: Added content misses from last commit, started settings --- app/Sorting/SortSet.php | 35 +++++++++++++ ...25_01_29_180933_create_sort_sets_table.php | 29 +++++++++++ lang/en/settings.php | 7 +++ .../settings/categories/sorting.blade.php | 49 +++++++++++++++++++ resources/views/settings/layout.blade.php | 2 + 5 files changed, 122 insertions(+) create mode 100644 app/Sorting/SortSet.php create mode 100644 database/migrations/2025_01_29_180933_create_sort_sets_table.php create mode 100644 resources/views/settings/categories/sorting.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php new file mode 100644 index 00000000000..42e1e0951c7 --- /dev/null +++ b/app/Sorting/SortSet.php @@ -0,0 +1,35 @@ +sequence); + $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + return array_filter($options); + } + + /** + * @param SortSetOption[] $options + */ + public function setOptions(array $options): void + { + $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $this->sequence = implode(',', $values); + } +} diff --git a/database/migrations/2025_01_29_180933_create_sort_sets_table.php b/database/migrations/2025_01_29_180933_create_sort_sets_table.php new file mode 100644 index 00000000000..bf9780c5b7e --- /dev/null +++ b/database/migrations/2025_01_29_180933_create_sort_sets_table.php @@ -0,0 +1,29 @@ +increments('id'); + $table->string('name'); + $table->text('sequence'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sort_sets'); + } +}; diff --git a/lang/en/settings.php b/lang/en/settings.php index c0b6b692a57..b20152bfe0b 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -74,6 +74,13 @@ 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
                                Note that users will be able to change their email addresses after successful registration.', 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + // Sorting Settings + 'sorting' => 'Sorting', + 'sorting_book_default' => 'Default Book Sort', + 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', + 'sorting_sets' => 'Sort Sets', + 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + // Maintenance settings 'maint' => 'Maintenance', 'maint_image_cleanup' => 'Cleanup Images', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php new file mode 100644 index 00000000000..153ea0e3b0b --- /dev/null +++ b/resources/views/settings/categories/sorting.blade.php @@ -0,0 +1,49 @@ +@extends('settings.layout') + +@section('card') +

                                {{ trans('settings.sorting') }}

                                +
                                + {{ csrf_field() }} + + +
                                +
                                +
                                + +

                                {{ trans('settings.sorting_book_default_desc') }}

                                +
                                +
                                + +
                                +
                                + +
                                + +
                                + +
                                +
                                +@endsection + +@section('after-card') +
                                +

                                {{ trans('settings.sorting_sets') }}

                                +

                                {{ trans('settings.sorting_sets_desc') }}

                                +{{-- TODO--}} +
                                +@endsection \ No newline at end of file diff --git a/resources/views/settings/layout.blade.php b/resources/views/settings/layout.blade.php index a59b58d535c..930d407a508 100644 --- a/resources/views/settings/layout.blade.php +++ b/resources/views/settings/layout.blade.php @@ -13,6 +13,7 @@ @icon('star') {{ trans('settings.app_features_security') }} @icon('palette') {{ trans('settings.app_customization') }} @icon('security') {{ trans('settings.reg_settings') }} + @icon('sort') {{ trans('settings.sorting') }}
                                {{ trans('settings.system_version') }}
                                @@ -29,6 +30,7 @@
                                @yield('card')
                                + @yield('after-card')
                                From 4f5f7c10b10bfa0434cbe4c81a066cf779ca63e5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 31 Jan 2025 21:29:38 +0000 Subject: [PATCH 304/800] Thumbnails: Fixed thumnail orientation Prevents double rotation caused from both our own orientation handling upon that invervention was auto-applying since v3. Fixes #5462 --- app/Uploads/ImageResizer.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php index fa6b1cac2d4..5f095658f3f 100644 --- a/app/Uploads/ImageResizer.php +++ b/app/Uploads/ImageResizer.php @@ -158,7 +158,10 @@ public function resizeImageData( */ protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage { - $manager = new ImageManager(new Driver()); + $manager = new ImageManager( + new Driver(), + autoOrientation: false, + ); // Ensure gif images are decoded natively instead of deferring to intervention GIF // handling since we don't need the added animation support. From bf8a84a8b1dd02578e6b5e2b39882902809f112a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 3 Feb 2025 16:48:57 +0000 Subject: [PATCH 305/800] Sorting: Started sort set routes and form --- app/Sorting/SortSetController.php | 19 +++++ app/Sorting/SortSetOption.php | 28 +++++++ lang/en/settings.php | 16 ++++ .../settings/categories/sorting.blade.php | 12 ++- .../views/settings/sort-sets/create.blade.php | 24 ++++++ .../settings/sort-sets/parts/form.blade.php | 74 +++++++++++++++++++ routes/web.php | 15 +++- 7 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 app/Sorting/SortSetController.php create mode 100644 resources/views/settings/sort-sets/create.blade.php create mode 100644 resources/views/settings/sort-sets/parts/form.blade.php diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php new file mode 100644 index 00000000000..4ef14829538 --- /dev/null +++ b/app/Sorting/SortSetController.php @@ -0,0 +1,19 @@ +middleware('can:settings-manage'); + // TODO - Test + } + + public function create() + { + return view('settings.sort-sets.create'); + } +} diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOption.php index 0a78e99c7eb..bb878cf30f9 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOption.php @@ -13,4 +13,32 @@ enum SortSetOption: string case UpdateDateDesc = 'updated_date_desc'; case ChaptersFirst = 'chapters_first'; case ChaptersLast = 'chapters_last'; + + /** + * Provide a translated label string for this option. + */ + public function getLabel(): string + { + $key = $this->value; + $label = ''; + if (str_ends_with($key, '_asc')) { + $key = substr($key, 0, -4); + $label = trans('settings.sort_set_op_asc'); + } elseif (str_ends_with($key, '_desc')) { + $key = substr($key, 0, -5); + $label = trans('settings.sort_set_op_desc'); + } + + $label = trans('settings.sort_set_op_' . $key) . ' ' . $label; + return trim($label); + } + + /** + * @return SortSetOption[] + */ + public static function allExcluding(array $options): array + { + $all = SortSetOption::cases(); + return array_diff($all, $options); + } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b20152bfe0b..b29ec25338b 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -80,6 +80,22 @@ 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_sets' => 'Sort Sets', 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_set_create' => 'Create Sort Set', + 'sort_set_edit' => 'Edit Sort Set', + 'sort_set_details' => 'Sort Set Details', + 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', + 'sort_set_operations' => 'Sort Operations', + 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', + 'sort_set_available_operations' => 'Available Operations', + 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_op_asc' => '(Asc)', + 'sort_set_op_desc' => '(Desc)', + 'sort_set_op_name' => 'Name - Alphabetical', + 'sort_set_op_name_numeric' => 'Name - Numeric', + 'sort_set_op_created_date' => 'Created Date', + 'sort_set_op_updated_date' => 'Updated Date', + 'sort_set_op_chapters_first' => 'Chapters First', + 'sort_set_op_chapters_last' => 'Chapters Last', // Maintenance settings 'maint' => 'Maintenance', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 153ea0e3b0b..9de11bb6f2b 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -42,8 +42,16 @@ class="setting-list-label">{{ trans('settings.sorting_book_default') }} @section('after-card')
                                -

                                {{ trans('settings.sorting_sets') }}

                                -

                                {{ trans('settings.sorting_sets_desc') }}

                                +
                                +
                                +

                                {{ trans('settings.sorting_sets') }}

                                +

                                {{ trans('settings.sorting_sets_desc') }}

                                +
                                + +
                                + {{-- TODO--}}
                                @endsection \ No newline at end of file diff --git a/resources/views/settings/sort-sets/create.blade.php b/resources/views/settings/sort-sets/create.blade.php new file mode 100644 index 00000000000..16f2d2ac72c --- /dev/null +++ b/resources/views/settings/sort-sets/create.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.simple') + +@section('body') + +
                                + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
                                +

                                {{ trans('settings.sort_set_create') }}

                                + +
                                + {{ csrf_field() }} + @include('settings.sort-sets.parts.form', ['model' => null]) + +
                                + {{ trans('common.cancel') }} + +
                                +
                                +
                                +
                                + +@stop diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php new file mode 100644 index 00000000000..6df04a72191 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -0,0 +1,74 @@ + +
                                +
                                +
                                + +

                                {{ trans('settings.sort_set_details_desc') }}

                                +
                                +
                                +
                                + + @include('form.text', ['name' => 'name']) +
                                +
                                +
                                + +
                                + +

                                {{ trans('settings.sort_set_operations_desc') }}

                                + + + +
                                +
                                + +
                                  + @foreach(($model?->getOptions() ?? []) as $option) +
                                • +
                                  @icon('grip')
                                  +
                                  {{ $option->getLabel() }}
                                  +
                                  + + + + +
                                  +
                                • + @endforeach +
                                +
                                + +
                                + +
                                  + @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) +
                                • +
                                  @icon('grip')
                                  +
                                  {{ $option->getLabel() }}
                                  +
                                  + + + + +
                                  +
                                • + @endforeach +
                                +
                                +
                                +
                                +
                                \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index e1e819dd03e..62c120f2020 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,7 +13,7 @@ use BookStack\References\ReferenceController; use BookStack\Search\SearchController; use BookStack\Settings as SettingControllers; -use BookStack\Sorting\BookSortController; +use BookStack\Sorting as SortingControllers; use BookStack\Theming\ThemeController; use BookStack\Uploads\Controllers as UploadControllers; use BookStack\Users\Controllers as UserControllers; @@ -67,7 +67,7 @@ Route::get('/books/{slug}/edit', [EntityControllers\BookController::class, 'edit']); Route::put('/books/{slug}', [EntityControllers\BookController::class, 'update']); Route::delete('/books/{id}', [EntityControllers\BookController::class, 'destroy']); - Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']); + Route::get('/books/{slug}/sort-item', [SortingControllers\BookSortController::class, 'showItem']); Route::get('/books/{slug}', [EntityControllers\BookController::class, 'show']); Route::get('/books/{bookSlug}/permissions', [PermissionsController::class, 'showForBook']); Route::put('/books/{bookSlug}/permissions', [PermissionsController::class, 'updateForBook']); @@ -75,8 +75,8 @@ Route::get('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'showCopy']); Route::post('/books/{bookSlug}/copy', [EntityControllers\BookController::class, 'copy']); Route::post('/books/{bookSlug}/convert-to-shelf', [EntityControllers\BookController::class, 'convertToShelf']); - Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); - Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); + Route::get('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'show']); + Route::put('/books/{bookSlug}/sort', [SortingControllers\BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); @@ -295,6 +295,13 @@ Route::get('/settings/webhooks/{id}/delete', [ActivityControllers\WebhookController::class, 'delete']); Route::delete('/settings/webhooks/{id}', [ActivityControllers\WebhookController::class, 'destroy']); + // Sort Sets + Route::get('/settings/sorting/sets/new', [SortingControllers\SortSetController::class, 'create']); + Route::post('/settings/sorting/sets', [SortingControllers\SortSetController::class, 'store']); + Route::get('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'edit']); + Route::put('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'update']); + Route::delete('/settings/sorting/sets/{id}', [SortingControllers\SortSetController::class, 'destroy']); + // Settings Route::get('/settings', [SettingControllers\SettingController::class, 'index'])->name('settings'); Route::get('/settings/{category}', [SettingControllers\SettingController::class, 'category'])->name('settings.category'); From 12cc2f0689306048b7f13e4d04577da255d96583 Mon Sep 17 00:00:00 2001 From: Silverlan Date: Mon, 3 Feb 2025 19:01:08 +0100 Subject: [PATCH 306/800] Fix incorrect condition for displaying new books section --- resources/views/books/index.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 418c0fea8d1..197de011d01 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -23,7 +23,7 @@
                                {{ trans('entities.books_new') }}
                                - @if(count($popular) > 0) + @if(count($new) > 0) @include('entities.list', ['entities' => $new, 'style' => 'compact']) @else

                                {{ trans('entities.books_new_empty') }}

                                @@ -59,4 +59,4 @@
                                -@stop \ No newline at end of file +@stop From d28278bba63eaa13d7ab691379b4b741c1fb83e6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 15:14:22 +0000 Subject: [PATCH 307/800] Sorting: Added sort set form manager UI JS Extracted much code to be shared with the shelf books management UI --- app/Sorting/SortSet.php | 12 ++-- ...SortSetOption.php => SortSetOperation.php} | 10 ++-- lang/en/settings.php | 2 + package-lock.json | 9 ++- package.json | 1 + resources/js/components/index.ts | 1 + resources/js/components/shelf-sort.js | 48 ++------------- resources/js/components/sort-set-manager.ts | 41 +++++++++++++ resources/js/services/dual-lists.ts | 51 ++++++++++++++++ resources/sass/_components.scss | 19 ++++-- .../settings/sort-sets/parts/form.blade.php | 58 ++++++------------- .../sort-sets/parts/operation.blade.php | 15 +++++ resources/views/shelves/parts/form.blade.php | 4 +- 13 files changed, 168 insertions(+), 103 deletions(-) rename app/Sorting/{SortSetOption.php => SortSetOperation.php} (82%) create mode 100644 resources/js/components/sort-set-manager.ts create mode 100644 resources/js/services/dual-lists.ts create mode 100644 resources/views/settings/sort-sets/parts/operation.blade.php diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 42e1e0951c7..ee45c211f2d 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -15,21 +15,21 @@ class SortSet extends Model { /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public function getOptions(): array + public function getOperations(): array { $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOption::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); return array_filter($options); } /** - * @param SortSetOption[] $options + * @param SortSetOperation[] $options */ - public function setOptions(array $options): void + public function setOperations(array $options): void { - $values = array_map(fn (SortSetOption $opt) => $opt->value, $options); + $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } } diff --git a/app/Sorting/SortSetOption.php b/app/Sorting/SortSetOperation.php similarity index 82% rename from app/Sorting/SortSetOption.php rename to app/Sorting/SortSetOperation.php index bb878cf30f9..12fda669fbd 100644 --- a/app/Sorting/SortSetOption.php +++ b/app/Sorting/SortSetOperation.php @@ -2,7 +2,7 @@ namespace BookStack\Sorting; -enum SortSetOption: string +enum SortSetOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -34,11 +34,11 @@ public function getLabel(): string } /** - * @return SortSetOption[] + * @return SortSetOperation[] */ - public static function allExcluding(array $options): array + public static function allExcluding(array $operations): array { - $all = SortSetOption::cases(); - return array_diff($all, $options); + $all = SortSetOperation::cases(); + return array_diff($all, $operations); } } diff --git a/lang/en/settings.php b/lang/en/settings.php index b29ec25338b..8bb2f6ef481 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -87,7 +87,9 @@ 'sort_set_operations' => 'Sort Operations', 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', 'sort_set_available_operations' => 'Available Operations', + 'sort_set_available_operations_empty' => 'No operations remaining', 'sort_set_configured_operations' => 'Configured Operations', + 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', 'sort_set_op_asc' => '(Asc)', 'sort_set_op_desc' => '(Desc)', 'sort_set_op_name' => 'Name - Alphabetical', diff --git a/package-lock.json b/package-lock.json index 1912106c2b7..44a735d2f0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "bookstack", "dependencies": { "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", @@ -32,6 +31,7 @@ }, "devDependencies": { "@lezer/generator": "^1.7.2", + "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.24.0", "eslint": "^8.57.1", @@ -2403,6 +2403,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/sortablejs": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/package.json b/package.json index 08af25d140f..4571ea77d76 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@lezer/generator": "^1.7.2", + "@types/sortablejs": "^1.15.8", "chokidar-cli": "^3.0", "esbuild": "^0.24.0", "eslint": "^8.57.1", diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 12c991a51d8..affa25fcf31 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -50,6 +50,7 @@ export {ShelfSort} from './shelf-sort'; export {Shortcuts} from './shortcuts'; export {ShortcutInput} from './shortcut-input'; export {SortableList} from './sortable-list'; +export {SortSetManager} from './sort-set-manager' export {SubmitOnChange} from './submit-on-change'; export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js index 01ca11a333f..b56b01980a1 100644 --- a/resources/js/components/shelf-sort.js +++ b/resources/js/components/shelf-sort.js @@ -1,29 +1,6 @@ import Sortable from 'sortablejs'; import {Component} from './component'; - -/** - * @type {Object} - */ -const itemActions = { - move_up(item) { - const list = item.parentNode; - const index = Array.from(list.children).indexOf(item); - const newIndex = Math.max(index - 1, 0); - list.insertBefore(item, list.children[newIndex] || null); - }, - move_down(item) { - const list = item.parentNode; - const index = Array.from(list.children).indexOf(item); - const newIndex = Math.min(index + 2, list.children.length); - list.insertBefore(item, list.children[newIndex] || null); - }, - remove(item, shelfBooksList, allBooksList) { - allBooksList.appendChild(item); - }, - add(item, shelfBooksList) { - shelfBooksList.appendChild(item); - }, -}; +import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts'; export class ShelfSort extends Component { @@ -55,12 +32,9 @@ export class ShelfSort extends Component { } setupListeners() { - this.elem.addEventListener('click', event => { - const sortItemAction = event.target.closest('.scroll-box-item button[data-action]'); - if (sortItemAction) { - this.sortItemActionClick(sortItemAction); - } - }); + const listActions = buildListActions(this.allBookList, this.shelfBookList); + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + this.elem.addEventListener('click', sortActionListener); this.bookSearchInput.addEventListener('input', () => { this.filterBooksByName(this.bookSearchInput.value); @@ -93,20 +67,6 @@ export class ShelfSort extends Component { } } - /** - * Called when a sort item action button is clicked. - * @param {HTMLElement} sortItemAction - */ - sortItemActionClick(sortItemAction) { - const sortItem = sortItemAction.closest('.scroll-box-item'); - const {action} = sortItemAction.dataset; - - const actionFunction = itemActions[action]; - actionFunction(sortItem, this.shelfBookList, this.allBookList); - - this.onChange(); - } - onChange() { const shelfBookElems = Array.from(this.shelfBookList.querySelectorAll('[data-id]')); this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(','); diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-set-manager.ts new file mode 100644 index 00000000000..c35ad41fe07 --- /dev/null +++ b/resources/js/components/sort-set-manager.ts @@ -0,0 +1,41 @@ +import {Component} from "./component.js"; +import Sortable from "sortablejs"; +import {buildListActions, sortActionClickListener} from "../services/dual-lists"; + + +export class SortSetManager extends Component { + + protected input!: HTMLInputElement; + protected configuredList!: HTMLElement; + protected availableList!: HTMLElement; + + setup() { + this.input = this.$refs.input as HTMLInputElement; + this.configuredList = this.$refs.configuredOperationsList; + this.availableList = this.$refs.availableOperationsList; + + this.initSortable(); + + const listActions = buildListActions(this.availableList, this.configuredList); + const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this)); + this.$el.addEventListener('click', sortActionListener); + } + + initSortable() { + const scrollBoxes = [this.configuredList, this.availableList]; + for (const scrollBox of scrollBoxes) { + new Sortable(scrollBox, { + group: 'sort-set-operations', + ghostClass: 'primary-background-light', + handle: '.handle', + animation: 150, + onSort: this.onChange.bind(this), + }); + } + } + + onChange() { + const configuredOpEls = Array.from(this.configuredList.querySelectorAll('[data-id]')); + this.input.value = configuredOpEls.map(elem => elem.getAttribute('data-id')).join(','); + } +} \ No newline at end of file diff --git a/resources/js/services/dual-lists.ts b/resources/js/services/dual-lists.ts new file mode 100644 index 00000000000..98f2af92daf --- /dev/null +++ b/resources/js/services/dual-lists.ts @@ -0,0 +1,51 @@ +/** + * Service for helping manage common dual-list scenarios. + * (Shelf book manager, sort set manager). + */ + +type ListActionsSet = Record void)>; + +export function buildListActions( + availableList: HTMLElement, + configuredList: HTMLElement, +): ListActionsSet { + return { + move_up(item) { + const list = item.parentNode as HTMLElement; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.max(index - 1, 0); + list.insertBefore(item, list.children[newIndex] || null); + }, + move_down(item) { + const list = item.parentNode as HTMLElement; + const index = Array.from(list.children).indexOf(item); + const newIndex = Math.min(index + 2, list.children.length); + list.insertBefore(item, list.children[newIndex] || null); + }, + remove(item) { + availableList.appendChild(item); + }, + add(item) { + configuredList.appendChild(item); + }, + }; +} + +export function sortActionClickListener(actions: ListActionsSet, onChange: () => void) { + return (event: MouseEvent) => { + const sortItemAction = (event.target as Element).closest('.scroll-box-item button[data-action]') as HTMLElement|null; + if (sortItemAction) { + const sortItem = sortItemAction.closest('.scroll-box-item') as HTMLElement; + const action = sortItemAction.dataset.action; + if (!action) { + throw new Error('No action defined for clicked button'); + } + + const actionFunction = actions[action]; + actionFunction(sortItem); + + onChange(); + } + }; +} + diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 888b325275e..58d39d3ee6e 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1062,12 +1062,16 @@ $btt-size: 40px; cursor: pointer; @include mixins.lightDark(background-color, #f8f8f8, #333); } + &.items-center { + align-items: center; + } .handle { color: #AAA; cursor: grab; } button { opacity: .6; + line-height: 1; } .handle svg { margin: 0; @@ -1108,12 +1112,19 @@ input.scroll-box-search, .scroll-box-header-item { border-radius: 0 0 3px 3px; } -.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] { +.scroll-box.configured-option-list [data-action="add"] { display: none; } -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"], -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"], -.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"], +.scroll-box.available-option-list [data-action="remove"], +.scroll-box.available-option-list [data-action="move_up"], +.scroll-box.available-option-list [data-action="move_down"], { display: none; +} + +.scroll-box > li.empty-state { + display: none; +} +.scroll-box > li.empty-state:last-child { + display: list-item; } \ No newline at end of file diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 6df04a72191..3f22209471f 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -1,4 +1,3 @@ -
                                @@ -13,59 +12,36 @@
                                -
                                +

                                {{ trans('settings.sort_set_operations_desc') }}

                                - +
                                - -
                                  {{ trans('settings.sort_set_configured_operations') }} +
                                    - @foreach(($model?->getOptions() ?? []) as $option) -
                                  • -
                                    @icon('grip')
                                    -
                                    {{ $option->getLabel() }}
                                    -
                                    - - - - -
                                    -
                                  • + class="scroll-box configured-option-list"> +
                                  • {{ trans('settings.sort_set_configured_operations_empty') }}
                                  • + @foreach(($model?->getOperations() ?? []) as $option) + @include('settings.sort-sets.parts.operation') @endforeach
                                - -
                                  {{ trans('settings.sort_set_available_operations') }} +
                                    - @foreach(\BookStack\Sorting\SortSetOption::allExcluding($model?->getOptions() ?? []) as $option) -
                                  • -
                                    @icon('grip')
                                    -
                                    {{ $option->getLabel() }}
                                    -
                                    - - - - -
                                    -
                                  • + class="scroll-box available-option-list"> +
                                  • {{ trans('settings.sort_set_available_operations_empty') }}
                                  • + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
                                diff --git a/resources/views/settings/sort-sets/parts/operation.blade.php b/resources/views/settings/sort-sets/parts/operation.blade.php new file mode 100644 index 00000000000..3feb68a4742 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/operation.blade.php @@ -0,0 +1,15 @@ +
                              • +
                                @icon('grip')
                                +
                                {{ $operation->getLabel() }}
                                +
                                + + + + +
                                +
                              • \ No newline at end of file diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index a75dd6ac1b5..7790ba5a4e7 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -38,7 +38,7 @@ class="icon-button px-xs py-xxs mx-xs text-bigger"
                                  + class="scroll-box configured-option-list"> @foreach (($shelf->visibleBooks ?? []) as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach @@ -49,7 +49,7 @@ class="scroll-box">
                                    + class="scroll-box available-option-list"> @foreach ($books as $book) @include('shelves.parts.shelf-sort-book-item', ['book' => $book]) @endforeach From b897af2ed034088193986c8526be9606edaca7d5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 4 Feb 2025 20:11:35 +0000 Subject: [PATCH 308/800] Sorting: Finished main sort set CRUD work --- app/Activity/ActivityType.php | 4 ++ app/Sorting/SortSet.php | 17 +++-- app/Sorting/SortSetController.php | 69 +++++++++++++++++++ app/Sorting/SortSetOperation.php | 17 ++++- lang/en/activities.php | 8 +++ lang/en/settings.php | 2 + .../settings/categories/sorting.blade.php | 13 +++- .../views/settings/sort-sets/edit.blade.php | 44 ++++++++++++ .../settings/sort-sets/parts/form.blade.php | 16 +++-- .../parts/sort-set-list-item.blade.php | 8 +++ 10 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 resources/views/settings/sort-sets/edit.blade.php create mode 100644 resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 5ec9b9cf0dc..4a648da6cfb 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -71,6 +71,10 @@ class ActivityType const IMPORT_RUN = 'import_run'; const IMPORT_DELETE = 'import_delete'; + const SORT_SET_CREATE = 'sort_set_create'; + const SORT_SET_UPDATE = 'sort_set_update'; + const SORT_SET_DELETE = 'sort_set_delete'; + /** * Get all the possible values. */ diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index ee45c211f2d..971b3e29aae 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -2,6 +2,7 @@ namespace BookStack\Sorting; +use BookStack\Activity\Models\Loggable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; @@ -12,16 +13,14 @@ * @property Carbon $created_at * @property Carbon $updated_at */ -class SortSet extends Model +class SortSet extends Model implements Loggable { /** * @return SortSetOperation[] */ public function getOperations(): array { - $strOptions = explode(',', $this->sequence); - $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); - return array_filter($options); + return SortSetOperation::fromSequence($this->sequence); } /** @@ -32,4 +31,14 @@ public function setOperations(array $options): void $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } + + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } + + public function getUrl(): string + { + return url("/settings/sorting/sets/{$this->id}"); + } } diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 4ef14829538..0d77bd88fcd 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -2,7 +2,9 @@ namespace BookStack\Sorting; +use BookStack\Activity\ActivityType; use BookStack\Http\Controller; +use BookStack\Http\Request; class SortSetController extends Controller { @@ -14,6 +16,73 @@ public function __construct() public function create() { + $this->setPageTitle(trans('settings.sort_set_create')); + return view('settings.sort-sets.create'); } + + public function store(Request $request) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'min:1', 'max:200'], + 'sequence' => ['required', 'string', 'min:1'], + ]); + + $operations = SortSetOperation::fromSequence($request->input('sequence')); + if (count($operations) === 0) { + return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + } + + $set = new SortSet(); + $set->name = $request->input('name'); + $set->setOperations($operations); + $set->save(); + + $this->logActivity(ActivityType::SORT_SET_CREATE, $set); + + return redirect('/settings/sorting'); + } + + public function edit(string $id) + { + $set = SortSet::query()->findOrFail($id); + + $this->setPageTitle(trans('settings.sort_set_edit')); + + return view('settings.sort-sets.edit', ['set' => $set]); + } + + public function update(string $id, Request $request) + { + $this->validate($request, [ + 'name' => ['required', 'string', 'min:1', 'max:200'], + 'sequence' => ['required', 'string', 'min:1'], + ]); + + $set = SortSet::query()->findOrFail($id); + $operations = SortSetOperation::fromSequence($request->input('sequence')); + if (count($operations) === 0) { + return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + } + + $set->name = $request->input('name'); + $set->setOperations($operations); + $set->save(); + + $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + + return redirect('/settings/sorting'); + } + + public function destroy(string $id) + { + $set = SortSet::query()->findOrFail($id); + + // TODO - Check if it's in use + + $set->delete(); + $this->logActivity(ActivityType::SORT_SET_DELETE, $set); + + return redirect('/settings/sorting'); + } } diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortSetOperation.php index 12fda669fbd..a6dd860f5c7 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortSetOperation.php @@ -39,6 +39,21 @@ public function getLabel(): string public static function allExcluding(array $operations): array { $all = SortSetOperation::cases(); - return array_diff($all, $operations); + $filtered = array_filter($all, function (SortSetOperation $operation) use ($operations) { + return !in_array($operation, $operations); + }); + return array_values($filtered); + } + + /** + * Create a set of operations from a string sequence representation. + * (values seperated by commas). + * @return SortSetOperation[] + */ + public static function fromSequence(string $sequence): array + { + $strOptions = explode(',', $sequence); + $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); + return array_filter($options); } } diff --git a/lang/en/activities.php b/lang/en/activities.php index 7c3454d41ca..7db872c0ca8 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -127,6 +127,14 @@ 'comment_update' => 'updated comment', 'comment_delete' => 'deleted comment', + // Sort Sets + 'sort_set_create' => 'created sort set', + 'sort_set_create_notification' => 'Sort set successfully created', + 'sort_set_update' => 'updated sort set', + 'sort_set_update_notification' => 'Sort set successfully update', + 'sort_set_delete' => 'deleted sort set', + 'sort_set_delete_notification' => 'Sort set successfully deleted', + // Other 'permissions_update' => 'updated permissions', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 8bb2f6ef481..cda097590a0 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -82,6 +82,8 @@ 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', + 'sort_set_delete' => 'Delete Sort Set', + 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 9de11bb6f2b..b5d61384003 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -52,6 +52,17 @@ class="setting-list-label">{{ trans('settings.sorting_book_default') }}
                                -{{-- TODO--}} + @php + $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); + @endphp + @if(empty($sortSets)) +

                                {{ trans('common.no_items') }}

                                + @else +
                                + @foreach($sortSets as $set) + @include('settings.sort-sets.parts.sort-set-list-item', ['set' => $set]) + @endforeach +
                                + @endif
                                @endsection \ No newline at end of file diff --git a/resources/views/settings/sort-sets/edit.blade.php b/resources/views/settings/sort-sets/edit.blade.php new file mode 100644 index 00000000000..3b88c124338 --- /dev/null +++ b/resources/views/settings/sort-sets/edit.blade.php @@ -0,0 +1,44 @@ +@extends('layouts.simple') + +@section('body') + +
                                + + @include('settings.parts.navbar', ['selected' => 'settings']) + +
                                +

                                {{ trans('settings.sort_set_edit') }}

                                + +
                                + {{ method_field('PUT') }} + {{ csrf_field() }} + + @include('settings.sort-sets.parts.form', ['model' => $set]) + +
                                + {{ trans('common.cancel') }} + +
                                +
                                +
                                + +
                                +
                                +
                                +

                                {{ trans('settings.sort_set_delete') }}

                                +

                                {{ trans('settings.sort_set_delete_desc') }}

                                +
                                +
                                +
                                + {{ method_field('DELETE') }} + {{ csrf_field() }} +
                                + +
                                +
                                +
                                +
                                +
                                +
                                + +@stop diff --git a/resources/views/settings/sort-sets/parts/form.blade.php b/resources/views/settings/sort-sets/parts/form.blade.php index 3f22209471f..38db840ac57 100644 --- a/resources/views/settings/sort-sets/parts/form.blade.php +++ b/resources/views/settings/sort-sets/parts/form.blade.php @@ -15,9 +15,14 @@

                                {{ trans('settings.sort_set_operations_desc') }}

                                + @include('form.errors', ['name' => 'sequence']) - + + + @php + $configuredOps = old('sequence') ? \BookStack\Sorting\SortSetOperation::fromSequence(old('sequence')) : ($model?->getOperations() ?? []); + @endphp
                                @@ -27,8 +32,9 @@ aria-labelledby="sort-set-configured-operations" class="scroll-box configured-option-list">
                              • {{ trans('settings.sort_set_configured_operations_empty') }}
                              • - @foreach(($model?->getOperations() ?? []) as $option) - @include('settings.sort-sets.parts.operation') + + @foreach($configuredOps as $operation) + @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach
                                @@ -40,7 +46,7 @@ class="scroll-box configured-option-list"> aria-labelledby="sort-set-available-operations" class="scroll-box available-option-list">
                              • {{ trans('settings.sort_set_available_operations_empty') }}
                              • - @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($model?->getOperations() ?? []) as $operation) + @foreach(\BookStack\Sorting\SortSetOperation::allExcluding($configuredOps) as $operation) @include('settings.sort-sets.parts.operation', ['operation' => $operation]) @endforeach diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php new file mode 100644 index 00000000000..e5ee1fb8795 --- /dev/null +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -0,0 +1,8 @@ +
                                + +
                                + {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }} +
                                +
                                \ No newline at end of file From 7093daa49de63e237d442709a93a03b5acc4f323 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 14:33:46 +0000 Subject: [PATCH 309/800] Sorting: Connected up default sort setting for books --- app/Entities/Models/Book.php | 11 ++++++++ app/Entities/Repos/BookRepo.php | 7 +++++ app/Sorting/SortSet.php | 7 +++++ app/Sorting/SortSetController.php | 13 +++++++-- ..._02_05_150842_add_sort_set_id_to_books.php | 28 +++++++++++++++++++ lang/en/settings.php | 2 ++ .../settings/categories/sorting.blade.php | 23 ++++++++------- 7 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index c1644dcf5fb..7d240e5cab9 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Models; +use BookStack\Sorting\SortSet; use BookStack\Uploads\Image; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,12 +17,14 @@ * @property string $description * @property int $image_id * @property ?int $default_template_id + * @property ?int $sort_set_id * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?Page $defaultTemplate + * @property ?SortSet $sortSet */ class Book extends Entity implements HasCoverImage { @@ -82,6 +85,14 @@ public function defaultTemplate(): BelongsTo return $this->belongsTo(Page::class, 'default_template_id'); } + /** + * Get the sort set assigned to this book, if existing. + */ + public function sortSet(): BelongsTo + { + return $this->belongsTo(SortSet::class); + } + /** * Get all pages within this book. */ diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 19d159eb1e7..b3b81164785 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -8,6 +8,7 @@ use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; +use BookStack\Sorting\SortSet; use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\UploadedFile; @@ -33,6 +34,12 @@ public function create(array $input): Book $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting && SortSet::query()->find($defaultBookSortSetting)) { + $book->sort_set_id = $defaultBookSortSetting; + $book->save(); + } + return $book; } diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 971b3e29aae..a73407bfa08 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -3,8 +3,10 @@ namespace BookStack\Sorting; use BookStack\Activity\Models\Loggable; +use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -41,4 +43,9 @@ public function getUrl(): string { return url("/settings/sorting/sets/{$this->id}"); } + + public function books(): HasMany + { + return $this->hasMany(Book::class); + } } diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 0d77bd88fcd..8f51207918f 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -62,7 +62,7 @@ public function update(string $id, Request $request) $set = SortSet::query()->findOrFail($id); $operations = SortSetOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect($set->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); } $set->name = $request->input('name'); @@ -78,7 +78,16 @@ public function destroy(string $id) { $set = SortSet::query()->findOrFail($id); - // TODO - Check if it's in use + if ($set->books()->count() > 0) { + $this->showErrorNotification(trans('settings.sort_set_delete_fail_books')); + return redirect($set->getUrl()); + } + + $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); + if ($defaultBookSortSetting === intval($id)) { + $this->showErrorNotification(trans('settings.sort_set_delete_fail_default')); + return redirect($set->getUrl()); + } $set->delete(); $this->logActivity(ActivityType::SORT_SET_DELETE, $set); diff --git a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php new file mode 100644 index 00000000000..c0b32c55288 --- /dev/null +++ b/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php @@ -0,0 +1,28 @@ +unsignedInteger('sort_set_id')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('books', function (Blueprint $table) { + $table->dropColumn('sort_set_id'); + }); + } +}; diff --git a/lang/en/settings.php b/lang/en/settings.php index cda097590a0..eb046d27848 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -84,6 +84,8 @@ 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', + 'sort_set_delete_fail_books' => 'Unable to delete this sort set since it has books assigned.', + 'sort_set_delete_fail_default' => 'Unable to delete this sort set since it\'s used as the default book sort.', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index b5d61384003..0af3a8fb85b 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,5 +1,9 @@ @extends('settings.layout') +@php + $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); +@endphp + @section('card')

                                {{ trans('settings.sorting') }}

                                @@ -19,15 +23,13 @@ class="setting-list-label">{{ trans('settings.sorting_book_default') }} -{{-- TODO--}} -{{-- @foreach(\BookStack\Users\Models\Role::all() as $role)--}} -{{-- --}} -{{-- @endforeach--}} + @foreach($sortSets as $set) + + @endforeach
                                @@ -52,9 +54,6 @@ class="setting-list-label">{{ trans('settings.sorting_book_default') }} - @php - $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); - @endphp @if(empty($sortSets))

                                {{ trans('common.no_items') }}

                                @else From c13ce1883708c184d14b3be10734894ddf7c9e00 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 16:52:20 +0000 Subject: [PATCH 310/800] Sorting: Added book autosort logic --- app/Entities/Repos/BaseRepo.php | 15 +++++ app/Entities/Repos/ChapterRepo.php | 6 ++ app/Entities/Repos/PageRepo.php | 6 ++ app/Sorting/BookSorter.php | 48 ++++++++++++++ app/Sorting/SortSetOperation.php | 9 +++ app/Sorting/SortSetOperationComparisons.php | 69 +++++++++++++++++++++ 6 files changed, 153 insertions(+) create mode 100644 app/Sorting/SortSetOperationComparisons.php diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 033350743e0..151d5b0555b 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -4,6 +4,7 @@ use BookStack\Activity\TagRepo; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\HasCoverImage; @@ -12,6 +13,7 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; +use BookStack\Sorting\BookSorter; use BookStack\Uploads\ImageRepo; use BookStack\Util\HtmlDescriptionFilter; use Illuminate\Http\UploadedFile; @@ -24,6 +26,7 @@ public function __construct( protected ReferenceUpdater $referenceUpdater, protected ReferenceStore $referenceStore, protected PageQueries $pageQueries, + protected BookSorter $bookSorter, ) { } @@ -134,6 +137,18 @@ public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): vo $entity->save(); } + /** + * Sort the parent of the given entity, if any auto sort actions are set for it. + * Typical ran during create/update/insert events. + */ + public function sortParent(Entity $entity): void + { + if ($entity instanceof BookChild) { + $book = $entity->book; + $this->bookSorter->runBookAutoSort($book); + } + } + protected function updateDescription(Entity $entity, array $input): void { if (!in_array(HasHtmlDescription::class, class_uses($entity))) { diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 17cbccd4133..fdf2de4e202 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -34,6 +34,8 @@ public function create(array $input, Book $parentBook): Chapter $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); + $this->baseRepo->sortParent($chapter); + return $chapter; } @@ -50,6 +52,8 @@ public function update(Chapter $chapter, array $input): Chapter Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); + $this->baseRepo->sortParent($chapter); + return $chapter; } @@ -88,6 +92,8 @@ public function move(Chapter $chapter, string $parentIdentifier): Book $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); + $this->baseRepo->sortParent($chapter); + return $parent; } } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 68b1c398f80..c3be6d826a2 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -83,6 +83,7 @@ public function publishDraft(Page $draft, array $input): Page $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); + $this->baseRepo->sortParent($draft); return $draft; } @@ -128,6 +129,7 @@ public function update(Page $page, array $input): Page } Activity::add(ActivityType::PAGE_UPDATE, $page); + $this->baseRepo->sortParent($page); return $page; } @@ -243,6 +245,8 @@ public function restoreRevision(Page $page, int $revisionId): Page Activity::add(ActivityType::PAGE_RESTORE, $page); Activity::add(ActivityType::REVISION_RESTORE, $revision); + $this->baseRepo->sortParent($page); + return $page; } @@ -272,6 +276,8 @@ public function move(Page $page, string $parentIdentifier): Entity Activity::add(ActivityType::PAGE_MOVE, $page); + $this->baseRepo->sortParent($page); + return $parent; } diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 7268b3543a3..e89fdaccc31 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,6 +16,54 @@ public function __construct( ) { } + /** + * Runs the auto-sort for a book if the book has a sort set applied to it. + * This does not consider permissions since the sort operations are centrally + * managed by admins so considered permitted if existing and assigned. + */ + public function runBookAutoSort(Book $book): void + { + $set = $book->sortSet; + if (!$set) { + return; + } + + $sortFunctions = array_map(function (SortSetOperation $op) { + return $op->getSortFunction(); + }, $set->getOperations()); + + $chapters = $book->chapters() + ->with('pages:id,name,priority,created_at,updated_at') + ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); + + /** @var (Chapter|Book)[] $topItems */ + $topItems = [ + ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']), + ...$chapters, + ]; + + foreach ($sortFunctions as $sortFunction) { + usort($topItems, $sortFunction); + } + + foreach ($topItems as $index => $topItem) { + $topItem->priority = $index + 1; + $topItem->save(); + } + + foreach ($chapters as $chapter) { + $pages = $chapter->pages->all(); + foreach ($sortFunctions as $sortFunction) { + usort($pages, $sortFunction); + } + + foreach ($pages as $index => $page) { + $page->priority = $index + 1; + $page->save(); + } + } + } + /** * Sort the books content using the given sort map. diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortSetOperation.php index a6dd860f5c7..7fdd0b002bf 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortSetOperation.php @@ -2,6 +2,9 @@ namespace BookStack\Sorting; +use Closure; +use Illuminate\Support\Str; + enum SortSetOperation: string { case NameAsc = 'name_asc'; @@ -33,6 +36,12 @@ public function getLabel(): string return trim($label); } + public function getSortFunction(): callable + { + $camelValue = Str::camel($this->value); + return SortSetOperationComparisons::$camelValue(...); + } + /** * @return SortSetOperation[] */ diff --git a/app/Sorting/SortSetOperationComparisons.php b/app/Sorting/SortSetOperationComparisons.php new file mode 100644 index 00000000000..e1c3e625f9f --- /dev/null +++ b/app/Sorting/SortSetOperationComparisons.php @@ -0,0 +1,69 @@ +name <=> $b->name; + } + + public static function nameDesc(Entity $a, Entity $b): int + { + return $b->name <=> $a->name; + } + + public static function nameNumericAsc(Entity $a, Entity $b): int + { + $numRegex = '/^\d+(\.\d+)?/'; + $aMatches = []; + $bMatches = []; + preg_match($numRegex, $a, $aMatches); + preg_match($numRegex, $b, $bMatches); + return ($aMatches[0] ?? 0) <=> ($bMatches[0] ?? 0); + } + + public static function nameNumericDesc(Entity $a, Entity $b): int + { + return -(static::nameNumericAsc($a, $b)); + } + + public static function createdDateAsc(Entity $a, Entity $b): int + { + return $a->created_at->unix() <=> $b->created_at->unix(); + } + + public static function createdDateDesc(Entity $a, Entity $b): int + { + return $b->created_at->unix() <=> $a->created_at->unix(); + } + + public static function updatedDateAsc(Entity $a, Entity $b): int + { + return $a->updated_at->unix() <=> $b->updated_at->unix(); + } + + public static function updatedDateDesc(Entity $a, Entity $b): int + { + return $b->updated_at->unix() <=> $a->updated_at->unix(); + } + + public static function chaptersFirst(Entity $a, Entity $b): int + { + return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0); + } + + public static function chaptersLast(Entity $a, Entity $b): int + { + return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0); + } +} From 103a8a8e8ee1528a1ce248f01bf3fd4ab16840f6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 5 Feb 2025 21:17:48 +0000 Subject: [PATCH 311/800] Meta: Updated sponsor list, licence year and readme --- LICENSE | 2 +- readme.md | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/LICENSE b/LICENSE index 5ed2edf8585..9b94d9be3f3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors. +Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/readme.md b/readme.md index 857e2051a55..339c7cbd4ae 100644 --- a/readme.md +++ b/readme.md @@ -29,11 +29,11 @@ A platform for storing and organising information and documentation. Details for ## 📚 Project Definition -BookStack is an opinionated documentation platform that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience. +BookStack is an opinionated documentation platform that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it, but they should not interfere with the core simple user experience. BookStack is not designed as an extensible platform to be used for purposes that differ to the statement above. -In regard to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time. +In regard to development philosophy, BookStack has a relaxed, open & positive approach. We aim to slowly yet continuously evolve the platform while providing a stable & easy upgrade path. You can read more about the project and its origins in [our FAQ here](https://www.bookstackapp.com/about/project-faq/). @@ -82,15 +82,11 @@ Big thanks to these companies for supporting the project. Practinet - - Transport Talent - - - - + Route4Me - Route Optimizer and Route Planner Software - + + ## 🛠️ Development & Testing @@ -134,7 +130,7 @@ We want BookStack to remain accessible to as many people as possible. We aim for ## 🖥️ Website, Docs & Blog -The website which contains the project docs & blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo. +The website which contains the project docs & blog can be found in the [BookStackApp/website](https://codeberg.org/bookstack/website) repo. ## ⚖️ License @@ -161,7 +157,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be * [KnpLabs/snappy](https://github.com/KnpLabs/snappy) - _[MIT](https://github.com/KnpLabs/snappy/blob/master/LICENSE)_ * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html) - _[LGPL v3.0](https://github.com/wkhtmltopdf/wkhtmltopdf/blob/master/LICENSE)_ * [diagrams.net](https://github.com/jgraph/drawio) - _[Embedded Version Terms](https://www.diagrams.net/trust/) / [Source Project - Apache-2.0](https://github.com/jgraph/drawio/blob/dev/LICENSE)_ -* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml) - _[MIT](https://github.com/onelogin/php-saml/blob/master/LICENSE)_ +* [SAML PHP Toolkit](https://github.com/SAML-Toolkits/php-saml) - _[MIT](https://github.com/SAML-Toolkits/php-saml/blob/master/LICENSE)_ * [League/CommonMark](https://commonmark.thephpleague.com/) - _[BSD-3-Clause](https://github.com/thephpleague/commonmark/blob/2.2/LICENSE)_ * [League/Flysystem](https://flysystem.thephpleague.com) - _[MIT](https://github.com/thephpleague/flysystem/blob/3.x/LICENSE)_ * [League/html-to-markdown](https://github.com/thephpleague/html-to-markdown) - _[MIT](https://github.com/thephpleague/html-to-markdown/blob/master/LICENSE)_ From ccd94684ebc5d8b14a9fa4e7b0808501b3c80cd9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Feb 2025 14:58:08 +0000 Subject: [PATCH 312/800] Sorting: Improved sort set display, delete, added action on edit - Changes to a sort set will now auto-apply to assinged books (basic chunck through all on save). - Added book count indicator to sort set list items. - Deletion now has confirmation and auto-handling of assigned books/settings. --- app/Sorting/BookSorter.php | 9 +++++ app/Sorting/SortSetController.php | 34 ++++++++++++++----- lang/en/settings.php | 6 ++-- .../settings/categories/sorting.blade.php | 5 ++- .../views/settings/sort-sets/edit.blade.php | 16 +++++++-- .../parts/sort-set-list-item.blade.php | 10 ++++-- 6 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index e89fdaccc31..fd99a8d3781 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,6 +16,15 @@ public function __construct( ) { } + public function runBookAutoSortForAllWithSet(SortSet $set): void + { + $set->books()->chunk(50, function ($books) { + foreach ($books as $book) { + $this->runBookAutoSort($book); + } + }); + } + /** * Runs the auto-sort for a book if the book has a sort set applied to it. * This does not consider permissions since the sort operations are centrally diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index 8f51207918f..b0ad2a7d766 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -52,7 +52,7 @@ public function edit(string $id) return view('settings.sort-sets.edit', ['set' => $set]); } - public function update(string $id, Request $request) + public function update(string $id, Request $request, BookSorter $bookSorter) { $this->validate($request, [ 'name' => ['required', 'string', 'min:1', 'max:200'], @@ -67,26 +67,44 @@ public function update(string $id, Request $request) $set->name = $request->input('name'); $set->setOperations($operations); + $changedSequence = $set->isDirty('sequence'); $set->save(); $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + if ($changedSequence) { + $bookSorter->runBookAutoSortForAllWithSet($set); + } + return redirect('/settings/sorting'); } - public function destroy(string $id) + public function destroy(string $id, Request $request) { $set = SortSet::query()->findOrFail($id); - - if ($set->books()->count() > 0) { - $this->showErrorNotification(trans('settings.sort_set_delete_fail_books')); - return redirect($set->getUrl()); + $confirmed = $request->input('confirm') === 'true'; + $booksAssigned = $set->books()->count(); + $warnings = []; + + if ($booksAssigned > 0) { + if ($confirmed) { + $set->books()->update(['sort_set_id' => null]); + } else { + $warnings[] = trans('settings.sort_set_delete_warn_books', ['count' => $booksAssigned]); + } } $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting === intval($id)) { - $this->showErrorNotification(trans('settings.sort_set_delete_fail_default')); - return redirect($set->getUrl()); + if ($confirmed) { + setting()->remove('sorting-book-default'); + } else { + $warnings[] = trans('settings.sort_set_delete_warn_default'); + } + } + + if (count($warnings) > 0) { + return redirect($set->getUrl() . '#delete')->withErrors(['delete' => $warnings]); } $set->delete(); diff --git a/lang/en/settings.php b/lang/en/settings.php index eb046d27848..19ffd924049 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -83,9 +83,9 @@ 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', - 'sort_set_delete_desc' => 'Remove this sort set from the system. Deletion will only go ahead if the sort is not in active use.', - 'sort_set_delete_fail_books' => 'Unable to delete this sort set since it has books assigned.', - 'sort_set_delete_fail_default' => 'Unable to delete this sort set since it\'s used as the default book sort.', + 'sort_set_delete_desc' => 'Remove this sort set from the system. Books using this sort will revert to manual sorting.', + 'sort_set_delete_warn_books' => 'This sort set is currently used on :count book(s). Are you sure you want to delete this?', + 'sort_set_delete_warn_default' => 'This sort set is currently used as the default for books. Are you sure you want to delete this?', 'sort_set_details' => 'Sort Set Details', 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', 'sort_set_operations' => 'Sort Operations', diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 0af3a8fb85b..60fb329b6aa 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,7 +1,10 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::query()->orderBy('name', 'asc')->get(); + $sortSets = \BookStack\Sorting\SortSet::query() + ->withCount('books') + ->orderBy('name', 'asc') + ->get(); @endphp @section('card') diff --git a/resources/views/settings/sort-sets/edit.blade.php b/resources/views/settings/sort-sets/edit.blade.php index 3b88c124338..febcd9ffe1f 100644 --- a/resources/views/settings/sort-sets/edit.blade.php +++ b/resources/views/settings/sort-sets/edit.blade.php @@ -22,16 +22,26 @@ -
                                +
                                -
                                +

                                {{ trans('settings.sort_set_delete') }}

                                -

                                {{ trans('settings.sort_set_delete_desc') }}

                                +

                                {{ trans('settings.sort_set_delete_desc') }}

                                + @if($errors->has('delete')) + @foreach($errors->get('delete') as $error) +

                                {{ $error }}

                                + @endforeach + @endif
                                {{ method_field('DELETE') }} {{ csrf_field() }} + + @if($errors->has('delete')) + + @endif +
                                diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php index e5ee1fb8795..e977c286e11 100644 --- a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -1,8 +1,12 @@ -
                                -
                                +
                                + -
                                +
                                {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }}
                                +
                                + @icon('book'){{ $set->books_count ?? 0 }} +
                                \ No newline at end of file From d938565839e5c59576b45ccb6e8caa40baa7129d Mon Sep 17 00:00:00 2001 From: inv-hareesh Date: Fri, 7 Feb 2025 08:59:36 +0530 Subject: [PATCH 313/800] =?UTF-8?q?Fix=20search=20issue=20for=20words=20in?= =?UTF-8?q?side=20Guillemets=20(=C2=AB=20=C2=BB)=20without=20spaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Search/SearchIndex.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index c7d9d6502e2..e10219e2d2f 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -16,7 +16,7 @@ class SearchIndex /** * A list of delimiter characters used to break-up parsed content into terms for indexing. */ - public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\""; + public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"«»"; public function __construct( protected EntityProvider $entityProvider From ec7951749333fb9b57c3e5ce368f3427065489b3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 15:16:18 +0000 Subject: [PATCH 314/800] Sorting: Added auto sort option to book sort UI Includes indicator on books added to sort operation. --- app/Sorting/BookSortController.php | 36 +++++++++++++------ app/Sorting/SortSet.php | 9 +++++ lang/en/entities.php | 4 ++- resources/icons/auto-sort.svg | 1 + resources/sass/_lists.scss | 4 +++ .../views/books/parts/sort-box.blade.php | 5 +++ resources/views/books/sort.blade.php | 28 +++++++++++++-- .../settings/categories/sorting.blade.php | 5 +-- 8 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 resources/icons/auto-sort.svg diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index feed5db4fde..98d79d0fd12 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -44,24 +44,40 @@ public function showItem(string $bookSlug) } /** - * Sorts a book using a given mapping array. + * Update the sort options of a book, setting the auto-sort and/or updating + * child order via mapping. */ public function update(Request $request, BookSorter $sorter, string $bookSlug) { $book = $this->queries->findVisibleBySlugOrFail($bookSlug); $this->checkOwnablePermission('book-update', $book); + $loggedActivityForBook = false; - // Return if no map sent - if (!$request->filled('sort-tree')) { - return redirect($book->getUrl()); - } + // Sort via map + if ($request->filled('sort-tree')) { + $sortMap = BookSortMap::fromJson($request->get('sort-tree')); + $booksInvolved = $sorter->sortUsingMap($sortMap); - $sortMap = BookSortMap::fromJson($request->get('sort-tree')); - $booksInvolved = $sorter->sortUsingMap($sortMap); + // Rebuild permissions and add activity for involved books. + foreach ($booksInvolved as $bookInvolved) { + Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($bookInvolved->id === $book->id) { + $loggedActivityForBook = true; + } + } + } - // Rebuild permissions and add activity for involved books. - foreach ($booksInvolved as $bookInvolved) { - Activity::add(ActivityType::BOOK_SORT, $bookInvolved); + if ($request->filled('auto-sort')) { + $sortSetId = intval($request->get('auto-sort')) ?: null; + if ($sortSetId && SortSet::query()->find($sortSetId) === null) { + $sortSetId = null; + } + $book->sort_set_id = $sortSetId; + $book->save(); + $sorter->runBookAutoSort($book); + if (!$loggedActivityForBook) { + Activity::add(ActivityType::BOOK_SORT, $book); + } } return redirect($book->getUrl()); diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index a73407bfa08..8cdee1df49c 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\Entities\Models\Book; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -48,4 +49,12 @@ public function books(): HasMany { return $this->hasMany(Book::class); } + + public static function allByName(): Collection + { + return static::query() + ->withCount('books') + ->orderBy('name', 'asc') + ->get(); + } } diff --git a/lang/en/entities.php b/lang/en/entities.php index 26a563a7eb5..28a209fa21a 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -166,7 +166,9 @@ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort option can be set to automatically sort this book\'s contents upon changes.', + 'books_sort_auto_sort' => 'Auto Sort Option', + 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', diff --git a/resources/icons/auto-sort.svg b/resources/icons/auto-sort.svg new file mode 100644 index 00000000000..c3cb2f51669 --- /dev/null +++ b/resources/icons/auto-sort.svg @@ -0,0 +1 @@ + diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index fd76f498ed1..1e503dd0ffe 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -242,6 +242,10 @@ margin-bottom: vars.$m; padding: vars.$m vars.$xl; position: relative; + summary:focus { + outline: 1px dashed var(--color-primary); + outline-offset: 5px; + } &::before { pointer-events: none; content: ''; diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 03998e26178..232616168f9 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -8,6 +8,11 @@ @icon('book') {{ $book->name }}
                                +
                                + @if($book->sortSet) + @icon('auto-sort') + @endif +
                                diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index c82ad4e3b1e..3c59ac1e092 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -18,14 +18,36 @@

                                {{ trans('entities.books_sort') }}

                                -

                                {{ trans('entities.books_sort_desc') }}

                                + +
                                +

                                {{ trans('entities.books_sort_desc') }}

                                +
                                + @php + $autoSortVal = intval(old('auto-sort') ?? $book->sort_set_id ?? 0); + @endphp + + +
                                +
                                @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
                                - - {!! csrf_field() !!} + + {{ csrf_field() }}
                                diff --git a/resources/views/settings/categories/sorting.blade.php b/resources/views/settings/categories/sorting.blade.php index 60fb329b6aa..6a52873e6ab 100644 --- a/resources/views/settings/categories/sorting.blade.php +++ b/resources/views/settings/categories/sorting.blade.php @@ -1,10 +1,7 @@ @extends('settings.layout') @php - $sortSets = \BookStack\Sorting\SortSet::query() - ->withCount('books') - ->orderBy('name', 'asc') - ->get(); + $sortSets = \BookStack\Sorting\SortSet::allByName(); @endphp @section('card') From 37d020c08350721320d3e7e3c8533daec544bb03 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 17:44:24 +0000 Subject: [PATCH 315/800] Sorting: Addded command to apply sort sets --- app/Console/Commands/AssignSortSetCommand.php | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 app/Console/Commands/AssignSortSetCommand.php diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php new file mode 100644 index 00000000000..aca046bae4a --- /dev/null +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -0,0 +1,99 @@ +argument('sort-set')) ?? 0; + if ($sortSetId === 0) { + return $this->listSortSets(); + } + + $set = SortSet::query()->find($sortSetId); + if ($this->option('all-books')) { + $query = Book::query(); + } else if ($this->option('books-without-sort')) { + $query = Book::query()->whereNull('sort_set_id'); + } else if ($this->option('books-with-sort')) { + $sortId = intval($this->option('books-with-sort')) ?: 0; + if (!$sortId) { + $this->error("Provided --books-with-sort option value is invalid"); + return 1; + } + $query = Book::query()->where('sort_set_id', $sortId); + } else { + $this->error("Either the --all-books or --books-without-sort option must be provided!"); + return 1; + } + + if (!$set) { + $this->error("Sort set of provided id {$sortSetId} not found!"); + return 1; + } + + $count = $query->clone()->count(); + $this->warn("This will apply sort set [{$set->id}: {$set->name}] to {$count} book(s) and run the sort on each."); + $confirmed = $this->confirm("Are you sure you want to continue?"); + + if (!$confirmed) { + return 1; + } + + $processed = 0; + $query->chunkById(10, function ($books) use ($set, $sorter, $count, &$processed) { + $max = min($count, ($processed + 10)); + $this->info("Applying to {$processed}-{$max} of {$count} books"); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + $sorter->runBookAutoSort($book); + } + $processed = $max; + }); + + $this->info("Sort applied to {$processed} books!"); + + return 0; + } + + protected function listSortSets(): int + { + + $sets = SortSet::query()->orderBy('id', 'asc')->get(); + $this->error("Sort set ID required!"); + $this->warn("\nAvailable sort sets:"); + foreach ($sets as $set) { + $this->info("{$set->id}: {$set->name}"); + } + + return 1; + } +} From 69683d50ecb854211fc9bb4502c9ef512ad23d8e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 9 Feb 2025 23:24:36 +0000 Subject: [PATCH 316/800] Sorting: Added tests to cover AssignSortSetCommand --- app/Console/Commands/AssignSortSetCommand.php | 4 +- app/Sorting/SortSet.php | 3 + database/factories/Sorting/SortSetFactory.php | 30 +++++ tests/Commands/AssignSortSetCommandTest.php | 112 ++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 database/factories/Sorting/SortSetFactory.php create mode 100644 tests/Commands/AssignSortSetCommandTest.php diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php index aca046bae4a..484f6995255 100644 --- a/app/Console/Commands/AssignSortSetCommand.php +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -50,7 +50,7 @@ public function handle(BookSorter $sorter): int } $query = Book::query()->where('sort_set_id', $sortId); } else { - $this->error("Either the --all-books or --books-without-sort option must be provided!"); + $this->error("No option provided to specify target. Run with the -h option to see all available options."); return 1; } @@ -79,7 +79,7 @@ public function handle(BookSorter $sorter): int $processed = $max; }); - $this->info("Sort applied to {$processed} books!"); + $this->info("Sort applied to {$processed} book(s)!"); return 0; } diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortSet.php index 8cdee1df49c..cc8879f9657 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortSet.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book; use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,8 @@ */ class SortSet extends Model implements Loggable { + use HasFactory; + /** * @return SortSetOperation[] */ diff --git a/database/factories/Sorting/SortSetFactory.php b/database/factories/Sorting/SortSetFactory.php new file mode 100644 index 00000000000..36e0a697677 --- /dev/null +++ b/database/factories/Sorting/SortSetFactory.php @@ -0,0 +1,30 @@ + $op->name . ' Sort', + 'sequence' => $op->value, + ]; + } +} diff --git a/tests/Commands/AssignSortSetCommandTest.php b/tests/Commands/AssignSortSetCommandTest.php new file mode 100644 index 00000000000..01cb5caa5e8 --- /dev/null +++ b/tests/Commands/AssignSortSetCommandTest.php @@ -0,0 +1,112 @@ +createMany(10); + + $commandRun = $this->artisan('bookstack:assign-sort-set') + ->expectsOutputToContain('Sort set ID required!') + ->assertExitCode(1); + + foreach ($sortSets as $sortSet) { + $commandRun->expectsOutputToContain("{$sortSet->id}: {$sortSet->name}"); + } + } + + public function test_run_without_options_advises_help() + { + $this->artisan("bookstack:assign-sort-set 100") + ->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.") + ->assertExitCode(1); + } + + public function test_run_without_valid_sort_advises_help() + { + $this->artisan("bookstack:assign-sort-set 100342 --all-books") + ->expectsOutput("Sort set of provided id 100342 not found!") + ->assertExitCode(1); + } + + public function test_confirmation_required() + { + $sortSet = SortSet::factory()->create(); + + $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books") + ->expectsConfirmation('Are you sure you want to continue?', 'no') + ->assertExitCode(1); + + $booksWithSort = Book::query()->whereNotNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithSort); + } + + public function test_assign_to_all_books() + { + $sortSet = SortSet::factory()->create(); + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertGreaterThan(0, $booksWithoutSort); + + $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books") + ->expectsOutputToContain("This will apply sort set [{$sortSet->id}: {$sortSet->name}] to {$booksWithoutSort} book(s)") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + } + + public function test_assign_to_all_books_without_sort() + { + $totalBooks = Book::query()->count(); + $book = $this->entities->book(); + $sortSetA = SortSet::factory()->create(); + $sortSetB = SortSet::factory()->create(); + $book->sort_set_id = $sortSetA->id; + $book->save(); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals($totalBooks, $booksWithoutSort + 1); + + $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-without-sort") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)") + ->assertExitCode(0); + + $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count(); + $this->assertEquals(0, $booksWithoutSort); + $this->assertEquals($totalBooks, $sortSetB->books()->count() + 1); + } + + public function test_assign_to_all_books_with_sort() + { + $book = $this->entities->book(); + $sortSetA = SortSet::factory()->create(); + $sortSetB = SortSet::factory()->create(); + $book->sort_set_id = $sortSetA->id; + $book->save(); + + $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-with-sort={$sortSetA->id}") + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Sort applied to 1 book(s)") + ->assertExitCode(0); + + $book->refresh(); + $this->assertEquals($sortSetB->id, $book->sort_set_id); + $this->assertEquals(1, $sortSetB->books()->count()); + } + + public function test_assign_to_all_books_with_sort_id_is_validated() + { + $this->artisan("bookstack:assign-sort-set 50 --books-with-sort=beans") + ->expectsOutputToContain("Provided --books-with-sort option value is invalid") + ->assertExitCode(1); + } +} From a65701294eaad4aad5ba5d9e249cbfb774ee6ca6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 10 Feb 2025 13:33:10 +0000 Subject: [PATCH 317/800] Sorting: Split out test class, added book autosort tests Just for test view, actual functionality of autosort on change still needs to be tested. --- app/Sorting/BookSorter.php | 2 +- .../SortTest.php => Sorting/BookSortTest.php} | 291 ++++-------------- tests/Sorting/MoveTest.php | 221 +++++++++++++ 3 files changed, 286 insertions(+), 228 deletions(-) rename tests/{Entity/SortTest.php => Sorting/BookSortTest.php} (51%) create mode 100644 tests/Sorting/MoveTest.php diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index fd99a8d3781..b6fe33b9c3e 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -42,7 +42,7 @@ public function runBookAutoSort(Book $book): void }, $set->getOperations()); $chapters = $book->chapters() - ->with('pages:id,name,priority,created_at,updated_at') + ->with('pages:id,name,priority,created_at,updated_at,chapter_id') ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); /** @var (Chapter|Book)[] $topItems */ diff --git a/tests/Entity/SortTest.php b/tests/Sorting/BookSortTest.php similarity index 51% rename from tests/Entity/SortTest.php rename to tests/Sorting/BookSortTest.php index 9a5a2fe175a..a726da14898 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -1,15 +1,27 @@ entities->book(); + + $resp = $this->asAdmin()->get($bookToSort->getUrl()); + $this->withHtml($resp)->assertElementExists('a[href="' . $bookToSort->getUrl('/sort') . '"]'); + + $resp = $this->get($bookToSort->getUrl('/sort')); + $resp->assertStatus(200); + $resp->assertSee($bookToSort->name); + } + public function test_drafts_do_not_show_up() { $this->asAdmin(); @@ -20,232 +32,10 @@ public function test_drafts_do_not_show_up() $resp = $this->get($book->getUrl()); $resp->assertSee($draft->name); - $resp = $this->get($book->getUrl() . '/sort'); + $resp = $this->get($book->getUrl('/sort')); $resp->assertDontSee($draft->name); } - public function test_page_move_into_book() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $resp = $this->asEditor()->get($page->getUrl('/move')); - $resp->assertSee('Move Page'); - - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee('moved page'); - $newBookResp->assertSee($page->name); - } - - public function test_page_move_into_chapter() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $newChapter = $newBook->chapters()->first(); - - $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ - 'entity_selection' => 'chapter:' . $newChapter->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter'); - - $newChapterResp = $this->get($newChapter->getUrl()); - $newChapterResp->assertSee($page->name); - } - - public function test_page_move_from_chapter_to_book() - { - $oldChapter = Chapter::query()->first(); - $page = $oldChapter->pages()->first(); - $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); - - $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $page->refresh(); - - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book'); - $this->assertTrue($page->chapter === null, 'Page has no parent chapter'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee($page->name); - } - - public function test_page_move_requires_create_permissions_on_parent() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); - - $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($movePageResp); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $page->refresh(); - $movePageResp->assertRedirect($page->getUrl()); - - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_page_move_requires_delete_permissions() - { - $page = $this->entities->page(); - $currentBook = $page->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); - - $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($movePageResp); - $pageView = $this->get($page->getUrl()); - $pageView->assertDontSee($page->getUrl('/move')); - - $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $movePageResp = $this->put($page->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $page->refresh(); - $movePageResp->assertRedirect($page->getUrl()); - $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); - $chapterMoveResp->assertSee('Move Chapter'); - - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); - - $newBookResp = $this->get($newBook->getUrl()); - $newBookResp->assertSee('moved chapter'); - $newBookResp->assertSee($chapter->name); - - $pageToCheck = Page::query()->find($pageToCheck->id); - $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); - $pageCheckResp = $this->get($pageToCheck->getUrl()); - $pageCheckResp->assertSee($newBook->name); - } - - public function test_chapter_move_requires_delete_permissions() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); - - $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($moveChapterResp); - $pageView = $this->get($chapter->getUrl()); - $pageView->assertDontSee($chapter->getUrl('/move')); - - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move_requires_create_permissions_in_new_book() - { - $chapter = $this->entities->chapter(); - $currentBook = $chapter->book; - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - $editor = $this->users->editor(); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); - $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); - - $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - $this->assertPermissionError($moveChapterResp); - - $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); - $moveChapterResp = $this->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $chapter = Chapter::query()->find($chapter->id); - $moveChapterResp->assertRedirect($chapter->getUrl()); - $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); - } - - public function test_chapter_move_changes_book_for_deleted_pages_within() - { - /** @var Chapter $chapter */ - $chapter = Chapter::query()->whereHas('pages')->first(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); - - $pageToCheck->delete(); - - $this->asEditor()->put($chapter->getUrl('/move'), [ - 'entity_selection' => 'book:' . $newBook->id, - ]); - - $pageToCheck->refresh(); - $this->assertEquals($newBook->id, $pageToCheck->book_id); - } - - public function test_book_sort_page_shows() - { - $bookToSort = $this->entities->book(); - - $resp = $this->asAdmin()->get($bookToSort->getUrl()); - $this->withHtml($resp)->assertElementExists('a[href="' . $bookToSort->getUrl('/sort') . '"]'); - - $resp = $this->get($bookToSort->getUrl('/sort')); - $resp->assertStatus(200); - $resp->assertSee($bookToSort->name); - } - public function test_book_sort() { $oldBook = $this->entities->book(); @@ -423,7 +213,7 @@ public function test_book_sort_item_returns_book_content() $firstPage = $bookToSort->pages[0]; $firstChapter = $bookToSort->chapters[0]; - $resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item'); + $resp = $this->asAdmin()->get($bookToSort->getUrl('/sort-item')); // Ensure book details are returned $resp->assertSee($bookToSort->name); @@ -431,6 +221,53 @@ public function test_book_sort_item_returns_book_content() $resp->assertSee($firstChapter->name); } + public function test_book_sort_item_shows_auto_sort_status() + { + $sort = SortSet::factory()->create(['name' => 'My sort']); + $book = $this->entities->book(); + + $resp = $this->asAdmin()->get($book->getUrl('/sort-item')); + $this->withHtml($resp)->assertElementNotExists("span[title='Auto Sort Active: My sort']"); + + $book->sort_set_id = $sort->id; + $book->save(); + + $resp = $this->asAdmin()->get($book->getUrl('/sort-item')); + $this->withHtml($resp)->assertElementExists("span[title='Auto Sort Active: My sort']"); + } + + public function test_auto_sort_options_shown_on_sort_page() + { + $sort = SortSet::factory()->create(); + $book = $this->entities->book(); + $resp = $this->asAdmin()->get($book->getUrl('/sort')); + + $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"]'); + } + + public function test_auto_sort_option_submit_saves_to_book() + { + $sort = SortSet::factory()->create(); + $book = $this->entities->book(); + $bookPage = $book->pages()->first(); + $bookPage->priority = 10000; + $bookPage->save(); + + $resp = $this->asAdmin()->put($book->getUrl('/sort'), [ + 'auto-sort' => $sort->id, + ]); + + $resp->assertRedirect($book->getUrl()); + $book->refresh(); + $bookPage->refresh(); + + $this->assertEquals($sort->id, $book->sort_set_id); + $this->assertNotEquals(10000, $bookPage->priority); + + $resp = $this->get($book->getUrl('/sort')); + $this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"][selected]'); + } + public function test_pages_in_book_show_sorted_by_priority() { $book = $this->entities->bookHasChaptersAndPages(); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php new file mode 100644 index 00000000000..edae1f3a3e1 --- /dev/null +++ b/tests/Sorting/MoveTest.php @@ -0,0 +1,221 @@ +entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/move')); + $resp->assertSee('Move Page'); + + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee('moved page'); + $newBookResp->assertSee($page->name); + } + + public function test_page_move_into_chapter() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $newChapter = $newBook->chapters()->first(); + + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ + 'entity_selection' => 'chapter:' . $newChapter->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter'); + + $newChapterResp = $this->get($newChapter->getUrl()); + $newChapterResp->assertSee($page->name); + } + + public function test_page_move_from_chapter_to_book() + { + $oldChapter = Chapter::query()->first(); + $page = $oldChapter->pages()->first(); + $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first(); + + $movePageResp = $this->actingAs($this->users->editor())->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $page->refresh(); + + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book'); + $this->assertTrue($page->chapter === null, 'Page has no parent chapter'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee($page->name); + } + + public function test_page_move_requires_create_permissions_on_parent() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], $editor->roles->all()); + + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($movePageResp); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all()); + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $page->refresh(); + $movePageResp->assertRedirect($page->getUrl()); + + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_page_move_requires_delete_permissions() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create'], $editor->roles->all()); + + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($movePageResp); + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($page->getUrl('/move')); + + $this->permissions->setEntityPermissions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $movePageResp = $this->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $page->refresh(); + $movePageResp->assertRedirect($page->getUrl()); + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); + $chapterMoveResp->assertSee('Move Chapter'); + + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); + + $newBookResp = $this->get($newBook->getUrl()); + $newBookResp->assertSee('moved chapter'); + $newBookResp->assertSee($chapter->name); + + $pageToCheck = Page::query()->find($pageToCheck->id); + $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); + $pageCheckResp = $this->get($pageToCheck->getUrl()); + $pageCheckResp->assertSee($newBook->name); + } + + public function test_chapter_move_requires_delete_permissions() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create'], $editor->roles->all()); + + $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($moveChapterResp); + $pageView = $this->get($chapter->getUrl()); + $pageView->assertDontSee($chapter->getUrl('/move')); + + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all()); + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move_requires_create_permissions_in_new_book() + { + $chapter = $this->entities->chapter(); + $currentBook = $chapter->book; + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + $editor = $this->users->editor(); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]); + $this->permissions->setEntityPermissions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + + $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + $this->assertPermissionError($moveChapterResp); + + $this->permissions->setEntityPermissions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]); + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $chapter = Chapter::query()->find($chapter->id); + $moveChapterResp->assertRedirect($chapter->getUrl()); + $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move_changes_book_for_deleted_pages_within() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::query()->where('id', '!=', $currentBook->id)->first(); + + $pageToCheck->delete(); + + $this->asEditor()->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id, + ]); + + $pageToCheck->refresh(); + $this->assertEquals($newBook->id, $pageToCheck->book_id); + } +} From a208c46b628505cfdd5caf5294a248560c485f50 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 10 Feb 2025 17:14:06 +0000 Subject: [PATCH 318/800] Sorting: Covered sort set management with tests --- app/Sorting/SortSetController.php | 3 +- .../factories/Entities/Models/BookFactory.php | 4 +- lang/en/settings.php | 1 + .../parts/sort-set-list-item.blade.php | 2 +- tests/Sorting/SortSetTest.php | 200 ++++++++++++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Sorting/SortSetTest.php diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortSetController.php index b0ad2a7d766..7b1c0bc4160 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortSetController.php @@ -4,14 +4,13 @@ use BookStack\Activity\ActivityType; use BookStack\Http\Controller; -use BookStack\Http\Request; +use Illuminate\Http\Request; class SortSetController extends Controller { public function __construct() { $this->middleware('can:settings-manage'); - // TODO - Test } public function create() diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 9cb8e971c6e..29403a2949c 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -26,7 +26,9 @@ public function definition() 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

                                ' . e($description) . '

                                ' + 'description_html' => '

                                ' . e($description) . '

                                ', + 'sort_set_id' => null, + 'default_template_id' => null, ]; } } diff --git a/lang/en/settings.php b/lang/en/settings.php index 19ffd924049..344c186cb2f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -80,6 +80,7 @@ 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', 'sorting_sets' => 'Sort Sets', 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_set_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', 'sort_set_create' => 'Create Sort Set', 'sort_set_edit' => 'Edit Sort Set', 'sort_set_delete' => 'Delete Sort Set', diff --git a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php index e977c286e11..6c0b84047c0 100644 --- a/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php +++ b/resources/views/settings/sort-sets/parts/sort-set-list-item.blade.php @@ -6,7 +6,7 @@ {{ implode(', ', array_map(fn ($op) => $op->getLabel(), $set->getOperations())) }}
                                - @icon('book'){{ $set->books_count ?? 0 }}
                                \ No newline at end of file diff --git a/tests/Sorting/SortSetTest.php b/tests/Sorting/SortSetTest.php new file mode 100644 index 00000000000..5f30034bd77 --- /dev/null +++ b/tests/Sorting/SortSetTest.php @@ -0,0 +1,200 @@ +create(); + $user = $this->users->viewer(); + $this->actingAs($user); + + $actions = [ + ['GET', '/settings/sorting'], + ['POST', '/settings/sorting/sets'], + ['GET', "/settings/sorting/sets/{$set->id}"], + ['PUT', "/settings/sorting/sets/{$set->id}"], + ['DELETE', "/settings/sorting/sets/{$set->id}"], + ]; + + foreach ($actions as [$method, $path]) { + $resp = $this->call($method, $path); + $this->assertPermissionError($resp); + } + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + foreach ($actions as [$method, $path]) { + $resp = $this->call($method, $path); + $this->assertNotPermissionError($resp); + } + } + + public function test_create_flow() + { + $resp = $this->asAdmin()->get('/settings/sorting'); + $this->withHtml($resp)->assertLinkExists(url('/settings/sorting/sets/new')); + + $resp = $this->get('/settings/sorting/sets/new'); + $this->withHtml($resp)->assertElementExists('form[action$="/settings/sorting/sets"] input[name="name"]'); + $resp->assertSeeText('Name - Alphabetical (Asc)'); + + $details = ['name' => 'My new sort', 'sequence' => 'name_asc']; + $resp = $this->post('/settings/sorting/sets', $details); + $resp->assertRedirect('/settings/sorting'); + + $this->assertActivityExists(ActivityType::SORT_SET_CREATE); + $this->assertDatabaseHas('sort_sets', $details); + } + + public function test_listing_in_settings() + { + $set = SortSet::factory()->create(['name' => 'My super sort set', 'sequence' => 'name_asc']); + $books = Book::query()->limit(5)->get(); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + } + + $resp = $this->asAdmin()->get('/settings/sorting'); + $resp->assertSeeText('My super sort set'); + $resp->assertSeeText('Name - Alphabetical (Asc)'); + $this->withHtml($resp)->assertElementContains('.item-list-row [title="Assigned to 5 Books"]', '5'); + } + + public function test_update_flow() + { + $set = SortSet::factory()->create(['name' => 'My sort set to update', 'sequence' => 'name_asc']); + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $respHtml = $this->withHtml($resp); + $respHtml->assertElementContains('.configured-option-list', 'Name - Alphabetical (Asc)'); + $respHtml->assertElementNotContains('.available-option-list', 'Name - Alphabetical (Asc)'); + + $updateData = ['name' => 'My updated sort', 'sequence' => 'name_desc,chapters_last']; + $resp = $this->put("/settings/sorting/sets/{$set->id}", $updateData); + + $resp->assertRedirect('/settings/sorting'); + $this->assertActivityExists(ActivityType::SORT_SET_UPDATE); + $this->assertDatabaseHas('sort_sets', $updateData); + } + + public function test_update_triggers_resort_on_assigned_books() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $set = SortSet::factory()->create(['name' => 'My sort set to update', 'sequence' => 'name_asc']); + $book->sort_set_id = $set->id; + $book->save(); + $chapter->priority = 10000; + $chapter->save(); + + $resp = $this->asAdmin()->put("/settings/sorting/sets/{$set->id}", ['name' => $set->name, 'sequence' => 'chapters_last']); + $resp->assertRedirect('/settings/sorting'); + + $chapter->refresh(); + $this->assertNotEquals(10000, $chapter->priority); + } + + public function test_delete_flow() + { + $set = SortSet::factory()->create(); + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $resp->assertSeeText('Delete Sort Set'); + + $resp = $this->delete("settings/sorting/sets/{$set->id}"); + $resp->assertRedirect('/settings/sorting'); + + $this->assertActivityExists(ActivityType::SORT_SET_DELETE); + $this->assertDatabaseMissing('sort_sets', ['id' => $set->id]); + } + + public function test_delete_requires_confirmation_if_books_assigned() + { + $set = SortSet::factory()->create(); + $books = Book::query()->limit(5)->get(); + foreach ($books as $book) { + $book->sort_set_id = $set->id; + $book->save(); + } + + $resp = $this->asAdmin()->get("/settings/sorting/sets/{$set->id}"); + $resp->assertSeeText('Delete Sort Set'); + + $resp = $this->delete("settings/sorting/sets/{$set->id}"); + $resp->assertRedirect("/settings/sorting/sets/{$set->id}#delete"); + $resp = $this->followRedirects($resp); + + $resp->assertSeeText('This sort set is currently used on 5 book(s). Are you sure you want to delete this?'); + $this->assertDatabaseHas('sort_sets', ['id' => $set->id]); + + $resp = $this->delete("settings/sorting/sets/{$set->id}", ['confirm' => 'true']); + $resp->assertRedirect('/settings/sorting'); + $this->assertDatabaseMissing('sort_sets', ['id' => $set->id]); + $this->assertDatabaseMissing('books', ['sort_set_id' => $set->id]); + } + + public function test_page_create_triggers_book_sort() + { + $book = $this->entities->bookHasChaptersAndPages(); + $set = SortSet::factory()->create(['sequence' => 'name_asc,chapters_first']); + $book->sort_set_id = $set->id; + $book->save(); + + $resp = $this->actingAsApiEditor()->post("/api/pages", [ + 'book_id' => $book->id, + 'name' => '1111 page', + 'markdown' => 'Hi' + ]); + $resp->assertOk(); + + $this->assertDatabaseHas('pages', [ + 'book_id' => $book->id, + 'name' => '1111 page', + 'priority' => $book->chapters()->count() + 1, + ]); + } + + public function test_name_numeric_ordering() + { + $book = Book::factory()->create(); + $set = SortSet::factory()->create(['sequence' => 'name_numeric_asc']); + $book->sort_set_id = $set->id; + $book->save(); + $this->permissions->regenerateForEntity($book); + + $namesToAdd = [ + "1 - Pizza", + "2.0 - Tomato", + "2.5 - Beans", + "10 - Bread", + "20 - Milk", + ]; + + foreach ($namesToAdd as $name) { + $this->actingAsApiEditor()->post("/api/pages", [ + 'book_id' => $book->id, + 'name' => $name, + 'markdown' => 'Hello' + ]); + } + + foreach ($namesToAdd as $index => $name) { + $this->assertDatabaseHas('pages', [ + 'book_id' => $book->id, + 'name' => $name, + 'priority' => $index + 1, + ]); + } + } +} From b9306a9029f41f3779b755d08c907a2edafc33df Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 11 Feb 2025 14:36:25 +0000 Subject: [PATCH 319/800] Sorting: Renamed sort set to sort rule Renamed based on feedback from Tim and Script on Discord. Also fixed flaky test --- app/Activity/ActivityType.php | 6 +- app/Console/Commands/AssignSortSetCommand.php | 6 +- app/Entities/Models/Book.php | 10 +-- app/Entities/Repos/BookRepo.php | 6 +- app/Sorting/BookSortController.php | 4 +- app/Sorting/BookSorter.php | 6 +- app/Sorting/{SortSet.php => SortRule.php} | 12 +-- ...tController.php => SortRuleController.php} | 58 ++++++------ ...SetOperation.php => SortRuleOperation.php} | 18 ++-- .../factories/Entities/Models/BookFactory.php | 2 +- ...SortSetFactory.php => SortRuleFactory.php} | 10 +-- ..._01_29_180933_create_sort_rules_table.php} | 4 +- ...2_05_150842_add_sort_rule_id_to_books.php} | 4 +- lang/en/entities.php | 2 +- lang/en/settings.php | 52 +++++------ resources/js/components/index.ts | 2 +- ...rt-set-manager.ts => sort-rule-manager.ts} | 4 +- .../views/books/parts/sort-box.blade.php | 19 ++-- resources/views/books/sort.blade.php | 14 +-- .../settings/categories/sorting.blade.php | 17 ++-- .../create.blade.php | 6 +- .../{sort-sets => sort-rules}/edit.blade.php | 12 +-- .../parts/form.blade.php | 36 ++++---- .../parts/operation.blade.php | 0 .../parts/sort-rule-list-item.blade.php} | 8 +- routes/web.php | 12 +-- tests/Commands/AssignSortSetCommandTest.php | 22 ++--- tests/Entity/PageTest.php | 2 +- tests/Sorting/BookSortTest.php | 12 +-- .../{SortSetTest.php => SortRuleTest.php} | 90 +++++++++---------- 30 files changed, 232 insertions(+), 224 deletions(-) rename app/Sorting/{SortSet.php => SortRule.php} (77%) rename app/Sorting/{SortSetController.php => SortRuleController.php} (51%) rename app/Sorting/{SortSetOperation.php => SortRuleOperation.php} (74%) rename database/factories/Sorting/{SortSetFactory.php => SortRuleFactory.php} (70%) rename database/migrations/{2025_01_29_180933_create_sort_sets_table.php => 2025_01_29_180933_create_sort_rules_table.php} (82%) rename database/migrations/{2025_02_05_150842_add_sort_set_id_to_books.php => 2025_02_05_150842_add_sort_rule_id_to_books.php} (79%) rename resources/js/components/{sort-set-manager.ts => sort-rule-manager.ts} (93%) rename resources/views/settings/{sort-sets => sort-rules}/create.blade.php (70%) rename resources/views/settings/{sort-sets => sort-rules}/edit.blade.php (83%) rename resources/views/settings/{sort-sets => sort-rules}/parts/form.blade.php (53%) rename resources/views/settings/{sort-sets => sort-rules}/parts/operation.blade.php (100%) rename resources/views/settings/{sort-sets/parts/sort-set-list-item.blade.php => sort-rules/parts/sort-rule-list-item.blade.php} (53%) rename tests/Sorting/{SortSetTest.php => SortRuleTest.php} (58%) diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 4a648da6cfb..a7f129f71d4 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -71,9 +71,9 @@ class ActivityType const IMPORT_RUN = 'import_run'; const IMPORT_DELETE = 'import_delete'; - const SORT_SET_CREATE = 'sort_set_create'; - const SORT_SET_UPDATE = 'sort_set_update'; - const SORT_SET_DELETE = 'sort_set_delete'; + const SORT_RULE_CREATE = 'sort_rule_create'; + const SORT_RULE_UPDATE = 'sort_rule_update'; + const SORT_RULE_DELETE = 'sort_rule_delete'; /** * Get all the possible values. diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortSetCommand.php index 484f6995255..6c9d3f7641a 100644 --- a/app/Console/Commands/AssignSortSetCommand.php +++ b/app/Console/Commands/AssignSortSetCommand.php @@ -4,7 +4,7 @@ use BookStack\Entities\Models\Book; use BookStack\Sorting\BookSorter; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use Illuminate\Console\Command; class AssignSortSetCommand extends Command @@ -37,7 +37,7 @@ public function handle(BookSorter $sorter): int return $this->listSortSets(); } - $set = SortSet::query()->find($sortSetId); + $set = SortRule::query()->find($sortSetId); if ($this->option('all-books')) { $query = Book::query(); } else if ($this->option('books-without-sort')) { @@ -87,7 +87,7 @@ public function handle(BookSorter $sorter): int protected function listSortSets(): int { - $sets = SortSet::query()->orderBy('id', 'asc')->get(); + $sets = SortRule::query()->orderBy('id', 'asc')->get(); $this->error("Sort set ID required!"); $this->warn("\nAvailable sort sets:"); foreach ($sets as $set) { diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 7d240e5cab9..ede4fc7d5a6 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,7 +2,7 @@ namespace BookStack\Entities\Models; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use BookStack\Uploads\Image; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,14 +17,14 @@ * @property string $description * @property int $image_id * @property ?int $default_template_id - * @property ?int $sort_set_id + * @property ?int $sort_rule_id * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?Page $defaultTemplate - * @property ?SortSet $sortSet + * @property ?SortRule $sortRule */ class Book extends Entity implements HasCoverImage { @@ -88,9 +88,9 @@ public function defaultTemplate(): BelongsTo /** * Get the sort set assigned to this book, if existing. */ - public function sortSet(): BelongsTo + public function sortRule(): BelongsTo { - return $this->belongsTo(SortSet::class); + return $this->belongsTo(SortRule::class); } /** diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index b3b81164785..92e6a81c337 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -8,7 +8,7 @@ use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\ImageUploadException; use BookStack\Facades\Activity; -use BookStack\Sorting\SortSet; +use BookStack\Sorting\SortRule; use BookStack\Uploads\ImageRepo; use Exception; use Illuminate\Http\UploadedFile; @@ -35,8 +35,8 @@ public function create(array $input): Book Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); - if ($defaultBookSortSetting && SortSet::query()->find($defaultBookSortSetting)) { - $book->sort_set_id = $defaultBookSortSetting; + if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { + $book->sort_rule_id = $defaultBookSortSetting; $book->save(); } diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index 98d79d0fd12..479d1972440 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -69,10 +69,10 @@ public function update(Request $request, BookSorter $sorter, string $bookSlug) if ($request->filled('auto-sort')) { $sortSetId = intval($request->get('auto-sort')) ?: null; - if ($sortSetId && SortSet::query()->find($sortSetId) === null) { + if ($sortSetId && SortRule::query()->find($sortSetId) === null) { $sortSetId = null; } - $book->sort_set_id = $sortSetId; + $book->sort_rule_id = $sortSetId; $book->save(); $sorter->runBookAutoSort($book); if (!$loggedActivityForBook) { diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index b6fe33b9c3e..7bf1b63f46c 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -16,7 +16,7 @@ public function __construct( ) { } - public function runBookAutoSortForAllWithSet(SortSet $set): void + public function runBookAutoSortForAllWithSet(SortRule $set): void { $set->books()->chunk(50, function ($books) { foreach ($books as $book) { @@ -32,12 +32,12 @@ public function runBookAutoSortForAllWithSet(SortSet $set): void */ public function runBookAutoSort(Book $book): void { - $set = $book->sortSet; + $set = $book->sortRule; if (!$set) { return; } - $sortFunctions = array_map(function (SortSetOperation $op) { + $sortFunctions = array_map(function (SortRuleOperation $op) { return $op->getSortFunction(); }, $set->getOperations()); diff --git a/app/Sorting/SortSet.php b/app/Sorting/SortRule.php similarity index 77% rename from app/Sorting/SortSet.php rename to app/Sorting/SortRule.php index cc8879f9657..45e5514fd5f 100644 --- a/app/Sorting/SortSet.php +++ b/app/Sorting/SortRule.php @@ -17,24 +17,24 @@ * @property Carbon $created_at * @property Carbon $updated_at */ -class SortSet extends Model implements Loggable +class SortRule extends Model implements Loggable { use HasFactory; /** - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public function getOperations(): array { - return SortSetOperation::fromSequence($this->sequence); + return SortRuleOperation::fromSequence($this->sequence); } /** - * @param SortSetOperation[] $options + * @param SortRuleOperation[] $options */ public function setOperations(array $options): void { - $values = array_map(fn (SortSetOperation $opt) => $opt->value, $options); + $values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options); $this->sequence = implode(',', $values); } @@ -45,7 +45,7 @@ public function logDescriptor(): string public function getUrl(): string { - return url("/settings/sorting/sets/{$this->id}"); + return url("/settings/sorting/rules/{$this->id}"); } public function books(): HasMany diff --git a/app/Sorting/SortSetController.php b/app/Sorting/SortRuleController.php similarity index 51% rename from app/Sorting/SortSetController.php rename to app/Sorting/SortRuleController.php index 7b1c0bc4160..96b8e8ef53e 100644 --- a/app/Sorting/SortSetController.php +++ b/app/Sorting/SortRuleController.php @@ -6,7 +6,7 @@ use BookStack\Http\Controller; use Illuminate\Http\Request; -class SortSetController extends Controller +class SortRuleController extends Controller { public function __construct() { @@ -15,9 +15,9 @@ public function __construct() public function create() { - $this->setPageTitle(trans('settings.sort_set_create')); + $this->setPageTitle(trans('settings.sort_rule_create')); - return view('settings.sort-sets.create'); + return view('settings.sort-rules.create'); } public function store(Request $request) @@ -27,28 +27,28 @@ public function store(Request $request) 'sequence' => ['required', 'string', 'min:1'], ]); - $operations = SortSetOperation::fromSequence($request->input('sequence')); + $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']); } - $set = new SortSet(); - $set->name = $request->input('name'); - $set->setOperations($operations); - $set->save(); + $rule = new SortRule(); + $rule->name = $request->input('name'); + $rule->setOperations($operations); + $rule->save(); - $this->logActivity(ActivityType::SORT_SET_CREATE, $set); + $this->logActivity(ActivityType::SORT_RULE_CREATE, $rule); return redirect('/settings/sorting'); } public function edit(string $id) { - $set = SortSet::query()->findOrFail($id); + $rule = SortRule::query()->findOrFail($id); - $this->setPageTitle(trans('settings.sort_set_edit')); + $this->setPageTitle(trans('settings.sort_rule_edit')); - return view('settings.sort-sets.edit', ['set' => $set]); + return view('settings.sort-rules.edit', ['rule' => $rule]); } public function update(string $id, Request $request, BookSorter $bookSorter) @@ -58,21 +58,21 @@ public function update(string $id, Request $request, BookSorter $bookSorter) 'sequence' => ['required', 'string', 'min:1'], ]); - $set = SortSet::query()->findOrFail($id); - $operations = SortSetOperation::fromSequence($request->input('sequence')); + $rule = SortRule::query()->findOrFail($id); + $operations = SortRuleOperation::fromSequence($request->input('sequence')); if (count($operations) === 0) { - return redirect($set->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); + return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']); } - $set->name = $request->input('name'); - $set->setOperations($operations); - $changedSequence = $set->isDirty('sequence'); - $set->save(); + $rule->name = $request->input('name'); + $rule->setOperations($operations); + $changedSequence = $rule->isDirty('sequence'); + $rule->save(); - $this->logActivity(ActivityType::SORT_SET_UPDATE, $set); + $this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule); if ($changedSequence) { - $bookSorter->runBookAutoSortForAllWithSet($set); + $bookSorter->runBookAutoSortForAllWithSet($rule); } return redirect('/settings/sorting'); @@ -80,16 +80,16 @@ public function update(string $id, Request $request, BookSorter $bookSorter) public function destroy(string $id, Request $request) { - $set = SortSet::query()->findOrFail($id); + $rule = SortRule::query()->findOrFail($id); $confirmed = $request->input('confirm') === 'true'; - $booksAssigned = $set->books()->count(); + $booksAssigned = $rule->books()->count(); $warnings = []; if ($booksAssigned > 0) { if ($confirmed) { - $set->books()->update(['sort_set_id' => null]); + $rule->books()->update(['sort_rule_id' => null]); } else { - $warnings[] = trans('settings.sort_set_delete_warn_books', ['count' => $booksAssigned]); + $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); } } @@ -98,16 +98,16 @@ public function destroy(string $id, Request $request) if ($confirmed) { setting()->remove('sorting-book-default'); } else { - $warnings[] = trans('settings.sort_set_delete_warn_default'); + $warnings[] = trans('settings.sort_rule_delete_warn_default'); } } if (count($warnings) > 0) { - return redirect($set->getUrl() . '#delete')->withErrors(['delete' => $warnings]); + return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]); } - $set->delete(); - $this->logActivity(ActivityType::SORT_SET_DELETE, $set); + $rule->delete(); + $this->logActivity(ActivityType::SORT_RULE_DELETE, $rule); return redirect('/settings/sorting'); } diff --git a/app/Sorting/SortSetOperation.php b/app/Sorting/SortRuleOperation.php similarity index 74% rename from app/Sorting/SortSetOperation.php rename to app/Sorting/SortRuleOperation.php index 7fdd0b002bf..0d8ff239f8d 100644 --- a/app/Sorting/SortSetOperation.php +++ b/app/Sorting/SortRuleOperation.php @@ -5,7 +5,7 @@ use Closure; use Illuminate\Support\Str; -enum SortSetOperation: string +enum SortRuleOperation: string { case NameAsc = 'name_asc'; case NameDesc = 'name_desc'; @@ -26,13 +26,13 @@ public function getLabel(): string $label = ''; if (str_ends_with($key, '_asc')) { $key = substr($key, 0, -4); - $label = trans('settings.sort_set_op_asc'); + $label = trans('settings.sort_rule_op_asc'); } elseif (str_ends_with($key, '_desc')) { $key = substr($key, 0, -5); - $label = trans('settings.sort_set_op_desc'); + $label = trans('settings.sort_rule_op_desc'); } - $label = trans('settings.sort_set_op_' . $key) . ' ' . $label; + $label = trans('settings.sort_rule_op_' . $key) . ' ' . $label; return trim($label); } @@ -43,12 +43,12 @@ public function getSortFunction(): callable } /** - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public static function allExcluding(array $operations): array { - $all = SortSetOperation::cases(); - $filtered = array_filter($all, function (SortSetOperation $operation) use ($operations) { + $all = SortRuleOperation::cases(); + $filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) { return !in_array($operation, $operations); }); return array_values($filtered); @@ -57,12 +57,12 @@ public static function allExcluding(array $operations): array /** * Create a set of operations from a string sequence representation. * (values seperated by commas). - * @return SortSetOperation[] + * @return SortRuleOperation[] */ public static function fromSequence(string $sequence): array { $strOptions = explode(',', $sequence); - $options = array_map(fn ($val) => SortSetOperation::tryFrom($val), $strOptions); + $options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions); return array_filter($options); } } diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php index 29403a2949c..48d43d7a8b7 100644 --- a/database/factories/Entities/Models/BookFactory.php +++ b/database/factories/Entities/Models/BookFactory.php @@ -27,7 +27,7 @@ public function definition() 'slug' => Str::random(10), 'description' => $description, 'description_html' => '

                                ' . e($description) . '

                                ', - 'sort_set_id' => null, + 'sort_rule_id' => null, 'default_template_id' => null, ]; } diff --git a/database/factories/Sorting/SortSetFactory.php b/database/factories/Sorting/SortRuleFactory.php similarity index 70% rename from database/factories/Sorting/SortSetFactory.php rename to database/factories/Sorting/SortRuleFactory.php index 36e0a697677..dafe8c3fa74 100644 --- a/database/factories/Sorting/SortSetFactory.php +++ b/database/factories/Sorting/SortRuleFactory.php @@ -2,25 +2,25 @@ namespace Database\Factories\Sorting; -use BookStack\Sorting\SortSet; -use BookStack\Sorting\SortSetOperation; +use BookStack\Sorting\SortRule; +use BookStack\Sorting\SortRuleOperation; use Illuminate\Database\Eloquent\Factories\Factory; -class SortSetFactory extends Factory +class SortRuleFactory extends Factory { /** * The name of the factory's corresponding model. * * @var string */ - protected $model = SortSet::class; + protected $model = SortRule::class; /** * Define the model's default state. */ public function definition(): array { - $cases = SortSetOperation::cases(); + $cases = SortRuleOperation::cases(); $op = $cases[array_rand($cases)]; return [ 'name' => $op->name . ' Sort', diff --git a/database/migrations/2025_01_29_180933_create_sort_sets_table.php b/database/migrations/2025_01_29_180933_create_sort_rules_table.php similarity index 82% rename from database/migrations/2025_01_29_180933_create_sort_sets_table.php rename to database/migrations/2025_01_29_180933_create_sort_rules_table.php index bf9780c5b7e..37d20ddf618 100644 --- a/database/migrations/2025_01_29_180933_create_sort_sets_table.php +++ b/database/migrations/2025_01_29_180933_create_sort_rules_table.php @@ -11,7 +11,7 @@ */ public function up(): void { - Schema::create('sort_sets', function (Blueprint $table) { + Schema::create('sort_rules', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->text('sequence'); @@ -24,6 +24,6 @@ public function up(): void */ public function down(): void { - Schema::dropIfExists('sort_sets'); + Schema::dropIfExists('sort_rules'); } }; diff --git a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php similarity index 79% rename from database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php rename to database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php index c0b32c55288..106db05caa0 100644 --- a/database/migrations/2025_02_05_150842_add_sort_set_id_to_books.php +++ b/database/migrations/2025_02_05_150842_add_sort_rule_id_to_books.php @@ -12,7 +12,7 @@ public function up(): void { Schema::table('books', function (Blueprint $table) { - $table->unsignedInteger('sort_set_id')->nullable()->default(null); + $table->unsignedInteger('sort_rule_id')->nullable()->default(null); }); } @@ -22,7 +22,7 @@ public function up(): void public function down(): void { Schema::table('books', function (Blueprint $table) { - $table->dropColumn('sort_set_id'); + $table->dropColumn('sort_rule_id'); }); } }; diff --git a/lang/en/entities.php b/lang/en/entities.php index 28a209fa21a..a74785eaacd 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -166,7 +166,7 @@ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', - 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort option can be set to automatically sort this book\'s contents upon changes.', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books. Optionally an auto sort rule can be set to automatically sort this book\'s contents upon changes.', 'books_sort_auto_sort' => 'Auto Sort Option', 'books_sort_auto_sort_active' => 'Auto Sort Active: :sortName', 'books_sort_named' => 'Sort Book :bookName', diff --git a/lang/en/settings.php b/lang/en/settings.php index 344c186cb2f..098479f3be0 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -77,32 +77,32 @@ // Sorting Settings 'sorting' => 'Sorting', 'sorting_book_default' => 'Default Book Sort', - 'sorting_book_default_desc' => 'Select the default sort set to apply to new books. This won\'t affect existing books, and can be overridden per-book.', - 'sorting_sets' => 'Sort Sets', - 'sorting_sets_desc' => 'These are predefined sorting operations which can be applied to content in the system.', - 'sort_set_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', - 'sort_set_create' => 'Create Sort Set', - 'sort_set_edit' => 'Edit Sort Set', - 'sort_set_delete' => 'Delete Sort Set', - 'sort_set_delete_desc' => 'Remove this sort set from the system. Books using this sort will revert to manual sorting.', - 'sort_set_delete_warn_books' => 'This sort set is currently used on :count book(s). Are you sure you want to delete this?', - 'sort_set_delete_warn_default' => 'This sort set is currently used as the default for books. Are you sure you want to delete this?', - 'sort_set_details' => 'Sort Set Details', - 'sort_set_details_desc' => 'Set a name for this sort set, which will appear in lists when users are selecting a sort.', - 'sort_set_operations' => 'Sort Operations', - 'sort_set_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', - 'sort_set_available_operations' => 'Available Operations', - 'sort_set_available_operations_empty' => 'No operations remaining', - 'sort_set_configured_operations' => 'Configured Operations', - 'sort_set_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', - 'sort_set_op_asc' => '(Asc)', - 'sort_set_op_desc' => '(Desc)', - 'sort_set_op_name' => 'Name - Alphabetical', - 'sort_set_op_name_numeric' => 'Name - Numeric', - 'sort_set_op_created_date' => 'Created Date', - 'sort_set_op_updated_date' => 'Updated Date', - 'sort_set_op_chapters_first' => 'Chapters First', - 'sort_set_op_chapters_last' => 'Chapters Last', + 'sorting_book_default_desc' => 'Select the default sort role to apply to new books. This won\'t affect existing books, and can be overridden per-book.', + 'sorting_rules' => 'Sort Rules', + 'sorting_rules_desc' => 'These are predefined sorting operations which can be applied to content in the system.', + 'sort_rule_assigned_to_x_books' => 'Assigned to :count Book|Assigned to :count Books', + 'sort_rule_create' => 'Create Sort Rule', + 'sort_rule_edit' => 'Edit Sort Rule', + 'sort_rule_delete' => 'Delete Sort Rule', + 'sort_rule_delete_desc' => 'Remove this sort rule from the system. Books using this sort will revert to manual sorting.', + 'sort_rule_delete_warn_books' => 'This sort rule is currently used on :count book(s). Are you sure you want to delete this?', + 'sort_rule_delete_warn_default' => 'This sort rule is currently used as the default for books. Are you sure you want to delete this?', + 'sort_rule_details' => 'Sort Rule Details', + 'sort_rule_details_desc' => 'Set a name for this sort rule, which will appear in lists when users are selecting a sort.', + 'sort_rule_operations' => 'Sort Operations', + 'sort_rule_operations_desc' => 'Configure the sort actions to be performed in this set by moving them from the list of available operations. Upon use, the operations will be applied in order, from top to bottom.', + 'sort_rule_available_operations' => 'Available Operations', + 'sort_rule_available_operations_empty' => 'No operations remaining', + 'sort_rule_configured_operations' => 'Configured Operations', + 'sort_rule_configured_operations_empty' => 'Drag/add operations from the "Available Operations" list', + 'sort_rule_op_asc' => '(Asc)', + 'sort_rule_op_desc' => '(Desc)', + 'sort_rule_op_name' => 'Name - Alphabetical', + 'sort_rule_op_name_numeric' => 'Name - Numeric', + 'sort_rule_op_created_date' => 'Created Date', + 'sort_rule_op_updated_date' => 'Updated Date', + 'sort_rule_op_chapters_first' => 'Chapters First', + 'sort_rule_op_chapters_last' => 'Chapters Last', // Maintenance settings 'maint' => 'Maintenance', diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index affa25fcf31..10b8025db63 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -50,7 +50,7 @@ export {ShelfSort} from './shelf-sort'; export {Shortcuts} from './shortcuts'; export {ShortcutInput} from './shortcut-input'; export {SortableList} from './sortable-list'; -export {SortSetManager} from './sort-set-manager' +export {SortRuleManager} from './sort-rule-manager' export {SubmitOnChange} from './submit-on-change'; export {Tabs} from './tabs'; export {TagManager} from './tag-manager'; diff --git a/resources/js/components/sort-set-manager.ts b/resources/js/components/sort-rule-manager.ts similarity index 93% rename from resources/js/components/sort-set-manager.ts rename to resources/js/components/sort-rule-manager.ts index c35ad41fe07..ff08f4ab878 100644 --- a/resources/js/components/sort-set-manager.ts +++ b/resources/js/components/sort-rule-manager.ts @@ -3,7 +3,7 @@ import Sortable from "sortablejs"; import {buildListActions, sortActionClickListener} from "../services/dual-lists"; -export class SortSetManager extends Component { +export class SortRuleManager extends Component { protected input!: HTMLInputElement; protected configuredList!: HTMLElement; @@ -25,7 +25,7 @@ export class SortSetManager extends Component { const scrollBoxes = [this.configuredList, this.availableList]; for (const scrollBox of scrollBoxes) { new Sortable(scrollBox, { - group: 'sort-set-operations', + group: 'sort-rule-operations', ghostClass: 'primary-background-light', handle: '.handle', animation: 150, diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index 232616168f9..6fdb1819e61 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -9,18 +9,23 @@ {{ $book->name }}
                                - @if($book->sortSet) - @icon('auto-sort') + @if($book->sortRule) + @icon('auto-sort') @endif
                                - - - - - + + + + +