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>
106 lines
4.3 KiB
TypeScript
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>`;
|
|
}
|