From 7f737b60c6f6445690f4a612e0a237420daa0cc1 Mon Sep 17 00:00:00 2001 From: Henrik Giesel Date: Thu, 31 Mar 2022 03:17:13 +0200 Subject: [PATCH] Fix infinite update loop in editor with invalid input HTML (#1761) * Use async function in PlainTextInput * Clean up PlainTextInput * Refactor logic from {Rich,Plain}TextInput into own files * Remove prohibited tags on content.subscribe which also parses the html --- ts/editor/DecoratedElements.svelte | 10 +- ts/editor/MathjaxElement.svelte | 5 +- .../editor-toolbar/TemplateButtons.svelte | 4 +- .../mathjax-overlay/MathjaxHandle.svelte | 4 +- .../plain-text-input/PlainTextInput.svelte | 86 ++++--- .../plain-text-input/remove-prohibited.ts | 11 +- ts/editor/plain-text-input/transform.ts | 12 + .../rich-text-input/RichTextInput.svelte | 225 +++++------------- .../rich-text-input/normalizing-node-store.ts | 25 ++ .../rich-text-input/rich-text-resolve.ts | 43 ++++ ts/editor/rich-text-input/transform.ts | 60 +++++ ts/lib/parsing.ts | 12 + 12 files changed, 267 insertions(+), 230 deletions(-) create mode 100644 ts/editor/plain-text-input/transform.ts create mode 100644 ts/editor/rich-text-input/normalizing-node-store.ts create mode 100644 ts/editor/rich-text-input/rich-text-resolve.ts create mode 100644 ts/editor/rich-text-input/transform.ts create mode 100644 ts/lib/parsing.ts diff --git a/ts/editor/DecoratedElements.svelte b/ts/editor/DecoratedElements.svelte index 150de2585..5c28a6edc 100644 --- a/ts/editor/DecoratedElements.svelte +++ b/ts/editor/DecoratedElements.svelte @@ -4,18 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - - diff --git a/ts/editor/MathjaxElement.svelte b/ts/editor/MathjaxElement.svelte index 5dbf2441e..212f66989 100644 --- a/ts/editor/MathjaxElement.svelte +++ b/ts/editor/MathjaxElement.svelte @@ -2,11 +2,10 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> - diff --git a/ts/editor/rich-text-input/normalizing-node-store.ts b/ts/editor/rich-text-input/normalizing-node-store.ts new file mode 100644 index 000000000..d75076006 --- /dev/null +++ b/ts/editor/rich-text-input/normalizing-node-store.ts @@ -0,0 +1,25 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import type { DecoratedElement } from "../../editable/decorated"; +import type { NodeStore } from "../../sveltelib/node-store"; +import { nodeStore } from "../../sveltelib/node-store"; +import { decoratedElements } from "../DecoratedElements.svelte"; + +function normalizeFragment(fragment: DocumentFragment): void { + fragment.normalize(); + + for (const decorated of decoratedElements) { + for (const element of fragment.querySelectorAll( + decorated.tagName, + ) as NodeListOf) { + element.undecorate(); + } + } +} + +function getStore(): NodeStore { + return nodeStore(undefined, normalizeFragment); +} + +export default getStore; diff --git a/ts/editor/rich-text-input/rich-text-resolve.ts b/ts/editor/rich-text-input/rich-text-resolve.ts new file mode 100644 index 000000000..21fa109e0 --- /dev/null +++ b/ts/editor/rich-text-input/rich-text-resolve.ts @@ -0,0 +1,43 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { bridgeCommand } from "../../lib/bridgecommand"; +import { on } from "../../lib/events"; +import { promiseWithResolver } from "../../lib/promise"; + +function bridgeCopyPasteCommands(input: HTMLElement): { destroy(): void } { + function onPaste(event: Event): void { + event.preventDefault(); + bridgeCommand("paste"); + } + + function onCutOrCopy(): void { + bridgeCommand("cutOrCopy"); + } + + const removePaste = on(input, "paste", onPaste); + const removeCopy = on(input, "copy", onCutOrCopy); + const removeCut = on(input, "cut", onCutOrCopy); + + return { + destroy() { + removePaste(); + removeCopy(); + removeCut(); + }, + }; +} + +function useRichTextResolve(): [Promise, (input: HTMLElement) => void] { + const [promise, resolve] = promiseWithResolver(); + + function richTextResolve(input: HTMLElement): { destroy(): void } { + const destroy = bridgeCopyPasteCommands(input); + resolve(input); + return destroy; + } + + return [promise, richTextResolve]; +} + +export default useRichTextResolve; diff --git a/ts/editor/rich-text-input/transform.ts b/ts/editor/rich-text-input/transform.ts new file mode 100644 index 000000000..185992834 --- /dev/null +++ b/ts/editor/rich-text-input/transform.ts @@ -0,0 +1,60 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { + fragmentToString, + nodeContainsInlineContent, + nodeIsElement, +} from "../../lib/dom"; +import { createDummyDoc } from "../../lib/parsing"; +import { decoratedElements } from "../DecoratedElements.svelte"; + +function adjustInputHTML(html: string): string { + for (const component of decoratedElements) { + html = component.toUndecorated(html); + } + + return html; +} + +function adjustInputFragment(fragment: DocumentFragment): void { + if (nodeContainsInlineContent(fragment)) { + fragment.appendChild(document.createElement("br")); + } +} + +export function storedToFragment(html: string): DocumentFragment { + /* We need .createContextualFragment so that customElements are initialized */ + const fragment = document + .createRange() + .createContextualFragment(createDummyDoc(adjustInputHTML(html))); + + adjustInputFragment(fragment); + return fragment; +} + +function adjustOutputFragment(fragment: DocumentFragment): void { + if ( + fragment.hasChildNodes() && + nodeIsElement(fragment.lastChild!) && + nodeContainsInlineContent(fragment) && + fragment.lastChild!.tagName === "BR" + ) { + fragment.lastChild!.remove(); + } +} + +function adjustOutputHTML(html: string): string { + for (const component of decoratedElements) { + html = component.toStored(html); + } + + return html; +} + +export function fragmentToStored(fragment: DocumentFragment): string { + const clone = document.importNode(fragment, true); + adjustOutputFragment(clone); + + return adjustOutputHTML(fragmentToString(clone)); +} diff --git a/ts/lib/parsing.ts b/ts/lib/parsing.ts new file mode 100644 index 000000000..83ec6eeb9 --- /dev/null +++ b/ts/lib/parsing.ts @@ -0,0 +1,12 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/** + * Parsing with or without this dummy structure changes the output + * for both `DOMParser.parseAsString` and range.createContextualFragment`. + * Parsing without means that comments or meaningless html elements are dropped, + * which we want to avoid. + */ +export function createDummyDoc(html: string): string { + return `${html}`; +}