From 5515297857b3d640f9ebec41e32e8fe76fe64eda Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:53:14 +0200 Subject: [PATCH] fix: keep blank lines in sent mail instead of collapsing them #1015 --- .../rich-text-editor-empty-lines.test.tsx | 96 +++++++++++++++++++ .../email/__tests__/signature-block.test.ts | 3 +- components/email/paragraph-breaks.ts | 57 +++++++++++ components/email/quoted-html.ts | 11 ++- components/email/rich-text-editor.tsx | 5 + 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-empty-lines.test.tsx create mode 100644 components/email/paragraph-breaks.ts diff --git a/components/email/__tests__/rich-text-editor-empty-lines.test.tsx b/components/email/__tests__/rich-text-editor-empty-lines.test.tsx new file mode 100644 index 000000000..5678f5788 --- /dev/null +++ b/components/email/__tests__/rich-text-editor-empty-lines.test.tsx @@ -0,0 +1,96 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import type { Editor } from '@tiptap/core'; + +import { RichTextEditor } from '../rich-text-editor'; +import { serializeEditorContent } from '../quoted-html'; + +async function setup(content: string) { + let editor: Editor | null = null; + render( + {}} + onEditorReady={(ed) => { editor = ed as unknown as Editor; }} + /> + ); + await waitFor(() => expect(editor).not.toBeNull()); + return editor as unknown as Editor; +} + +// The composer marks every block dir="auto"; that's not what these tests are about. +const serialize = (editor: Editor) => serializeEditorContent(editor).replace(/ dir="auto"/g, ''); + +// Re-open what was saved, the way a draft or a signature swap does. +function reload(editor: Editor, html: string) { + act(() => { editor.commands.setContent(html, { emitUpdate: false }); }); +} + +describe('RichTextEditor empty lines (#1015)', () => { + it('sends an empty line as a paragraph with a line break, so it keeps its height', async () => { + const editor = await setup('

one

two

'); + + expect(serialize(editor)).toBe('

one



two

'); + }); + + it('does not add a line when the saved body is opened again', async () => { + const editor = await setup('

one

two

'); + const saved = serialize(editor); + + reload(editor, saved); + + expect(editor.state.doc.child(1).type.name).toBe('paragraph'); + expect(editor.state.doc.child(1).childCount).toBe(0); + expect(serialize(editor)).toBe(saved); + reload(editor, serializeEditorContent(editor)); + expect(serialize(editor)).toBe(saved); + }); + + it('keeps a line break at the end of a line visible and stable', async () => { + const editor = await setup('

one

'); + act(() => { + editor.commands.setContent({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }, { type: 'hardBreak' }] }], + }, { emitUpdate: false }); + }); + const saved = serialize(editor); + expect(saved).toBe('

one

'); + + reload(editor, saved); + + expect(editor.getJSON().content?.[0].content).toEqual([{ type: 'text', text: 'one' }, { type: 'hardBreak' }]); + expect(serialize(editor)).toBe(saved); + }); + + it('leaves line breaks inside a line alone', async () => { + const editor = await setup('

one
two

'); + + expect(serialize(editor)).toBe('

one
two

'); + }); + + it('does not touch the quoted original or the signature markers', async () => { + const quoted = '

quoted

more

'; + const editor = await setup( + '

reply

' + + '

Alice

' + + `
${quoted}
`, + ); + + const html = serialize(editor); + + expect(html).toBe( + '

reply


' + + '

Alice

' + // The editor's own empty paragraph after the quote adds no line. + + `
${quoted}

`, + ); + }); + + it('adds no blank lines at the end of the message', async () => { + const editor = await setup('

one

two

'); + + expect(serialize(editor)).toBe('

one


two

'); + }); +}); diff --git a/components/email/__tests__/signature-block.test.ts b/components/email/__tests__/signature-block.test.ts index ece6c27aa..2f657ff20 100644 --- a/components/email/__tests__/signature-block.test.ts +++ b/components/email/__tests__/signature-block.test.ts @@ -300,7 +300,8 @@ Der Inhalt dieser E-Mail ist vertraulich. const out = serializeEditorContent(editor); // Leading paragraph + separator + ONE signature paragraph + end marker. expect((out.match(/

`, which a browser or mail + * client renders zero pixels high: the blank lines vanished for the recipient + * and in the Sent folder. Browsers only show a block's last line when it holds + * something, which is why Gmail and Thunderbird write `


