fix: keep blank lines when pasting plain text into the composer

This commit is contained in:
Linus Rath
2026-09-30 19:46:05 +02:00
parent 7b61e55529
commit c081a51842
3 changed files with 101 additions and 0 deletions
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { PlainTextPaste } from '../plain-text-paste';
function makeEditor(content = '<p></p>') {
return new Editor({
element: document.createElement('div'),
extensions: [StarterKit, PlainTextPaste],
coreExtensionOptions: { clipboardTextSerializer: { blockSeparator: '\n' } },
content,
});
}
// jsdom has no ClipboardEvent, which pasteText constructs when given none.
function paste(editor: Editor, text: string) {
editor.view.pasteText(text, new Event('paste') as ClipboardEvent);
}
describe('plain-text paste', () => {
it('keeps blank lines as empty paragraphs', () => {
const editor = makeEditor();
paste(editor, 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1');
expect(editor.getHTML()).toBe(
'<p>Hi Joost,</p><p></p><p>Thanks for the write-up.</p><p></p><p></p><p>PHASE 1</p>',
);
editor.destroy();
});
it('treats CRLF and CR line endings like LF', () => {
const editor = makeEditor();
paste(editor, 'one\r\n\r\ntwo\rthree');
expect(editor.getHTML()).toBe('<p>one</p><p></p><p>two</p><p>three</p>');
editor.destroy();
});
it('pastes a single line inline without splitting the paragraph', () => {
const editor = makeEditor('<p>Hello world</p>');
editor.commands.setTextSelection(7);
paste(editor, 'big ');
expect(editor.getHTML()).toBe('<p>Hello big world</p>');
editor.destroy();
});
it('copies back out as the same text', () => {
const text = 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1';
const editor = makeEditor();
paste(editor, text);
// TipTap's serializer reads the editor selection, not the slice it gets.
editor.commands.selectAll();
const slice = editor.state.selection.content();
const copied = editor.view.someProp('clipboardTextSerializer', (f) => f(slice, editor.view));
expect(copied).toBe(text);
editor.destroy();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { Extension } from "@tiptap/core";
import { Fragment, Slice } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
/**
* Pastes plain text as one paragraph per line, blank lines included.
*
* In the composer Enter starts a new paragraph and a blank line is an empty
* paragraph. ProseMirror's default text paste splits on runs of newlines
* instead, so every blank line was dropped and a pasted mail arrived as a
* wall of lines with no gap between its paragraphs. This builds the same
* paragraphs typing the text would.
*/
export const PlainTextPaste = Extension.create({
name: "plainTextPaste",
addProseMirrorPlugins() {
const { schema } = this.editor;
return [
new Plugin({
key: new PluginKey("plainTextPaste"),
props: {
clipboardTextParser: (text, $context) => {
const marks = $context.marks();
const paragraphs = text
.split(/\r\n?|\n/)
.map((line) =>
schema.nodes.paragraph.create(null, line ? schema.text(line, marks) : null),
);
return Slice.maxOpen(Fragment.from(paragraphs));
},
},
}),
];
},
});
+8
View File
@@ -9,6 +9,7 @@ import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
import { TextDirection } from "@/components/email/text-direction";
import { PlainTextPaste } from "@/components/email/plain-text-paste";
import { TextStyle, BackgroundColor } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { FontSize, FONT_SIZES } from "@/components/email/font-size";
@@ -236,7 +237,14 @@ export function RichTextEditor({
// into editable content (#822).
SignatureBlock.configure({ editHint: tComposer('signature_edit_hint') }),
TextDirection,
PlainTextPaste,
],
// One newline per paragraph when copying out as plain text, the inverse
// of PlainTextPaste: a blank line is an empty paragraph, so the default
// "\n\n" separator doubled every line break.
coreExtensionOptions: {
clipboardTextSerializer: { blockSeparator: "\n" },
},
content,
editorProps: {
attributes: {