]> BookStack Code Mirror - bookstack/commitdiff
Comments: Switched to lexical editor
authorDan Brown <redacted>
Wed, 25 Jun 2025 13:16:01 +0000 (14:16 +0100)
committerDan Brown <redacted>
Wed, 25 Jun 2025 13:16:01 +0000 (14:16 +0100)
Required a lot of changes to provide at least a decent attempt at proper
editor teardown control.
Also updates HtmlDescriptionFilter and testing to address issue with bad
child iteration which could lead to missed items.
Renamed editor version from comments to basic as it'll also be used for
item descriptions.

16 files changed:
app/Util/HtmlDescriptionFilter.php
resources/js/components/page-comment.ts
resources/js/components/page-comments.ts
resources/js/wysiwyg/index.ts
resources/js/wysiwyg/nodes.ts
resources/js/wysiwyg/ui/defaults/toolbars.ts
resources/js/wysiwyg/ui/framework/core.ts
resources/js/wysiwyg/ui/framework/decorator.ts
resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts
resources/js/wysiwyg/ui/framework/manager.ts
resources/js/wysiwyg/ui/framework/modals.ts
resources/js/wysiwyg/ui/framework/toolbars.ts
resources/js/wysiwyg/utils/actions.ts
resources/views/comments/comment.blade.php
resources/views/comments/comments.blade.php
tests/Entity/CommentStoreTest.php

index cb091b869f8fc9a2ec5d4a9ba644387612f82c95..d4f7d2c8fa258fc3c4489099e43202814153e1d5 100644 (file)
@@ -4,7 +4,6 @@ namespace BookStack\Util;
 
 use DOMAttr;
 use DOMElement;
-use DOMNamedNodeMap;
 use DOMNode;
 
 /**
@@ -25,6 +24,7 @@ class HtmlDescriptionFilter
         'ul' => [],
         'li' => [],
         'strong' => [],
+        'span' => [],
         'em' => [],
         'br' => [],
     ];
@@ -59,7 +59,6 @@ class HtmlDescriptionFilter
             return;
         }
 
-        /** @var DOMNamedNodeMap $attrs */
         $attrs = $element->attributes;
         for ($i = $attrs->length - 1; $i >= 0; $i--) {
             /** @var DOMAttr $attr */
@@ -70,7 +69,8 @@ class HtmlDescriptionFilter
             }
         }
 
