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..a1eeda1f9 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/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js deleted file mode 100644 index aa21a6371..000000000 --- a/resources/js/components/wysiwyg-input.js +++ /dev/null @@ -1,23 +0,0 @@ -import {Component} from './component'; -import {buildForInput} from '../wysiwyg-tinymce/config'; - -export class WysiwygInput extends Component { - - setup() { - this.elem = this.$el; - - const config = buildForInput({ - language: this.$opts.language, - containerElement: this.elem, - darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.$opts.textDirection, - translations: {}, - translationMap: window.editor_translations, - }); - - window.tinymce.init(config).then(editors => { - this.editor = editors[0]; - }); - } - -} diff --git a/resources/js/components/wysiwyg-input.ts b/resources/js/components/wysiwyg-input.ts new file mode 100644 index 000000000..1d914adb9 --- /dev/null +++ b/resources/js/components/wysiwyg-input.ts @@ -0,0 +1,32 @@ +import {Component} from './component'; +import {el} from "../wysiwyg/utils/dom"; +import {SimpleWysiwygEditorInterface} from "../wysiwyg"; + +export class WysiwygInput extends Component { + private elem!: HTMLTextAreaElement; + private wysiwygEditor!: SimpleWysiwygEditorInterface; + private textDirection!: string; + + async setup() { + this.elem = this.$el as HTMLTextAreaElement; + this.textDirection = this.$opts.textDirection; + + type WysiwygModule = typeof import('../wysiwyg'); + const wysiwygModule = (await window.importVersioned('wysiwyg')) as WysiwygModule; + const container = el('div', {class: 'basic-editor-container'}); + this.elem.parentElement?.appendChild(container); + this.elem.hidden = true; + + this.wysiwygEditor = wysiwygModule.createBasicEditorInstance(container as HTMLElement, this.elem.value, { + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.textDirection, + translations: (window as unknown as Record).editor_translations, + }); + + this.wysiwygEditor.onChange(() => { + this.wysiwygEditor.getContentAsHtml().then(html => { + this.elem.value = html; + }); + }); + } +} diff --git a/resources/js/wysiwyg-tinymce/config.js b/resources/js/wysiwyg-tinymce/config.js index 1666aa500..c0cfd37d9 100644 --- a/resources/js/wysiwyg-tinymce/config.js +++ b/resources/js/wysiwyg-tinymce/config.js @@ -310,54 +310,6 @@ export function buildForEditor(options) { }; } -/** - * @param {WysiwygConfigOptions} options - * @return {RawEditorOptions} - */ -export function buildForInput(options) { - // Set language - window.tinymce.addI18n(options.language, options.translationMap); - - // BookStack Version - const version = document.querySelector('script[src*="/dist/app.js"]').getAttribute('src').split('?version=')[1]; - - // Return config object - return { - width: '100%', - height: '185px', - target: options.containerElement, - cache_suffix: `?version=${version}`, - content_css: [ - window.baseUrl('/dist/styles.css'), - ], - branding: false, - skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5', - body_class: 'wysiwyg-input', - browser_spellcheck: true, - relative_urls: false, - language: options.language, - directionality: options.textDirection, - remove_script_host: false, - document_base_url: window.baseUrl('/'), - end_container_on_empty_block: true, - remove_trailing_brs: false, - statusbar: false, - menubar: false, - plugins: 'link autolink lists', - contextmenu: false, - toolbar: 'bold italic link bullist numlist', - content_style: getContentStyle(options), - file_picker_types: 'file', - valid_elements: 'p,a[href|title|target],ol,ul,li,strong,em,br', - file_picker_callback: filePickerCallback, - init_instance_callback(editor) { - addCustomHeadContent(editor.getDoc()); - - editor.contentDocument.documentElement.classList.toggle('dark-mode', options.darkMode); - }, - }; -} - /** * @typedef {Object} WysiwygConfigOptions * @property {Element} containerElement diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 7ecf91d23..f572f9de5 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,75 +1,76 @@ -import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; +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"; 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"; 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, 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"; + +const 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', + } +}; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { - const config: CreateEditorArgs = { + const editor = createEditor({ 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 editArea = el('div', { - contenteditable: 'true', - class: 'editor-content-area page-content', + theme: theme, }); - 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); - const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); + const context: EditorUiContext = buildEditorUI(container, editor, { + ...options, + editorClass: 'page-content', + }); + editor.setRootElement(context.editorDOM); mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context), registerKeyboardHandling(context), - registerTableResizer(editor, editWrap), + registerTableResizer(editor, context.scrollDOM), registerTableSelectionHandler(editor), - registerTaskListHandler(editor, editArea), + registerTaskListHandler(editor, context.editorDOM), registerDropPasteHandling(context), registerNodeResizer(context), registerAutoLinks(editor), ); - listenToCommonEvents(editor); + // Register toolbars, modals & decorators + context.manager.setToolbar(getMainEditorFullToolbar(context)); + for (const key of Object.keys(contextToolbars)) { + context.manager.registerContextToolbar(key, contextToolbars[key]); + } + for (const key of Object.keys(modals)) { + context.manager.registerModal(key, modals[key]); + } + context.manager.registerDecoratorType('code', CodeBlockDecorator); + context.manager.registerDecoratorType('diagram', DiagramDecorator); + listenToCommonEvents(editor); setEditorContentFromHtml(editor, htmlContent); const debugView = document.getElementById('lexical-debug'); @@ -89,17 +90,76 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerCommonNodeMutationListeners(context); - return new SimpleWysiwygEditorInterface(editor); + return new SimpleWysiwygEditorInterface(context); +} + +export function createBasicEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { + const editor = createEditor({ + namespace: 'BookStackBasicEditor', + nodes: getNodesForBasicEditor(), + onError: console.error, + theme: theme, + }); + const context: EditorUiContext = buildEditorUI(container, editor, options); + editor.setRootElement(context.editorDOM); + + const editorTeardown = mergeRegister( + registerRichText(editor), + registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(context), + registerAutoLinks(editor), + ); + + // Register toolbars, modals & decorators + 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(context); } export class SimpleWysiwygEditorInterface { - protected editor: LexicalEditor; + protected context: EditorUiContext; + protected onChangeListeners: (() => void)[] = []; + protected editorListenerTeardown: (() => void)|null = null; - 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); + } + + onChange(listener: () => void) { + this.onChangeListeners.push(listener); + this.startListeningToChanges(); + } + + focus(): void { + focusEditor(this.context.editor); + } + + remove() { + this.context.manager.teardown(); + this.context.containerDOM.remove(); + if (this.editorListenerTeardown) { + this.editorListenerTeardown(); + } + } + + protected startListeningToChanges(): void { + if (this.editorListenerTeardown) { + return; + } + + this.editorListenerTeardown = this.context.editor.registerUpdateListener(() => { + for (const listener of this.onChangeListeners) { + listener(); + } + }); } } \ 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 cdc451d08..33468e0a2 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -79,6 +79,7 @@ import { import {el} from "../../utils/dom"; import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; import {EditorSeparator} from "../framework/blocks/separator"; +import {EditorContextToolbarDefinition} from "../framework/toolbars"; export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { @@ -220,50 +221,74 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai ]); } -export function getImageToolbarContent(): EditorUiElement[] { - return [new EditorButton(image)]; -} - -export function getMediaToolbarContent(): EditorUiElement[] { - return [new EditorButton(media)]; -} - -export function getLinkToolbarContent(): EditorUiElement[] { - return [ +export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement { + return new EditorSimpleClassContainer('editor-toolbar-main', [ + new EditorButton(bold), + new EditorButton(italic), new EditorButton(link), - new EditorButton(unlink), - ]; + new EditorButton(bulletList), + new EditorButton(numberList), + ]); } -export function getCodeToolbarContent(): EditorUiElement[] { - return [ - new EditorButton(editCodeBlock), - ]; -} - -export function getTableToolbarContent(): EditorUiElement[] { - return [ - new EditorOverflowContainer(2, [ - new EditorButton(tableProperties), - 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), - ]), - ]; -} - -export function getDetailsToolbarContent(): EditorUiElement[] { - return [ - new EditorButton(detailsEditLabel), - new EditorButton(detailsToggle), - new EditorButton(detailsUnwrap), - ]; -} \ No newline at end of file +export const contextToolbars: Record = { + image: { + selector: 'img:not([drawio-diagram] img)', + content: () => [new EditorButton(image)], + }, + media: { + selector: '.editor-media-wrap', + content: () => [new EditorButton(media)], + }, + link: { + selector: 'a', + content() { + return [ + new EditorButton(link), + new EditorButton(unlink), + ] + }, + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + const image = originalTarget.querySelector('img'); + return image || originalTarget; + } + }, + code: { + selector: '.editor-code-block-wrap', + content: () => [new EditorButton(editCodeBlock)], + }, + table: { + selector: 'td,th', + content() { + return [ + new EditorOverflowContainer(2, [ + new EditorButton(tableProperties), + 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), + ]), + ]; + }, + displayTargetLocator(originalTarget: HTMLElement): HTMLElement { + return originalTarget.closest('table') as HTMLTableElement; + } + }, + details: { + selector: 'details', + content() { + return [ + new EditorButton(detailsEditLabel), + new EditorButton(detailsToggle), + new EditorButton(detailsUnwrap), + ] + }, + }, +}; \ 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 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 2d15b341b..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); } @@ -198,7 +230,7 @@ export class EditorUIManager { contentByTarget.set(targetEl, []) } // @ts-ignore - contentByTarget.get(targetEl).push(...definition.content); + contentByTarget.get(targetEl).push(...definition.content()); } } @@ -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 de2255444..cf5ec4ad1 100644 --- a/resources/js/wysiwyg/ui/framework/toolbars.ts +++ b/resources/js/wysiwyg/ui/framework/toolbars.ts @@ -4,7 +4,7 @@ import {el} from "../../utils/dom"; export type EditorContextToolbarDefinition = { selector: string; - content: EditorUiElement[], + content: () => EditorUiElement[], displayTargetLocator?: (originalTarget: HTMLElement) => HTMLElement; }; @@ -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/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index e7ec6adbc..c48386bb4 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,23 +1,30 @@ import {LexicalEditor} from "lexical"; -import { - getCodeToolbarContent, getDetailsToolbarContent, - getImageToolbarContent, - getLinkToolbarContent, - getMainEditorFullToolbar, getMediaToolbarContent, getTableToolbarContent -} from "./defaults/toolbars"; import {EditorUIManager} from "./framework/manager"; import {EditorUiContext} from "./framework/core"; -import {CodeBlockDecorator} from "./decorators/code-block"; -import {DiagramDecorator} from "./decorators/diagram"; -import {modals} from "./defaults/modals"; +import {el} from "../utils/dom"; + +export function buildEditorUI(containerDOM: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { + const editorDOM = el('div', { + contenteditable: 'true', + class: `editor-content-area ${options.editorClass || ''}`, + }); + const scrollDOM = el('div', { + class: 'editor-content-wrap', + }, [editorDOM]); + + containerDOM.append(scrollDOM); + containerDOM.classList.add('editor-container'); + containerDOM.setAttribute('dir', options.textDirection); + if (options.darkMode) { + containerDOM.classList.add('editor-dark'); + } -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, + containerDOM: containerDOM, + editorDOM: editorDOM, + scrollDOM: scrollDOM, manager, translate(text: string): string { const translations = options.translations; @@ -31,50 +38,5 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro }; manager.setContext(context); - // Create primary toolbar - manager.setToolbar(getMainEditorFullToolbar(context)); - - // Register modals - for (const key of Object.keys(modals)) { - manager.registerModal(key, modals[key]); - } - - // Register context toolbars - manager.registerContextToolbar('image', { - selector: 'img:not([drawio-diagram] img)', - content: getImageToolbarContent(), - }); - manager.registerContextToolbar('media', { - selector: '.editor-media-wrap', - content: getMediaToolbarContent(), - }); - 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', - content: getCodeToolbarContent(), - }); - manager.registerContextToolbar('table', { - selector: 'td,th', - content: getTableToolbarContent(), - displayTargetLocator(originalTarget: HTMLElement): HTMLElement { - return originalTarget.closest('table') as HTMLTableElement; - } - }); - manager.registerContextToolbar('details', { - selector: 'details', - content: getDetailsToolbarContent(), - }); - - // Register image decorator listener - manager.registerDecoratorType('code', CodeBlockDecorator); - manager.registerDecoratorType('diagram', DiagramDecorator); - return context; } \ 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/sass/_editor.scss b/resources/sass/_editor.scss index 633fa78a6..de43540a3 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -52,6 +52,25 @@ body.editor-is-fullscreen { flex: 1; } +// Variation specific styles +.comment-editor-container, +.basic-editor-container { + border-left: 1px solid #DDD; + border-right: 1px solid #DDD; + border-bottom: 1px solid #DDD; + border-radius: 3px; + @include mixins.lightDark(border-color, #DDD, #000); + + .editor-toolbar-main { + border-radius: 3px 3px 0 0; + justify-content: end; + } +} + +.basic-editor-container .editor-content-area { + padding-bottom: 0; +} + // Buttons .editor-button { font-size: 12px; diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index ee261e72d..44d495c27 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
diff --git a/resources/views/chapters/parts/form.blade.php b/resources/views/chapters/parts/form.blade.php index 602693916..70721631d 100644 --- a/resources/views/chapters/parts/form.blade.php +++ b/resources/views/chapters/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
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/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php index 3cf726ba4..52244eda6 100644 --- a/resources/views/form/description-html-input.blade.php +++ b/resources/views/form/description-html-input.blade.php @@ -1,5 +1,4 @@ diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php index 7790ba5a4..0207d7278 100644 --- a/resources/views/shelves/parts/form.blade.php +++ b/resources/views/shelves/parts/form.blade.php @@ -1,7 +1,3 @@ -@push('head') - -@endpush - {{ csrf_field() }}
diff --git a/tests/Entity/CommentDisplayTest.php b/tests/Entity/CommentDisplayTest.php index 22e96c250..bffe29fa9 100644 --- a/tests/Entity/CommentDisplayTest.php +++ b/tests/Entity/CommentDisplayTest.php @@ -60,7 +60,6 @@ class CommentDisplayTest extends TestCase $page = $this->entities->page(); $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertSee('tinymce.min.js?', false); $resp->assertSee('window.editor_translations', false); $resp->assertSee('component="entity-selector"', false); @@ -68,7 +67,6 @@ class CommentDisplayTest extends TestCase $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']); $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertDontSee('tinymce.min.js?', false); $resp->assertDontSee('window.editor_translations', false); $resp->assertDontSee('component="entity-selector"', false); @@ -79,7 +77,6 @@ class CommentDisplayTest extends TestCase ]); $resp = $this->actingAs($editor)->get($page->getUrl()); - $resp->assertSee('tinymce.min.js?', false); $resp->assertSee('window.editor_translations', false); $resp->assertSee('component="entity-selector"', false); } 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, + ]); + } }