Feature: editable layout-preserving quote island

Replying to / forwarding a layout-heavy HTML email (nested tables, MJML,
Outlook divs) destroyed its layout: ProseMirror re-parsed the quoted body
through its strict schema and discarded anything that didn't fit. The quoted
original is now held verbatim in a new atomic QuotedHtml node and never parsed
into the schema; its NodeView renders inside a shadow root so app CSS can't
cascade in and the in-editor view matches the sent mail 1:1.

- quoted-html.ts (new): QuotedHtml atom node + shadow-DOM NodeView (inner
  contentEditable for redaction), serializeEditorContent(), buildQuotedHtmlBlock().
- rich-text-editor: register the node; emit via serializeEditorContent (not
  getHTML) so the verbatim island survives.
- composer: both HTML reply/forward paths embed the original as an island
  (sanitize -> cid-rewrite -> buildQuotedHtmlBlock); the signature-swap effect
  serializes via serializeEditorContent and treats the island as a quote
  boundary so the splice never cuts into the quoted body.

atom:true means Backspace at the boundary / Ctrl+A+Delete removes the whole
quote in one go.
This commit is contained in:
dealerweb
2026-05-30 16:43:50 +02:00
committed by Linus Rath
parent ad48f4394a
commit 1512b9afc0
3 changed files with 235 additions and 18 deletions
+11 -3
View File
@@ -16,6 +16,7 @@ import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { cn } from "@/lib/utils";
import {
Bold,
@@ -231,6 +232,9 @@ export function RichTextEditor({
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
},
}),
// Quoted/forwarded original email body - held verbatim as an atomic
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
QuotedHtml,
],
content,
editorProps: {
@@ -281,14 +285,18 @@ export function RichTextEditor({
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
// serializeEditorContent (not getHTML) so the verbatim quoted-original
// HTML held in the QuotedHtml atom node is emitted intact.
onChange(serializeEditorContent(editor));
},
immediatelyRender: false,
});
// Sync external content changes (e.g. template application)
// Sync external content changes (e.g. template application). Compare against
// the custom serialization so a doc that only differs inside a QuotedHtml
// island isn't needlessly re-parsed (which would reset the island DOM).
useEffect(() => {
if (editor && content !== editor.getHTML()) {
if (editor && content !== serializeEditorContent(editor)) {
editor.commands.setContent(content, { emitUpdate: false });
}
}, [content, editor]);