mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: turn pasted dash and numbered lines into lists in the composer
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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())));
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user