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
+37 -15
View File
@@ -9,8 +9,9 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
@@ -358,15 +359,22 @@ export function EmailComposer({
// Plugin override (resolved at composer open via onBuildQuoteHeader).
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
if (replyTo.htmlBody) {
// Layout-heavy original: embed verbatim as a QuotedHtml island so
// nested tables / MJML survive 1:1 (sanitize strips scripts/styles
// first; cid rewrite runs after so its data-cid markers survive).
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
);
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
}
const escaped = replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: '';
const wrap = replyTo.quoteWrapInBlockquote !== false;
const originalHtml = replyTo.htmlBody
? rewriteCidImagesForEditor(replyTo.htmlBody)
: (replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: '');
const bodyHtml = wrap
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
: originalHtml;
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
: escaped;
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
}
@@ -375,10 +383,14 @@ export function EmailComposer({
const quoteHeader = mode === 'forward'
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
// cid: image refs are rewritten so they render in the editor (browsers
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
// its layout survives the editor round-trip. Sanitize first to strip
// scripts/styles/head; cid rewrite afterwards so data-cid markers
// aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false.
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody))
);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div>${island}`;
}
if (replyTo.body) {
@@ -518,7 +530,9 @@ export function EmailComposer({
if (!isReplyLike && mode !== 'compose') return;
if (isReplyLike && signaturePosition !== 'above_quote') return;
const currentHtml = editor.getHTML();
// serializeEditorContent (not getHTML) so a QuotedHtml island's verbatim
// body isn't lost during the signature splice + setContent round-trip.
const currentHtml = serializeEditorContent(editor);
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
if (!startEl) return;
@@ -540,15 +554,23 @@ export function EmailComposer({
if (!parent) return;
// Remove the existing signature range [startEl … endEl] inclusive, or
// from startEl to the next blockquote if no end marker is present.
// from startEl to the next quote boundary if no end marker is present.
// The quote boundary is either a legacy <blockquote> or the QuotedHtml
// island wrapper (<div data-quoted-html>) - stop before either so the
// signature splice never eats into the quoted body.
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
const isQuoteBoundary = (n: Node | null): boolean => {
if (!n || n.nodeType !== 1) return false;
const el = n as Element;
return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html');
};
const toRemove: Node[] = [];
let cursor: Node | null = startEl;
while (cursor) {
toRemove.push(cursor);
if (cursor === removeUntil) break;
const next: Node | null = cursor.nextSibling;
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
if (!removeUntil && isQuoteBoundary(next)) break;
cursor = next;
}
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
/**
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
* and is NEVER parsed into the ProseMirror schema, so layout-heavy emails
* (nested tables, MJML, Outlook divs) survive a reply/forward 1:1.
*
* Behaviour:
* - To ProseMirror it's a single atomic block: Backspace at the boundary or
* Ctrl+A + Delete removes the whole quote in one go ("wie es sich gehört").
* - Its NodeView renders an inner `contentEditable` region so the user can
* still redact text inside the quote. Inner edits are synced back into the
* `html` attribute (without polluting the undo history).
*
* Serialization: ProseMirror's DOM serializer can't emit an atom's inner raw
* HTML, so use `serializeEditorContent(editor)` (below) instead of
* `editor.getHTML()` to read the composer body for sending/draft-saving.
*/
export const QuotedHtml = TiptapNode.create({
name: "quotedHtml",
group: "block",
atom: true,
selectable: true,
draggable: false,
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
isolating: true,
addAttributes() {
return {
html: {
default: "",
// Capture the verbatim inner HTML when parsing. Because the node is
// an atom, ProseMirror does NOT descend into the children, so they
// never hit the schema.
parseHTML: (el) => el.innerHTML,
// Not rendered as an attribute - the real content round-trips via the
// custom serializer. renderHTML below only needs the wrapper.
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: `div[${QUOTED_HTML_MARKER}]` }];
},
renderHTML({ HTMLAttributes }) {
// Only used for ProseMirror's internal/clipboard round-trip. The send /
// draft path uses serializeEditorContent() which inlines attrs.html.
return ["div", mergeAttributes(HTMLAttributes, { [QUOTED_HTML_MARKER]: "" })];
},
addNodeView() {
return ({ node, editor, getPos }) => {
// Host element ProseMirror manages. A subtle left border echoes the
// classic email quote bar without touching the quoted content's styling.
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
dom.style.cssText =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
// resets) would otherwise cascade INTO the quote and destroy its layout
// - even though the verbatim HTML serializes/sends perfectly. Shadow DOM
// isolates both directions: only the browser's UA defaults + the email's
// own inline styles apply, so the in-editor rendering matches the sent
// mail 1:1.
const shadow = dom.attachShadow({ mode: "open" });
const inner = document.createElement("div");
inner.contentEditable = "true";
inner.style.cssText = "outline:none;";
inner.innerHTML = node.attrs.html || "";
shadow.appendChild(inner);
// Track focus via focusin/focusout: inside a shadow root,
// document.activeElement is retargeted to the host, so we can't rely on
// it to detect "is the user editing in here".
let focused = false;
inner.addEventListener("focusin", () => {
focused = true;
});
inner.addEventListener("focusout", () => {
focused = false;
});
// Sync inner edits back into the node attribute. Coalesced via rAF so a
// burst of keystrokes is one transaction; addToHistory:false keeps
// redaction edits out of the editor's undo stack. The `input` event is
// composed and crosses the shadow boundary, so this listener fires.
let frame = 0;
const syncBack = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (typeof getPos !== "function") return;
const pos = getPos();
if (pos == null) return;
const current = inner.innerHTML;
if (current === node.attrs.html) return;
editor.view.dispatch(
editor.view.state.tr
.setNodeAttribute(pos, "html", current)
.setMeta("addToHistory", false)
);
});
};
inner.addEventListener("input", syncBack);
return {
dom,
// ProseMirror must not try to reconcile the foreign shadow content.
ignoreMutation: () => true,
// Events originating inside the island are retargeted to the host
// (`dom`) once they cross the shadow boundary, so dom.contains(target)
// is true for them → let the native shadow contentEditable handle
// them. Events from the surrounding doc (boundary Backspace,
// Ctrl+A+Delete) target other elements → fall through to ProseMirror
// so whole-block deletion still works.
stopEvent: (event) => {
const target = event.target as Node | null;
return !!target && dom.contains(target);
},
update: (updatedNode) => {
if (updatedNode.type.name !== "quotedHtml") return false;
// Don't clobber the caret while the user is redacting inside.
if (!focused && inner.innerHTML !== updatedNode.attrs.html) {
inner.innerHTML = updatedNode.attrs.html || "";
}
return true;
},
destroy: () => {
cancelAnimationFrame(frame);
inner.removeEventListener("input", syncBack);
},
};
};
},
});
/**
* Serialize the composer document to HTML for sending / draft-saving.
*
* Use this INSTEAD of `editor.getHTML()`: ProseMirror's DOM serializer cannot
* emit the raw inner HTML of an atom node, so it would drop the quoted body.
* Here we walk the top-level nodes, inline the quote node's verbatim `html`,
* and serialize everything else normally.
*/
export function serializeEditorContent(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema);
const parts: string[] = [];
editor.state.doc.forEach((node) => {
if (node.type.name === "quotedHtml") {
// Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft
// round-trips: re-opening parses this back into a QuotedHtml node
// instead of letting the schema mangle the raw table layout again.
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || ""));
return;
}
const fragment = serializer.serializeNode(node);
const tmp = document.createElement("div");
tmp.appendChild(fragment);
parts.push(tmp.innerHTML);
});
return parts.join("");
}
/**
* Build the editor-content wrapper that the composer prepends/appends so the
* quoted original becomes a single QuotedHtml node. The inner HTML must be
* pre-sanitized (scripts/styles/head stripped, cid: images rewritten).
*
* The `data-quoted-html` marker is what parseHTML keys on, so this exact form
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
}
+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]);