mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: send paragraphs with the spacing the composer shows #1340
This commit is contained in:
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user