`. + * + * The rule here mirrors the browser's: a
at the very end of a paragraph + * doesn't show. On the way out every paragraph that is empty or ends in a line + * break gets that extra
; on the way back in (re-opened draft, signature + * swap) the parse rule below drops it again, so the editor content round-trips + * without gaining a line each time. + */ + +function lastChild(el: Element): ChildNode | null { + let node = el.lastChild; + while (node && node.nodeType === 3 && !(node.textContent ?? "").trim()) { + node = node.previousSibling; + } + return node; +} + +/** + * Add the trailing
to every paragraph under `root` that would otherwise + * lose its last line. The signature's range marker paragraphs stay as they + * are: they only bracket the signature for the composer. + */ +export function addTrailingParagraphBreaks(root: ParentNode): void { + root.querySelectorAll("p").forEach((p) => { + if (p.hasAttribute(SIGNATURE_RANGE_MARKER)) return; + const last = lastChild(p); + if (!last || last.nodeName === "BR") { + p.appendChild(p.ownerDocument.createElement("br")); + } + }); +} + +/** Whether `el` is the
a paragraph ends on, which a browser doesn't show. */ +export function isTrailingParagraphBreak(el: HTMLElement): boolean { + if (el.parentElement?.nodeName !== "P") return false; + return lastChild(el.parentElement) === el; +} + +/** + * Parse rule for the paragraph node: skip the invisible trailing
instead + * of turning it into a hard break. Ranks above the hard break's own rule. + */ +export const trailingParagraphBreakRule = { + tag: "br", + priority: 60, + ignore: true, + getAttrs: (el: HTMLElement) => (isTrailingParagraphBreak(el) ? null : false), +} as const; diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index 40d708bbc..604d4d34e 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -5,6 +5,7 @@ import { DOMSerializer } from "@tiptap/pm/model"; import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; +import { addTrailingParagraphBreaks } from "@/components/email/paragraph-breaks"; import { buildSignatureBlock } from "@/components/email/signature-block"; import { parkRemoteResources, restoreRemoteResources } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; @@ -235,7 +236,14 @@ export const QuotedHtml = TiptapNode.create({ export function serializeEditorContent(editor: Editor): string { const serializer = DOMSerializer.fromSchema(editor.schema); const parts: string[] = []; - editor.state.doc.forEach((node) => { + // Empty paragraphs at the very end (the editor keeps one after a quote) + // stay invisible; every other empty line gets a
so it keeps its + // height in the recipient's client (#1015). + let lastLine = -1; + editor.state.doc.forEach((node, _offset, index) => { + if (node.type.name !== "paragraph" || node.childCount > 0) lastLine = index; + }); + editor.state.doc.forEach((node, _offset, index) => { 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 @@ -253,6 +261,7 @@ export function serializeEditorContent(editor: Editor): string { const fragment = serializer.serializeNode(node); const tmp = document.createElement("div"); tmp.appendChild(fragment); + if (index <= lastLine) addTrailingParagraphBreaks(tmp); parts.push(tmp.innerHTML); }); return parts.join(""); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 35413bd10..a92c28c4d 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -23,6 +23,7 @@ import { TableRow } from "@tiptap/extension-table-row"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableCell } from "@tiptap/extension-table-cell"; import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html"; +import { trailingParagraphBreakRule } from "@/components/email/paragraph-breaks"; import { SignatureBlock } from "@/components/email/signature-block"; import { styledBlockAttributes } from "@/components/email/styled-block-attributes"; import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle } from "@/components/email/recipient-mention"; @@ -69,6 +70,10 @@ const StyledParagraph = Paragraph.extend({ ...styledBlockAttributes, }; }, + // The
serializeEditorContent adds to empty lines isn't a hard break. + parseHTML() { + return [...(this.parent?.() ?? []), trailingParagraphBreakRule]; + }, }); const StyledHeading = Heading.extend({