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}`;
+}