mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
147 lines
4.8 KiB
TypeScript
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())));
|
|
},
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|