mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
|
import { DOMSerializer } from "@tiptap/pm/model";
|
|
import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state";
|
|
import type { Editor } from "@tiptap/react";
|
|
|
|
import { buildSignatureBlock } from "@/components/email/signature-block";
|
|
import { parkRemoteResources, restoreRemoteResources } from "@/lib/email-sanitization";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
|
|
// 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";
|
|
|
|
// Set on the wrapper the composer builds when the quoted message may load
|
|
// remote content (policy "allow", or a trusted sender). Display hint only:
|
|
// serializeEditorContent never emits it.
|
|
const QUOTED_REMOTE_ALLOWED_ATTR = "data-quoted-remote";
|
|
|
|
// Atom blocks typing must never replace just because they were left selected.
|
|
const ATOM_BLOCKS = new Set(["quotedHtml", "signatureBlock"]);
|
|
|
|
// Reusable style for the quote bar when quoting email text (like in a reply).
|
|
const QUOTE_BAR_STYLE =
|
|
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
|
|
|
/**
|
|
* 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: () => ({}),
|
|
},
|
|
remoteAllowed: {
|
|
default: false,
|
|
parseHTML: (el) => el.getAttribute(QUOTED_REMOTE_ALLOWED_ATTR) === "allowed",
|
|
renderHTML: () => ({}),
|
|
},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{ tag: `div[${QUOTED_HTML_MARKER}]` }];
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey("quotedHtmlFocus"),
|
|
props: {
|
|
handleDOMEvents: {
|
|
// A reply can open with the quote (or the signature) selected as
|
|
// a node. Tabbing into the body kept that selection, and the first
|
|
// key typed replaced the whole block. Focus puts the caret at the
|
|
// start instead; a click still lands where it was aimed, because
|
|
// ProseMirror handles the click after the focus event.
|
|
focus: (view) => {
|
|
const { selection, doc } = view.state;
|
|
if (
|
|
selection instanceof NodeSelection &&
|
|
ATOM_BLOCKS.has(selection.node.type.name)
|
|
) {
|
|
const start = Selection.atStart(doc);
|
|
if (start instanceof TextSelection) {
|
|
view.dispatch(view.state.tr.setSelection(start));
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
|
|
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 = QUOTE_BAR_STYLE;
|
|
|
|
// 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;";
|
|
|
|
// The quote renders in the app document, where the viewer's iframe
|
|
// CSP does not reach: unless the quoted message may load remote
|
|
// content, its images and backgrounds are parked so opening a reply
|
|
// doesn't fire the trackers the viewer held back. Parking is
|
|
// display-only - the html attribute (what gets sent) keeps them.
|
|
const blockRemote =
|
|
!node.attrs.remoteAllowed && useSettingsStore.getState().externalContentPolicy !== "allow";
|
|
let renderedHtml = "";
|
|
const render = (html: string) => {
|
|
renderedHtml = html;
|
|
inner.innerHTML = blockRemote ? parkRemoteResources(html).html : html;
|
|
};
|
|
const readBack = () => (blockRemote ? restoreRemoteResources(inner.innerHTML) : inner.innerHTML);
|
|
render(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 = readBack();
|
|
if (current === node.attrs.html) return;
|
|
renderedHtml = current;
|
|
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 && (updatedNode.attrs.html || "") !== renderedHtml) {
|
|
render(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;
|
|
}
|
|
if (node.type.name === "signatureBlock") {
|
|
// Same rationale as quotedHtml: inline the verbatim signature HTML so the
|
|
// styled signature reaches the recipient (and a saved draft round-trips)
|
|
// instead of the schema-flattened version.
|
|
parts.push(buildSignatureBlock((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,
|
|
opts: { remoteAllowed?: boolean } = {},
|
|
): string {
|
|
const remote = opts.remoteAllowed ? ` ${QUOTED_REMOTE_ALLOWED_ATTR}="allowed"` : "";
|
|
return `<div ${QUOTED_HTML_MARKER}${remote} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
|
}
|