-        foreach ($element->childNodes as $child) {
+        $childNodes = [...$element->childNodes];
+        foreach ($childNodes as $child) {
             if ($child instanceof DOMElement) {
                 static::filterElement($child);
             }
index a0bb7a55bf8985c3c54cf8358e7dc4e3b67f6dd9..8334ebb8a092ce25bc1783d2a9930df6a215cffe 100644 (file)
@@ -1,8 +1,9 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
-import {buildForInput} from '../wysiwyg-tinymce/config';
 import {PageCommentReference} from "./page-comment-reference";
 import {HttpError} from "../services/http";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
+import {el} from "../wysiwyg/utils/dom";
 
 export interface PageCommentReplyEventData {
     id: string; // ID of comment being replied to
@@ -21,8 +22,7 @@ export class PageComment extends Component {
     protected updatedText!: string;
     protected archiveText!: string;
 
-    protected wysiwygEditor: any = null;
-    protected wysiwygLanguage!: string;
+    protected wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
     protected wysiwygTextDirection!: string;
 
     protected container!: HTMLElement;
@@ -44,7 +44,6 @@ export class PageComment extends Component {
         this.archiveText = this.$opts.archiveText;
 
         // Editor reference and text options
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
         this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
 
         // Element references
@@ -90,7 +89,7 @@ export class PageComment extends Component {
         this.form.toggleAttribute('hidden', !show);
     }
 
-    protected startEdit() : void {
+    protected async startEdit(): Promise<void> {
         this.toggleEditMode(true);
 
         if (this.wysiwygEditor) {
@@ -98,21 +97,20 @@ export class PageComment extends Component {
             return;
         }
 
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.input,
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const editorContent = this.input.value;
+        const container = el('div', {class: 'comment-editor-container'});
+        this.input.parentElement?.appendChild(container);
+        this.input.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, editorContent, {
             darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.wysiwygTextDirection,
-            drawioUrl: '',
-            pageId: 0,
-            translations: {},
-            translationMap: (window as unknown as Record<string, Object>).editor_translations,
+            textDirection: this.$opts.textDirection,
+            translations: (window as unknown as Record<string, Object>).editor_translations,
         });
 
-        (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
-            this.wysiwygEditor = editors[0];
-            setTimeout(() => this.wysiwygEditor.focus(), 50);
-        });
+        this.wysiwygEditor.focus();
     }
 
     protected async update(event: Event): Promise<void> {
@@ -121,7 +119,7 @@ export class PageComment extends Component {
         this.form.toggleAttribute('hidden', true);
 
         const reqData = {
-            html: this.wysiwygEditor.getContent(),
+            html: await this.wysiwygEditor?.getContentAsHtml() || '',
         };
 
         try {
index 5c1cd014c54d8a67a7012e85a693ec0825f9c690..e988343cabbfa08c06bd75c6b9dfe8cccac16f69 100644 (file)
@@ -1,10 +1,11 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
-import {buildForInput} from '../wysiwyg-tinymce/config';
 import {Tabs} from "./tabs";
 import {PageCommentReference} from "./page-comment-reference";
 import {scrollAndHighlightElement} from "../services/util";
 import {PageCommentArchiveEventData, PageCommentReplyEventData} from "./page-comment";
+import {el} from "../wysiwyg/utils/dom";
+import {SimpleWysiwygEditorInterface} from "../wysiwyg";
 
 export class PageComments extends Component {
 
@@ -28,9 +29,8 @@ export class PageComments extends Component {
     private hideFormButton!: HTMLElement;
     private removeReplyToButton!: HTMLElement;
     private removeReferenceButton!: HTMLElement;
-    private wysiwygLanguage!: string;
     private wysiwygTextDirection!: string;
-    private wysiwygEditor: any = null;
+    private wysiwygEditor: SimpleWysiwygEditorInterface|null = null;
     private createdText!: string;
     private countText!: string;
     private archivedCountText!: string;
@@ -63,7 +63,6 @@ export class PageComments extends Component {
         this.removeReferenceButton = this.$refs.removeReferenceButton;
 
         // WYSIWYG options
-        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
         this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
 
         // Translations
@@ -107,7 +106,7 @@ export class PageComments extends Component {
         }
     }
 
-    protected saveComment(event: SubmitEvent): void {
+    protected async saveComment(event: SubmitEvent): Promise<void> {
         event.preventDefault();
         event.stopPropagation();
 
@@ -117,7 +116,7 @@ export class PageComments extends Component {
         this.form.toggleAttribute('hidden', true);
 
         const reqData = {
-            html: this.wysiwygEditor.getContent(),
+            html: (await this.wysiwygEditor?.getContentAsHtml()) || '',
             parent_id: this.parentId || null,
             content_ref: this.contentReference,
         };
@@ -189,27 +188,25 @@ export class PageComments extends Component {
         this.addButtonContainer.toggleAttribute('hidden', false);
     }
 
-    protected loadEditor(): void {
+    protected async loadEditor(): Promise<void> {
         if (this.wysiwygEditor) {
             this.wysiwygEditor.focus();
             return;
         }
 
-        const config = buildForInput({
-            language: this.wysiwygLanguage,
-            containerElement: this.formInput,
+        type WysiwygModule = typeof import('../wysiwyg');
+        const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule;
+        const container = el('div', {class: 'comment-editor-container'});
+        this.formInput.parentElement?.appendChild(container);
+        this.formInput.hidden = true;
+
+        this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, '', {
             darkMode: document.documentElement.classList.contains('dark-mode'),
             textDirection: this.wysiwygTextDirection,
-            drawioUrl: '',
-            pageId: 0,
-            translations: {},
-            translationMap: (window as unknown as Record<string, Object>).editor_translations,
+            translations: (window as unknown as Record<string, Object>).editor_translations,
         });
 
-        (window as unknown as {tinymce: {init: (arg0: Object) => Promise<any>}}).tinymce.init(config).then(editors => {
-            this.wysiwygEditor = editors[0];
-            setTimeout(() => this.wysiwygEditor.focus(), 50);
-        });
+        this.wysiwygEditor.focus();
     }
 
     protected removeEditor(): void {
index 8e98780d58574c4ca98fc9fba2a904ddcd3e1e3d..8f6c41c1ac4f118b9103cc32c03130e9e06177f0 100644 (file)
@@ -2,9 +2,9 @@ import {createEditor, LexicalEditor} from 'lexical';
 import {createEmptyHistoryState, registerHistory} from '@lexical/history';
 import {registerRichText} from '@lexical/rich-text';
 import {mergeRegister} from '@lexical/utils';
-import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
+import {getNodesForBasicEditor, getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
 import {buildEditorUI} from "./ui";
-import {getEditorContentAsHtml, setEditorContentFromHtml} from "./utils/actions";
+import {focusEditor, 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";
@@ -15,7 +15,7 @@ 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";
-import {contextToolbars, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
+import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "./ui/defaults/toolbars";
 import {modals} from "./ui/defaults/modals";
 import {CodeBlockDecorator} from "./ui/decorators/code-block";
 import {DiagramDecorator} from "./ui/decorators/diagram";
@@ -90,20 +90,20 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
 
     registerCommonNodeMutationListeners(context);
 
-    return new SimpleWysiwygEditorInterface(editor);
+    return new SimpleWysiwygEditorInterface(context);
 }
 
-export function createCommentEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
+export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
     const editor = createEditor({
-        namespace: 'BookStackCommentEditor',
-        nodes: getNodesForPageEditor(),
+        namespace: 'BookStackBasicEditor',
+        nodes: getNodesForBasicEditor(),
         onError: console.error,
         theme: theme,
     });
     const context: EditorUiContext = buildEditorUI(container, editor, options);
     editor.setRootElement(context.editorDOM);
 
-    mergeRegister(
+    const editorTeardown = mergeRegister(
         registerRichText(editor),
         registerHistory(editor, createEmptyHistoryState(), 300),
         registerShortcuts(context),
@@ -111,23 +111,33 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
     );
 
     // Register toolbars, modals & decorators
-    context.manager.setToolbar(getMainEditorFullToolbar(context)); // TODO - Create comment toolbar
+    context.manager.setToolbar(getBasicEditorToolbar(context));
     context.manager.registerContextToolbar('link', contextToolbars.link);
     context.manager.registerModal('link', modals.link);
+    context.manager.onTeardown(editorTeardown);
 
     setEditorContentFromHtml(editor, htmlContent);
 
-    return new SimpleWysiwygEditorInterface(editor);
+    return new SimpleWysiwygEditorInterface(context);
 }
 
 export class SimpleWysiwygEditorInterface {
-    protected editor: LexicalEditor;
+    protected context: EditorUiContext;
 
-    constructor(editor: LexicalEditor) {
-        this.editor = editor;
+    constructor(context: EditorUiContext) {
+        this.context = context;
     }
 
     async getContentAsHtml(): Promise<string> {
-        return await getEditorContentAsHtml(this.editor);
+        return await getEditorContentAsHtml(this.context.editor);
+    }
+
+    focus(): void {
+        focusEditor(this.context.editor);
+    }
+
+    remove() {
+        this.context.editorDOM.remove();
+        this.context.manager.teardown();
     }
 }
\ No newline at end of file
index c1db0f0869fc3b59b110652598b4e8158c692d1c..413e2c4cd3f7cef0d97be5022ecee44208aa9c2d 100644 (file)
@@ -20,9 +20,6 @@ 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.
- */
 export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
     return [
         CalloutNode,
@@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
     ];
 }
 
+export function getNodesForBasicEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
+    return [
+        ListNode,
+        ListItemNode,
+        ParagraphNode,
+        LinkNode,
+    ];
+}
+
 export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
     const decorated = [ImageNode, CodeBlockNode, DiagramNode];
 
@@ -53,7 +59,7 @@ export function registerCommonNodeMutationListeners(context: EditorUiContext): v
             if (mutation === "destroyed") {
                 const decorator = context.manager.getDecoratorByNodeKey(nodeKey);
                 if (decorator) {
-                    decorator.destroy(context);
+                    decorator.teardown();
                 }
             }
         }
index fc413bb8f80bed8221440dd4382208c0acb87626..33468e0a23a5de953da6593b4c99d524e9af9328 100644 (file)
@@ -221,6 +221,16 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
     ]);
 }
 
+export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
+    return new EditorSimpleClassContainer('editor-toolbar-main', [
+        new EditorButton(bold),
+        new EditorButton(italic),
+        new EditorButton(link),
+        new EditorButton(bulletList),
+        new EditorButton(numberList),
+    ]);
+}
+
 export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
     image: {
         selector: 'img:not([drawio-diagram] img)',
index ca2ba40c6fc369b5a39e4e6e9c7f1061611b96f9..9c524dff057bceaa9a0a8d4a3dca10f4c79b6a81 100644 (file)
@@ -30,6 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
 export abstract class EditorUiElement {
     protected dom: HTMLElement|null = null;
     private context: EditorUiContext|null = null;
+    private abortController: AbortController = new AbortController();
 
     protected abstract buildDOM(): HTMLElement;
 
@@ -79,9 +80,16 @@ export abstract class EditorUiElement {
         if (target) {
             target.addEventListener('editor::' + name, ((event: CustomEvent) => {
                 callback(event.detail);
-            }) as EventListener);
+            }) as EventListener, { signal: this.abortController.signal });
         }
     }
+
+    teardown(): void {
+        if (this.dom && this.dom.isConnected) {
+            this.dom.remove();
+        }
+        this.abortController.abort('teardown');
+    }
 }
 
 export class EditorContainerUiElement extends EditorUiElement {
@@ -129,6 +137,13 @@ export class EditorContainerUiElement extends EditorUiElement {
             child.setContext(context);
         }
     }
+
+    teardown() {
+        for (const child of this.children) {
+            child.teardown();
+        }
+        super.teardown();
+    }
 }
 
 export class EditorSimpleClassContainer extends EditorContainerUiElement {
index 570b8222b9c8e1fed5cb8b08db28382d859d9fa8..6ea0b8b393426f2840d68a2d4d6885c889d2d8cc 100644 (file)
@@ -48,7 +48,7 @@ export abstract class EditorDecorator {
      * Destroy this decorator. Used for tear-down operations upon destruction
      * of the underlying node this decorator is attached to.
      */
-    destroy(context: EditorUiContext): void {
+    teardown(): void {
         for (const callback of this.onDestroyCallbacks) {
             callback();
         }
index 751c1b3f207233134bcb8c0a3a70009d054fa8a4..890d5b325fe97b0a18f833d90eacddfefd188d2b 100644 (file)
@@ -41,11 +41,18 @@ export class DropDownManager {
 
     constructor() {
         this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
+        this.onWindowClick = this.onWindowClick.bind(this);
 
-        window.addEventListener('click', (event: MouseEvent) => {
-            const target = event.target as HTMLElement;
-            this.closeAllNotContainingElement(target);
-        });
+        window.addEventListener('click', this.onWindowClick);
+    }
+
+    teardown(): void {
+        window.removeEventListener('click', this.onWindowClick);
+    }
+
+    protected onWindowClick(event: MouseEvent): void {
+        const target = event.target as HTMLElement;
+        this.closeAllNotContainingElement(target);
     }
 
     protected closeAllNotContainingElement(element: HTMLElement): void {
index c40206607c62585da1140557a72104d388314752..3f46455da630e7618d2c5e63298d8f5508459b6b 100644 (file)
@@ -12,6 +12,8 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
 
 export class EditorUIManager {
 
+    public dropdowns: DropDownManager = new DropDownManager();
+
     protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
     protected activeModalsByKey: Record<string, EditorFormModal> = {};
     protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
@@ -21,12 +23,12 @@ export class EditorUIManager {
     protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
     protected activeContextToolbars: EditorContextToolbar[] = [];
     protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
-
-    public dropdowns: DropDownManager = new DropDownManager();
+    protected domEventAbortController = new AbortController();
+    protected teardownCallbacks: (()=>void)[] = [];
 
     setContext(context: EditorUiContext) {
         this.context = context;
-        this.setupEventListeners(context);
+        this.setupEventListeners();
         this.setupEditor(context.editor);
     }
 
@@ -99,7 +101,7 @@ export class EditorUIManager {
 
     setToolbar(toolbar: EditorContainerUiElement) {
         if (this.toolbar) {
-            this.toolbar.getDOMElement().remove();
+            this.toolbar.teardown();
         }
 
         this.toolbar = toolbar;
@@ -170,10 +172,40 @@ export class EditorUIManager {
         return this.getContext().options.textDirection === 'rtl' ? 'rtl' : 'ltr';
     }
 
+    onTeardown(callback: () => void): void {
+        this.teardownCallbacks.push(callback);
+    }
+
+    teardown(): void {
+        this.domEventAbortController.abort('teardown');
+
+        for (const [_, modal] of Object.entries(this.activeModalsByKey)) {
+            modal.teardown();
+        }
+
+        for (const [_, decorator] of Object.entries(this.decoratorInstancesByNodeKey)) {
+            decorator.teardown();
+        }
+
+        if (this.toolbar) {
+            this.toolbar.teardown();
+        }
+
+        for (const toolbar of this.activeContextToolbars) {
+            toolbar.teardown();
+        }
+
+        this.dropdowns.teardown();
+
+        for (const callback of this.teardownCallbacks) {
+            callback();
+        }
+    }
+
     protected updateContextToolbars(update: EditorUiStateUpdate): void {
         for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
             const toolbar = this.activeContextToolbars[i];
-            toolbar.destroy();
+            toolbar.teardown();
             this.activeContextToolbars.splice(i, 1);
         }
 
@@ -253,9 +285,9 @@ export class EditorUIManager {
         });
     }
 
-    protected setupEventListeners(context: EditorUiContext) {
+    protected setupEventListeners() {
         const layoutUpdate = this.triggerLayoutUpdate.bind(this);
-        window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
-        window.addEventListener('resize', layoutUpdate, {passive: true});
+        window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true, signal: this.domEventAbortController.signal});
+        window.addEventListener('resize', layoutUpdate, {passive: true, signal: this.domEventAbortController.signal});
     }
 }
\ No newline at end of file
index 3eea62ebb94d9cca2e1ab1ae9bfff7f1990467e0..4dbe9d962c5849611c2840b9ec9306efd1dda712 100644 (file)
@@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement {
     }
 
     hide() {
-        this.getDOMElement().remove();
         this.getContext().manager.setModalInactive(this.key);
+        this.teardown();
     }
 
     getForm(): EditorForm {
index 323b17450085ce4a3ddc30e13d32b3ee2311db01..cf5ec4ad15118aba17073e76b935e7d9c52578ec 100644 (file)
@@ -60,17 +60,4 @@ export class EditorContextToolbar extends EditorContainerUiElement {
         const dom = this.getDOMElement();
         dom.append(...children.map(child => child.getDOMElement()));
     }
-
-    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
index ae829bae3a3c7cfbbb28fc88138b58a87facbd8f..b7ce65eeb6c52edfed1ed056d4da199d435f17ea 100644 (file)
@@ -64,6 +64,6 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise<string> {
     });
 }
 
-export function focusEditor(editor: LexicalEditor) {
+export function focusEditor(editor: LexicalEditor): void {
     editor.focus(() => {}, {defaultSelection: "rootStart"});
 }
\ No newline at end of file
index eadf3518722c0a6ff29ba90f8cb711cd1a6f903b..d70a8c1d909b1d1b55f88347060b1049186ad46b 100644 (file)
@@ -7,7 +7,6 @@
      option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
      option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
      option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}"
-     option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}"
      option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
      id="comment{{$comment->local_id}}"
      class="comment-box">
index f27127e9732c1970d4ff0d977ac3e3c91198a9da..a5f0168a5c8a95a5f4db3b695992703712928e91 100644 (file)
@@ -3,7 +3,6 @@
          option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
          option:page-comments:count-text="{{ trans('entities.comment_thread_count') }}"
          option:page-comments:archived-count-text="{{ trans('entities.comment_archived_count') }}"
-         option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}"
          option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
          class="comments-list tab-container"
          aria-label="{{ trans('entities.comments') }}">
@@ -73,7 +72,6 @@
 
     @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
         @push('body-end')
-            <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>
             @include('form.editor-translations')
             @include('entities.selector-popup')
         @endpush
index 8b8a5d488b869447d3a8f905c57324c6dc1c7b6a..c5fe4ce5064a600bbfa0fd427f780468e6482c8c 100644 (file)
@@ -193,13 +193,14 @@ class CommentStoreTest extends TestCase
     {
         $page = $this->entities->page();
 
-        $script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>';
+        $script = '<script>const a = "script";</script><script>const b = "sneakyscript";</script><p onclick="1">My lovely comment</p>';
         $this->asAdmin()->postJson("/comment/$page->id", [
             'html' => $script,
         ]);
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee($script, false);
+        $pageView->assertDontSee('sneakyscript', false);
         $pageView->assertSee('<p>My lovely comment</p>', false);
 
         $comment = $page->comments()->first();
@@ -209,6 +210,7 @@ class CommentStoreTest extends TestCase
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee($script, false);
+        $pageView->assertDontSee('sneakyscript', false);
         $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
     }
 
@@ -216,7 +218,7 @@ class CommentStoreTest extends TestCase
     {
         $page = $this->entities->page();
         Comment::factory()->create([
-            'html' => '<script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
+            'html' => '<script>superbadscript</script><script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
             'entity_type' => 'page', 'entity_id' => $page
         ]);
 
@@ -229,7 +231,7 @@ class CommentStoreTest extends TestCase
     public function test_comment_html_is_limited()
     {
         $page = $this->entities->page();
-        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section></p>';
+        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section><section>there</section></p>';
         $expected = '<p>Content<a href="#cat">a</a></p>';
 
         $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
@@ -248,4 +250,27 @@ class CommentStoreTest extends TestCase
             'html' => $expected,
         ]);
     }
+
+    public function test_comment_html_spans_are_cleaned()
+    {
+        $page = $this->entities->page();
+        $input = '<p><span class="beans">Hello</span> do you have <span style="white-space: discard;">biscuits</span>?</p>';
+        $expected = '<p><span>Hello</span> do you have <span>biscuits</span>?</p>';
+
+        $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'entity_type' => 'page',
+            'entity_id' => $page->id,
+            'html' => $expected,
+        ]);
+
+        $comment = $page->comments()->first();
+        $resp = $this->put("/comment/{$comment->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'id'   => $comment->id,
+            'html' => $expected,
+        ]);
+    }
 }