diff --git a/components/email/__tests__/plain-text-paste.test.ts b/components/email/__tests__/plain-text-paste.test.ts new file mode 100644 index 000000000..5a517ee5b --- /dev/null +++ b/components/email/__tests__/plain-text-paste.test.ts @@ -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 = '

') { + 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( + '

Hi Joost,

Thanks for the write-up.

PHASE 1

', + ); + 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('

one

two

three

'); + editor.destroy(); + }); + + it('pastes a single line inline without splitting the paragraph', () => { + const editor = makeEditor('

Hello world

'); + editor.commands.setTextSelection(7); + paste(editor, 'big '); + expect(editor.getHTML()).toBe('

Hello big world

'); + 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(); + }); +}); diff --git a/components/email/plain-text-paste.ts b/components/email/plain-text-paste.ts new file mode 100644 index 000000000..ded9db99a --- /dev/null +++ b/components/email/plain-text-paste.ts @@ -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)); + }, + }, + }), + ]; + }, +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index ea6729ec7..5189cec97 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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: {