diff --git a/components/email/__tests__/plain-text-paste.test.ts b/components/email/__tests__/plain-text-paste.test.ts index 5a517ee5b..b47db7795 100644 --- a/components/email/__tests__/plain-text-paste.test.ts +++ b/components/email/__tests__/plain-text-paste.test.ts @@ -19,6 +19,8 @@ function paste(editor: Editor, text: string) { } describe('plain-text paste', () => { + // A paste that ends in a list leaves the empty paragraph the cursor was in + // below it, hence the trailing
in those expectations. it('keeps blank lines as empty paragraphs', () => { const editor = makeEditor(); paste(editor, 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1'); @@ -43,6 +45,78 @@ describe('plain-text paste', () => { editor.destroy(); }); + it('turns dash items into a bullet list, dropping the blank lines between them', () => { + const editor = makeEditor(); + paste(editor, 'How it works:\n\n- One\n\n- Two\n\nAfter'); + expect(editor.getHTML()).toBe( + 'How it works:
' + + 'One
Two
After
', + ); + editor.destroy(); + }); + + it('accepts * and • as bullet markers', () => { + const editor = makeEditor(); + paste(editor, '* One\n• Two'); + expect(editor.getHTML()).toBe('One
Two
One
Two
Text
' + + 'Three
Four
One
Dash
Both drafts:
filenode-14
blobext-01
After
', + ); + editor.destroy(); + }); + + it('nests indented items', () => { + const editor = makeEditor(); + paste(editor, '- Outer\n - Inner\n- Next'); + expect(editor.getHTML()).toBe( + 'Outer
Inner
Next
A long item that the
sender wrapped
Next
Costs - roughly
--
Linus
'); + 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(); diff --git a/components/email/plain-text-paste.ts b/components/email/plain-text-paste.ts index ded9db99a..50c7bf4e8 100644 --- a/components/email/plain-text-paste.ts +++ b/components/email/plain-text-paste.ts @@ -1,9 +1,124 @@ import { Extension } from "@tiptap/core"; -import { Fragment, Slice } from "@tiptap/pm/model"; +import { Fragment, Slice, type Mark, type Node, type Schema } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +type PlainBlock = + | { type: "line"; text: string } + | { type: "list"; ordered: boolean; start: number; items: PlainBlock[][] }; + +interface ItemMarker { + indent: number; + ordered: boolean; + start: number; + content: string; +} + +// "- ", "* ", "• ", "1. " or "1) " followed by the item text. +const ITEM_MARKER = /^([ \t]*)(?:[-*•]|(\d{1,3})[.)])[ \t]+(?=\S)/; + +const isBlank = (line: string) => line.trim() === ""; + +function indentOf(line: string): number { + const lead = /^[ \t]*/.exec(line)![0]; + return lead.replace(/\t/g, " ").length; +} + +function dedent(line: string, columns: number): string { + let i = 0; + for (let col = 0; i < line.length && col < columns; i++) { + if (line[i] === " ") col += 1; + else if (line[i] === "\t") col += 4; + else break; + } + return line.slice(i); +} + +function itemMarker(line: string): ItemMarker | null { + const m = ITEM_MARKER.exec(line); + if (!m) return null; + return { + indent: indentOf(m[1]), + ordered: m[2] !== undefined, + start: m[2] !== undefined ? Number(m[2]) : 1, + content: line.slice(m[0].length), + }; +} + +function parseLines(lines: string[]): PlainBlock[] { + const blocks: PlainBlock[] = []; + let i = 0; + while (i < lines.length) { + const marker = itemMarker(lines[i]); + if (marker) { + const items: PlainBlock[][] = []; + i = parseList(lines, i, marker, items); + blocks.push({ type: "list", ordered: marker.ordered, start: marker.start, items }); + } else { + blocks.push({ type: "line", text: isBlank(lines[i]) ? "" : lines[i] }); + i++; + } + } + return blocks; +} + /** - * Pastes plain text as one paragraph per line, blank lines included. + * Reads the items of one list starting at `i` into `items` and returns the + * index of the first line after it. An item takes the lines indented under + * it (continuations and nested lists) and, like Markdown, unindented lines + * that follow it directly, so hard-wrapped items stay whole. Blank lines + * between items are dropped; blank lines after the list are left for the + * caller. + */ +function parseList(lines: string[], i: number, first: ItemMarker, items: PlainBlock[][]): number { + let marker = first; + for (;;) { + const body: { text: string; indented: boolean }[] = []; + let j = i + 1; + let end = j; + let lazy = true; + while (j < lines.length) { + const line = lines[j]; + if (isBlank(line)) { + lazy = false; + j++; + continue; + } + const indented = indentOf(line) > marker.indent; + if (!indented && !(lazy && !itemMarker(line))) break; + for (let k = end; k < j; k++) body.push({ text: "", indented: false }); + body.push({ text: line, indented }); + end = ++j; + lazy = true; + } + + const shift = Math.min(...body.filter((l) => l.indented).map((l) => indentOf(l.text))); + const rest = body.map((l) => (l.indented ? dedent(l.text, shift) : l.text.trimStart())); + items.push([{ type: "line", text: marker.content }, ...parseLines(rest)]); + + let next = end; + while (next < lines.length && isBlank(lines[next])) next++; + const sibling = next < lines.length ? itemMarker(lines[next]) : null; + if (!sibling || sibling.ordered !== first.ordered || sibling.indent !== first.indent) return end; + i = next; + marker = sibling; + } +} + +function toNodes(blocks: PlainBlock[], schema: Schema, marks: readonly Mark[]): Node[] { + return blocks.map((block) => { + if (block.type === "line") { + return schema.nodes.paragraph.create(null, block.text ? schema.text(block.text, marks) : null); + } + const items = block.items.map((item) => schema.nodes.listItem.create(null, toNodes(item, schema, marks))); + return block.ordered + ? schema.nodes.orderedList.create({ start: block.start }, items) + : schema.nodes.bulletList.create(null, items); + }); +} + +/** + * Pastes plain text as one paragraph per line, blank lines included, and + * turns "- " / "1. " items into real lists. * * 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 @@ -21,13 +136,8 @@ export const PlainTextPaste = Extension.create({ 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)); + const blocks = parseLines(text.split(/\r\n?|\n/)); + return Slice.maxOpen(Fragment.from(toNodes(blocks, schema, $context.marks()))); }, }, }),