diff --git a/components/email/__tests__/quoted-html.test.ts b/components/email/__tests__/quoted-html.test.ts new file mode 100644 index 000000000..6dbdea83f --- /dev/null +++ b/components/email/__tests__/quoted-html.test.ts @@ -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 = '
Original reply
'; + const editor = createEditor(buildQuotedHtmlBlock(original)); + try { + expect(editor.view.dom.querySelector('.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 = '
Ok no problem
Hello this is a quick test
Max
Layout
'; + const editor = createEditor(buildQuotedHtmlBlock(original, { showQuoteBorder: false })); + let saved = ''; + try { + const island = editor.view.dom.querySelector('.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('.quoted-html-island')?.style.borderLeftWidth).toBe(''); + expect(serializeEditorContent(reopened)).toBe(saved); + } finally { reopened.destroy(); } + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 81e641f48..6433a6c92 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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}
${replyTo.quoteHeaderHtml}${island}`; } diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index b983539b3..ad92c90bf 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -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(`[${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 `
${sanitizedInnerHtml}
`; + const borderAttribute = showQuoteBorder ? "" : ' data-quote-border="false"'; + const style = showQuoteBorder ? QUOTE_BAR_STYLE : "margin-top:8px;"; + return `
${html}
`; } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 4df1b48c9..2eb8212fe 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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). */