Files
SRCmail/components/email/signature-block.ts
T
198407ebf5 fix(composer): keep HTML signature styling in the editor and on send
Rich, table-based identity signatures lost all their inline CSS
(background/text colors, fonts, border-radius, bgcolor). The composer
embeds the signature into the TipTap editor, and parsing it into the
ProseMirror schema flattened it to a generic bordered table. That
normalized version was then shown while composing AND delivered to the
recipient, even though Identity settings stored and previewed it
correctly.

Hold the signature as a dedicated, non-editable atom node
(SignatureBlock) that keeps the verbatim HTML in an attribute and renders
it inside a Shadow Root, mirroring the existing QuotedHtml island. The
markup is never parsed into the schema, so the styling survives 1:1 both
in the in-editor preview and in the outgoing mail
(serializeEditorContent inlines the verbatim HTML, as it already does for
quoted originals). The signature stays a single unit: select it and
Backspace/Delete to remove it; identity switching still swaps it via the
existing data-signature-block markers.

Adds unit coverage (parse + serialize round-trip preserves inline styles).

Fixes #475

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:56:10 +02:00

106 lines
4.3 KiB
TypeScript

"use client";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
// Marker attribute that identifies the signature wrapper in serialized HTML,
// so parseHTML can recognise it on the way back in (initial content, drafts).
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
/**
* SignatureBlock — an atomic, NON-editable block node that carries the
* *verbatim* HTML of the user's identity signature in its `html` attribute.
*
* Why: the signature is embedded into the composer so it stays in the body,
* but parsing rich, table-based "brand" signatures into the ProseMirror schema
* strips their inline CSS (background/text colors, fonts, border-radius). By
* holding the signature as an atom it is never parsed into the schema, so the
* styling survives 1:1 — both in the editor (rendered by the NodeView below)
* and in the sent mail (emitted by serializeEditorContent in quoted-html.ts).
*
* Mirrors QuotedHtml, but the inner region is read-only: a signature is meant
* to be inserted/removed as a unit, not edited inline. Select the node and
* press Backspace/Delete to drop the whole signature.
*/
export const SignatureBlock = TiptapNode.create({
name: "signatureBlock",
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 the rich
// signature markup never hits (and is never mangled by) the schema.
parseHTML: (el) => el.innerHTML,
// The real content round-trips via the custom serializer
// (serializeEditorContent); renderHTML below only needs the wrapper.
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: `div[${SIGNATURE_BLOCK_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, { [SIGNATURE_BLOCK_MARKER]: "" })];
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("div");
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
// CRITICAL: render the signature inside a Shadow Root. The app's global
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
// would otherwise cascade INTO the signature and destroy its layout -
// exactly the corruption we are fixing. Shadow DOM isolates both
// directions, so only the browser's UA defaults + the signature's own
// inline styles apply and the in-editor preview matches the sent mail.
const shadow = dom.attachShadow({ mode: "open" });
const inner = document.createElement("div");
// Read-only: a signature is inserted/removed as a unit, not edited inline.
inner.contentEditable = "false";
inner.innerHTML = node.attrs.html || "";
shadow.appendChild(inner);
return {
dom,
// ProseMirror must not try to reconcile the foreign shadow content.
ignoreMutation: () => true,
// Let ProseMirror handle all events so clicking selects the atom and
// Backspace/Delete removes the whole signature.
stopEvent: () => false,
update: (updatedNode) => {
if (updatedNode.type.name !== "signatureBlock") return false;
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
inner.innerHTML = updatedNode.attrs.html || "";
}
return true;
},
};
};
},
});
/**
* Build the editor-content wrapper that embeds the signature as a single
* SignatureBlock node. The inner HTML must be pre-sanitized
* (sanitizeSignatureHtml). The `data-signature-block-node` marker is what
* parseHTML keys on, so this exact form must be what serializeEditorContent
* emits too (round-trip consistency).
*/
export function buildSignatureBlock(sanitizedInnerHtml: string): string {
return `<div ${SIGNATURE_BLOCK_MARKER}>${sanitizedInnerHtml}</div>`;
}