mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: honor borderless Outlook-style HTML quotes
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { buildQuotedHtmlBlock, QuotedHtml, serializeEditorContent } from '../quoted-html';
|
||||
|
||||
const createEditor = (content: string) => new Editor({ extensions: [StarterKit, QuotedHtml], content });
|
||||
|
||||
describe('HTML quote borders', () => {
|
||||
it('keeps normal quote styling by default', () => {
|
||||
const original = '<blockquote>Original reply</blockquote>';
|
||||
const editor = createEditor(buildQuotedHtmlBlock(original));
|
||||
try {
|
||||
expect(editor.view.dom.querySelector<HTMLElement>('.quoted-html-island')?.style.borderLeftWidth).toBe('2px');
|
||||
expect(serializeEditorContent(editor)).toContain(original);
|
||||
} finally { editor.destroy(); }
|
||||
});
|
||||
|
||||
it('flattens nested quotes and preserves content and border preference after reopening', () => {
|
||||
const original = '<blockquote style="border-left:2px solid gray">Ok no problem<hr><blockquote>Hello this is a quick test<br><a href="mailto:max@example.com">Max</a></blockquote></blockquote><table><tbody><tr><td>Layout</td></tr></tbody></table>';
|
||||
const editor = createEditor(buildQuotedHtmlBlock(original, { showQuoteBorder: false }));
|
||||
let saved = '';
|
||||
try {
|
||||
const island = editor.view.dom.querySelector<HTMLElement>('.quoted-html-island')!;
|
||||
expect(island.style.borderLeftWidth).toBe('');
|
||||
expect(island.style.paddingLeft).toBe('');
|
||||
expect(island.shadowRoot?.querySelector('blockquote')).toBeNull();
|
||||
expect(island.shadowRoot?.textContent).toContain('Ok no problem');
|
||||
expect(island.shadowRoot?.querySelector('a')?.getAttribute('href')).toBe('mailto:max@example.com');
|
||||
expect(island.shadowRoot?.querySelector('td')?.textContent).toBe('Layout');
|
||||
saved = serializeEditorContent(editor);
|
||||
expect(saved).toContain('data-quote-border="false"');
|
||||
expect(saved).not.toContain('blockquote');
|
||||
} finally { editor.destroy(); }
|
||||
const reopened = createEditor(saved);
|
||||
try {
|
||||
expect(reopened.view.dom.querySelector<HTMLElement>('.quoted-html-island')?.style.borderLeftWidth).toBe('');
|
||||
expect(serializeEditorContent(reopened)).toBe(saved);
|
||||
} finally { reopened.destroy(); }
|
||||
});
|
||||
});
|
||||
@@ -699,7 +699,7 @@ export function EmailComposer({
|
||||
// first; cid rewrite runs after so its data-cid markers survive).
|
||||
const island = buildQuotedHtmlBlock(
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed },
|
||||
{ remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ export const QuotedHtml = TiptapNode.create({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
showQuoteBorder: {
|
||||
default: true,
|
||||
parseHTML: (el) => el.getAttribute("data-quote-border") !== "false",
|
||||
renderHTML: (attrs) => attrs.showQuoteBorder === false ? { "data-quote-border": "false" } : {},
|
||||
},
|
||||
html: {
|
||||
default: "",
|
||||
// Capture the verbatim inner HTML when parsing. Because the node is
|
||||
@@ -138,7 +143,11 @@ export const QuotedHtml = TiptapNode.create({
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||
dom.className = "quoted-html-island";
|
||||
dom.style.cssText = QUOTE_BAR_STYLE + QUOTE_ISLAND_CONTAINMENT_STYLE;
|
||||
const updateQuoteStyle = (showBorder: boolean) => {
|
||||
dom.style.cssText = (showBorder ? QUOTE_BAR_STYLE : "margin-top:8px;")
|
||||
+ QUOTE_ISLAND_CONTAINMENT_STYLE;
|
||||
};
|
||||
updateQuoteStyle(node.attrs.showQuoteBorder !== false);
|
||||
|
||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||
@@ -218,6 +227,7 @@ export const QuotedHtml = TiptapNode.create({
|
||||
},
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "quotedHtml") return false;
|
||||
updateQuoteStyle(updatedNode.attrs.showQuoteBorder !== false);
|
||||
// Don't clobber the caret while the user is redacting inside.
|
||||
if (!focused && (updatedNode.attrs.html || "") !== renderedHtml) {
|
||||
render(updatedNode.attrs.html || "");
|
||||
@@ -256,7 +266,7 @@ export function serializeEditorContent(editor: Editor): string {
|
||||
// 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) || ""));
|
||||
parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || "", { showQuoteBorder: node.attrs.showQuoteBorder !== false }));
|
||||
return;
|
||||
}
|
||||
if (node.type.name === "signatureBlock") {
|
||||
@@ -286,8 +296,29 @@ export function serializeEditorContent(editor: Editor): string {
|
||||
*/
|
||||
export function buildQuotedHtmlBlock(
|
||||
sanitizedInnerHtml: string,
|
||||
opts: { remoteAllowed?: boolean } = {},
|
||||
opts: { remoteAllowed?: boolean; showQuoteBorder?: boolean } = {},
|
||||
): string {
|
||||
const showQuoteBorder = opts.showQuoteBorder !== false;
|
||||
let html = sanitizedInnerHtml;
|
||||
if (!showQuoteBorder) {
|
||||
// Outlook-style replies flatten quote levels, including earlier replies.
|
||||
// Work on sanitized DOM so tables, links and signatures remain intact.
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll("blockquote").forEach((quote) => {
|
||||
const block = document.createElement("div");
|
||||
block.append(...Array.from(quote.childNodes));
|
||||
quote.replaceWith(block);
|
||||
});
|
||||
container.querySelectorAll<HTMLElement>(`[${QUOTED_HTML_MARKER}]`).forEach((quote) => {
|
||||
quote.setAttribute("data-quote-border", "false");
|
||||
quote.style.borderLeft = "none";
|
||||
quote.style.paddingLeft = "0";
|
||||
});
|
||||
html = container.innerHTML;
|
||||
}
|
||||
const remote = opts.remoteAllowed ? ` ${QUOTED_REMOTE_ALLOWED_ATTR}="allowed"` : "";
|
||||
return `<div ${QUOTED_HTML_MARKER}${remote} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||
const borderAttribute = showQuoteBorder ? "" : ' data-quote-border="false"';
|
||||
const style = showQuoteBorder ? QUOTE_BAR_STYLE : "margin-top:8px;";
|
||||
return `<div ${QUOTED_HTML_MARKER}${remote}${borderAttribute} style="${style}">${html}</div>`;
|
||||
}
|
||||
|
||||
+2
-1
@@ -1105,7 +1105,8 @@ export interface QuoteHeader {
|
||||
text: string;
|
||||
/**
|
||||
* When false, the composer skips its default blockquote wrapping around the
|
||||
* quoted body (HTML mode only). Use this for the Outlook style where the
|
||||
* quoted body, including HTML islands and nested blockquotes (HTML mode only).
|
||||
* This preference persists when saving/reopening drafts. Use this for the Outlook style where the
|
||||
* quoted message is intended to follow the header without indentation.
|
||||
* Defaults to true (preserve the existing blockquote wrapping).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user