mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep blank lines in sent mail instead of collapsing them #1015
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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("");
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user