From: Dan Brown Date: Wed, 25 Jun 2025 13:16:01 +0000 (+0100) Subject: Comments: Switched to lexical editor X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/b80992ca59a4803fe81d577add6a0611e976c83b?ds=inline Comments: Switched to lexical editor 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. --- diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index cb091b869..d4f7d2c8f 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -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); } diff --git a/resources/js/components/page-comment.ts b/resources/js/components/page-comment.ts index a0bb7a55b..8334ebb8a 100644 --- a/resources/js/components/page-comment.ts +++ b/resources/js/components/page-comment.ts @@ -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 { 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).editor_translations, + textDirection: this.$opts.textDirection, + translations: (window as unknown as Record).editor_translations, }); - (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => { - this.wysiwygEditor = editors[0]; - setTimeout(() => this.wysiwygEditor.focus(), 50); - }); + this.wysiwygEditor.focus(); } protected async update(event: Event): Promise { @@ -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 { diff --git a/resources/js/components/page-comments.ts b/resources/js/components/page-comments.ts index 5c1cd014c..e988343ca 100644 --- a/resources/js/components/page-comments.ts +++ b/resources/js/components/page-comments.ts @@ -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 { 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 { 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).editor_translations, + translations: (window as unknown as Record).editor_translations, }); - (window as unknown as {tinymce: {init: (arg0: Object) => Promise}}).tinymce.init(config).then(editors => { - this.wysiwygEditor = editors[0]; - setTimeout(() => this.wysiwygEditor.focus(), 50); - }); + this.wysiwygEditor.focus(); } protected removeEditor(): void { diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 8e98780d5..8f6c41c1a 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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 = {}): SimpleWysiwygEditorInterface { +export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): 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 { - 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 diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index c1db0f086..413e2c4cd 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -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 | LexicalNodeReplacement)[] { return [ CalloutNode, @@ -45,6 +42,15 @@ export function getNodesForPageEditor(): (KlassConstructor | ]; } +export function getNodesForBasicEditor(): (KlassConstructor | 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(); } } } diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index fc413bb8f..33468e0a2 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -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 = { image: { selector: 'img:not([drawio-diagram] img)', diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index ca2ba40c6..9c524dff0 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -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 { diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index 570b8222b..6ea0b8b39 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -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(); } diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index 751c1b3f2..890d5b325 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -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 { diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index c40206607..3f46455da 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -12,6 +12,8 @@ export type SelectionChangeHandler = (selection: BaseSelection|null) => void; export class EditorUIManager { + public dropdowns: DropDownManager = new DropDownManager(); + protected modalDefinitionsByKey: Record = {}; protected activeModalsByKey: Record = {}; protected decoratorConstructorsByType: Record = {}; @@ -21,12 +23,12 @@ export class EditorUIManager { protected contextToolbarDefinitionsByKey: Record = {}; protected activeContextToolbars: EditorContextToolbar[] = []; protected selectionChangeHandlers: Set = 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 diff --git a/resources/js/wysiwyg/ui/framework/modals.ts b/resources/js/wysiwyg/ui/framework/modals.ts index 3eea62ebb..4dbe9d962 100644 --- a/resources/js/wysiwyg/ui/framework/modals.ts +++ b/resources/js/wysiwyg/ui/framework/modals.ts @@ -34,8 +34,8 @@ export class EditorFormModal extends EditorContainerUiElement { } hide() { - this.getDOMElement().remove(); this.getContext().manager.setModalInactive(this.key); + this.teardown(); } getForm(): EditorForm { diff --git a/resources/js/wysiwyg/ui/framework/toolbars.ts b/resources/js/wysiwyg/ui/framework/toolbars.ts index 323b17450..cf5ec4ad1 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -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 diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index ae829bae3..b7ce65eeb 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -64,6 +64,6 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise { }); } -export function focusEditor(editor: LexicalEditor) { +export function focusEditor(editor: LexicalEditor): void { editor.focus(() => {}, {defaultSelection: "rootStart"}); } \ No newline at end of file diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index eadf35187..d70a8c1d9 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -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"> diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index f27127e97..a5f0168a5 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -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') - @include('form.editor-translations') @include('entities.selector-popup') @endpush diff --git a/tests/Entity/CommentStoreTest.php b/tests/Entity/CommentStoreTest.php index 8b8a5d488..c5fe4ce50 100644 --- a/tests/Entity/CommentStoreTest.php +++ b/tests/Entity/CommentStoreTest.php @@ -193,13 +193,14 @@ class CommentStoreTest extends TestCase { $page = $this->entities->page(); - $script = '

My lovely comment

'; + $script = '

My lovely comment

'; $this->asAdmin()->postJson("/comment/$page->id", [ 'html' => $script, ]); $pageView = $this->get($page->getUrl()); $pageView->assertDontSee($script, false); + $pageView->assertDontSee('sneakyscript', false); $pageView->assertSee('

My lovely comment

', 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('

My lovely comment

updated

'); } @@ -216,7 +218,7 @@ class CommentStoreTest extends TestCase { $page = $this->entities->page(); Comment::factory()->create([ - 'html' => '

scriptincommentest

', + 'html' => '

scriptincommentest

', '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 = '

Test

Contenta

Hello

'; + $input = '

Test

Contenta

Hello
there

'; $expected = '

Contenta

'; $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 = '

Hello do you have biscuits?

'; + $expected = '

Hello do you have biscuits?

'; + + $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, + ]); + } }