fix: send paragraphs with the spacing the composer shows #1340

This commit is contained in:
Linus Rath
2026-10-25 12:27:13 +01:00
parent 171fd1ea4c
commit 9c360694bf
5 changed files with 105 additions and 4 deletions
@@ -19,8 +19,11 @@ async function setup(content: string) {
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, '');
// The composer marks every block dir="auto" and inlines the paragraph margin
// (#1340); that's not what these tests are about.
const serialize = (editor: Editor) => serializeEditorContent(editor)
.replace(/ dir="auto"/g, '')
.replace(/ style="margin:4px 0;"/g, '');
// Re-open what was saved, the way a draft or a signature swap does.
function reload(editor: Editor, html: string) {
@@ -0,0 +1,63 @@
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;
}
const serialize = (editor: Editor) => serializeEditorContent(editor).replace(/ dir="auto"/g, '');
describe('RichTextEditor paragraph spacing (#1340)', () => {
it('sends each line with the 4px margin the composer shows, not the 1em default', async () => {
const editor = await setup('<p>one</p><p>two</p>');
expect(serialize(editor)).toBe('<p style="margin:4px 0;">one</p><p style="margin:4px 0;">two</p>');
});
it('keeps the alignment next to the margin', async () => {
const editor = await setup('<p style="text-align: center">mid</p>');
expect(serialize(editor)).toBe('<p style="margin:4px 0;text-align: center;">mid</p>');
});
it('spaces paragraphs in lists too', async () => {
const editor = await setup('<ul><li><p>a</p></li></ul>');
expect(serialize(editor)).toContain('<li><p style="margin:4px 0;">a</p></li>');
});
it('sends no margin inside a table, as the composer shows none there', async () => {
const editor = await setup('<table><tbody><tr><td><p>cell</p></td></tr></tbody></table>');
expect(serialize(editor)).toContain('<p style="margin:0;">cell</p>');
});
it('does not stack margins when a saved draft is opened again', async () => {
const editor = await setup('<p style="text-align: right">one</p><p>two</p>');
const saved = serialize(editor);
act(() => { editor.commands.setContent(saved, { emitUpdate: false }); });
expect(serialize(editor)).toBe(saved);
});
it('leaves the quoted original alone', async () => {
const editor = await setup('<p>reply</p><div data-quoted-html=""><p>quoted</p></div>');
expect(serialize(editor)).toContain('<p>quoted</p>');
});
});
+33
View File
@@ -39,6 +39,39 @@ export function addTrailingParagraphBreaks(root: ParentNode): void {
});
}
/**
* Paragraph spacing (#1340).
*
* The composer shows paragraphs 4px apart (`.tiptap p` in globals.css, none
* inside a table), but a sent `<p>` carries no style, so the Sent folder and
* every recipient's client fall back to the browser's 1em margins and each
* Enter looks like a blank line. Inline the composer's margin on the way out.
* Paragraphs keep their `style` in the editor, so the parse side strips the
* margin again (paragraphStyleWithoutSpacing) and a re-opened draft doesn't
* stack one more margin per save.
*/
const PARAGRAPH_MARGIN = "margin:4px 0;";
const TABLE_PARAGRAPH_MARGIN = "margin:0;";
export function addParagraphSpacing(root: ParentNode): void {
root.querySelectorAll("p").forEach((p) => {
if (p.hasAttribute(SIGNATURE_RANGE_MARKER)) return;
const margin = p.closest("table") ? TABLE_PARAGRAPH_MARGIN : PARAGRAPH_MARGIN;
// Ahead of any style the editor wrote, so an explicit margin still wins.
p.setAttribute("style", margin + (p.getAttribute("style") ?? ""));
});
}
/** A paragraph's `style` without the margin addParagraphSpacing put in front. */
export function paragraphStyleWithoutSpacing(el: HTMLElement): string | null {
const style = el.getAttribute("style");
if (style === null) return null;
for (const margin of [PARAGRAPH_MARGIN, TABLE_PARAGRAPH_MARGIN]) {
if (style.startsWith(margin)) return style.slice(margin.length).trim() || null;
}
return style;
}
/** 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;
+2 -1
View File
@@ -5,7 +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 { addParagraphSpacing, addTrailingParagraphBreaks } from "@/components/email/paragraph-breaks";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { parkRemoteResources, restoreRemoteResources, sanitizeComposerIslandHtml } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
@@ -270,6 +270,7 @@ export function serializeEditorContent(editor: Editor): string {
const tmp = document.createElement("div");
tmp.appendChild(fragment);
if (index <= lastLine) addTrailingParagraphBreaks(tmp);
addParagraphSpacing(tmp);
parts.push(tmp.innerHTML);
});
return parts.join("");
+2 -1
View File
@@ -23,7 +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 { paragraphStyleWithoutSpacing, 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 +69,7 @@ const StyledParagraph = Paragraph.extend({
return {
...this.parent?.(),
...styledBlockAttributes,
style: { ...styledBlockAttributes.style, parseHTML: paragraphStyleWithoutSpacing },
};
},
// The <br> serializeEditorContent adds to empty lines isn't a hard break.