fix: keep blank lines in sent mail instead of collapsing them #1015

This commit is contained in:
Linus Rath
2026-10-17 19:17:54 +02:00
parent 0fb4fc53cf
commit 5515297857
5 changed files with 170 additions and 2 deletions
@@ -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(
<RichTextEditor
content={content}
onChange={() => {}}
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('<p>one</p><p></p><p></p><p>two</p>');
expect(serialize(editor)).toBe('<p>one</p><p><br></p><p><br></p><p>two</p>');
});
it('does not add a line when the saved body is opened again', async () => {
const editor = await setup('<p>one</p><p></p><p>two</p>');
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('<p>one</p>');
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('<p>one<br><br></p>');
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('<p>one<br>two</p>');
expect(serialize(editor)).toBe('<p>one<br>two</p>');
});
it('does not touch the quoted original or the signature markers', async () => {
const quoted = '<p>quoted</p><p></p><p>more</p>';
const editor = await setup(
'<p>reply</p><p></p>'
+ '<p data-signature-block="start"></p><p>Alice</p><p data-signature-block="end"></p>'
+ `<div data-quoted-html="">${quoted}</div>`,
);
const html = serialize(editor);
expect(html).toBe(
'<p>reply</p><p><br></p>'
+ '<p data-signature-block="start"></p><p>Alice</p><p data-signature-block="end"></p>'
// The editor's own empty paragraph after the quote adds no line.
+ `<div data-quoted-html style="border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;">${quoted}</div><p></p>`,
);
});
it('adds no blank lines at the end of the message', async () => {
const editor = await setup('<p>one</p><p></p><p>two</p><p></p><p></p>');
expect(serialize(editor)).toBe('<p>one</p><p><br></p><p>two</p><p></p><p></p>');
});
});
@@ -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(/<p/g) || []).length).toBe(4);
expect((out.match(/<br/g) || []).length).toBe(7);
// 7 in the signature, 1 keeping the empty first line visible (#1015).
expect((out.match(/<br/g) || []).length).toBe(8);
expect(out).toContain('font-size: 9pt');
expect(out).toContain('Jane Doe');
} finally {
+57
View File
@@ -0,0 +1,57 @@
import { SIGNATURE_RANGE_MARKER } from "@/components/email/signature-block";
/**
* Empty lines in the composer (#1015).
*
* The composer writes one paragraph per line, so a blank line is an empty
* paragraph. ProseMirror serializes that as `<p></p>`, 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 `<p><br></p>`.
*
* The rule here mirrors the browser's: a <br> 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 <br>; 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 <br> 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 <br> 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 <br> 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;
+10 -1
View File
@@ -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 <br> 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("");
+5
View File
@@ -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 <br> serializeEditorContent adds to empty lines isn't a hard break.
parseHTML() {
return [...(this.parent?.() ?? []), trailingParagraphBreakRule];
},
});
const StyledHeading = Heading.extend({