feat: turn pasted dash and numbered lines into lists in the composer

This commit is contained in:
Linus Rath
2026-09-30 20:00:01 +02:00
parent bfc8e0e52e
commit ac09146054
2 changed files with 193 additions and 9 deletions
@@ -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 <p></p> 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(
'<p>How it works:</p><p></p>' +
'<ul><li><p>One</p></li><li><p>Two</p></li></ul>' +
'<p></p><p>After</p>',
);
editor.destroy();
});
it('accepts * and • as bullet markers', () => {
const editor = makeEditor();
paste(editor, '* One\n• Two');
expect(editor.getHTML()).toBe('<ul><li><p>One</p></li><li><p>Two</p></li></ul><p></p>');
editor.destroy();
});
it('turns numbered items into an ordered list that keeps its first number', () => {
const editor = makeEditor();
paste(editor, '1. One\n2) Two\n\nText\n\n3. Three\n4. Four');
expect(editor.getHTML()).toBe(
'<ol><li><p>One</p></li><li><p>Two</p></li></ol><p></p><p>Text</p><p></p>' +
'<ol start="3"><li><p>Three</p></li><li><p>Four</p></li></ol><p></p>',
);
editor.destroy();
});
it('starts a new list where a numbered list switches to dashes', () => {
const editor = makeEditor();
paste(editor, '1. One\n\n- Dash');
expect(editor.getHTML()).toBe(
'<ol><li><p>One</p></li></ol><p></p><ul><li><p>Dash</p></li></ul><p></p>',
);
editor.destroy();
});
it('keeps indented lines inside the item above them', () => {
const editor = makeEditor();
paste(editor, '- Both drafts:\n filenode-14\n blobext-01\n\nAfter');
expect(editor.getHTML()).toBe(
'<ul><li><p>Both drafts:</p><p>filenode-14</p><p>blobext-01</p></li></ul>' +
'<p></p><p>After</p>',
);
editor.destroy();
});
it('nests indented items', () => {
const editor = makeEditor();
paste(editor, '- Outer\n - Inner\n- Next');
expect(editor.getHTML()).toBe(
'<ul><li><p>Outer</p><ul><li><p>Inner</p></li></ul></li><li><p>Next</p></li></ul><p></p>',
);
editor.destroy();
});
it('keeps a hard-wrapped item whole', () => {
const editor = makeEditor();
paste(editor, '- A long item that the\nsender wrapped\n- Next');
expect(editor.getHTML()).toBe(
'<ul><li><p>A long item that the</p><p>sender wrapped</p></li><li><p>Next</p></li></ul><p></p>',
);
editor.destroy();
});
it('leaves a signature delimiter and a dash inside a sentence alone', () => {
const editor = makeEditor();
paste(editor, 'Costs - roughly\n-- \nLinus');
expect(editor.getHTML()).toBe('<p>Costs - roughly</p><p>-- </p><p>Linus</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();
+119 -9
View File
@@ -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())));
},
},
}),