Files

147 lines
4.8 KiB
TypeScript

import { Extension } from "@tiptap/core";
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;
}
/**
* 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
* 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 blocks = parseLines(text.split(/\r\n?|\n/));
return Slice.maxOpen(Fragment.from(toNodes(blocks, schema, $context.marks())));
},
},
}),
];
},
});