mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-04 20:17:04 +00:00
features
This commit is contained in:
commit
852cba1840
252 files changed
+55050
No files matched your search
@@ -0,0 +1,396 @@
|
||||
// Highlighting is paint, and the tests that matter are the ones that prove it stayed paint: the
|
||||
// text of a fence, the attributes on it and the bytes it serializes to are the same whether or not
|
||||
// a grammar was ever run over it. The rest is about the two ways a highlighter goes wrong in a real
|
||||
// editor. It can throw or misalign on input it did not expect, which is answered here by fences
|
||||
// nobody has a grammar for, and it can be slow, which is answered by counting how much of the
|
||||
// document it walks when one character is typed.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import type { Plugin } from "@tiptap/pm/state";
|
||||
import type { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
// The highlighter is private to code.ts, deliberately, so the only place left to watch how often it
|
||||
// runs is underneath it. Everything the real lowlight does still happens; the wrapper only records
|
||||
// the text it was handed.
|
||||
const { highlighted } = vi.hoisted(() => ({ highlighted: [] as string[] }));
|
||||
|
||||
vi.mock("lowlight", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lowlight")>();
|
||||
return {
|
||||
...actual,
|
||||
createLowlight(...created: Parameters<typeof actual.createLowlight>) {
|
||||
const instance = actual.createLowlight(...created);
|
||||
return {
|
||||
...instance,
|
||||
highlight(...call: Parameters<typeof instance.highlight>) {
|
||||
highlighted.push(call[1]);
|
||||
return instance.highlight(...call);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { createEditorExtensions } = await import("../extensions");
|
||||
const { setCodeLanguage } = await import("./code");
|
||||
const { parseMarkdown, serializeMarkdown } = await import("../../markdown");
|
||||
|
||||
const extensions = () =>
|
||||
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
|
||||
|
||||
function editorFor(source: string): Editor {
|
||||
return new Editor({
|
||||
element: null,
|
||||
injectCSS: false,
|
||||
extensions: extensions(),
|
||||
content: parseMarkdown(source, "/notes/a.md").doc.toJSON(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The document with the highlighting plugin over it, and nothing else.
|
||||
*
|
||||
* An editor cannot be mounted without a DOM and an unmounted one's state carries no plugins at all,
|
||||
* so the plugin is lifted out of the extension manager and given a state of its own. What it sees
|
||||
* there is what it sees in the app: a real state over a real parsed document, and transactions
|
||||
* applied to it one at a time. Only the view is missing, and a decoration is computed without one.
|
||||
*/
|
||||
function stateFor(source: string): EditorState {
|
||||
const editor = editorFor(source);
|
||||
const plugin = editor.extensionManager.plugins.find((candidate: Plugin) =>
|
||||
String((candidate as unknown as { key: string }).key).startsWith("codeHighlighting"),
|
||||
);
|
||||
if (!plugin) throw new Error("the code highlighting plugin is not in the extension list");
|
||||
const state = EditorState.create({ doc: editor.state.doc, plugins: [plugin] });
|
||||
editor.destroy();
|
||||
return state;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
pos: number;
|
||||
node: ProseMirrorNode;
|
||||
}
|
||||
|
||||
function codeBlocks(doc: ProseMirrorNode): Block[] {
|
||||
const found: Block[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== "codeBlock") return true;
|
||||
found.push({ pos, node });
|
||||
return false;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function decorations(state: EditorState): Decoration[] {
|
||||
for (const plugin of state.plugins) {
|
||||
const set = plugin.getState(state) as DecorationSet | undefined;
|
||||
if (set) return set.find();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function classOf(decoration: Decoration): string {
|
||||
return (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class;
|
||||
}
|
||||
|
||||
/** The text a span was cut from, which is the only thing that says it landed in the right place. */
|
||||
function textOf(state: EditorState, decoration: Decoration): string {
|
||||
return state.doc.textBetween(decoration.from, decoration.to);
|
||||
}
|
||||
|
||||
function spanWith(state: EditorState, className: string): string | undefined {
|
||||
const found = decorations(state).find((decoration) => classOf(decoration).includes(className));
|
||||
return found && textOf(state, found);
|
||||
}
|
||||
|
||||
function fence(language: string, ...lines: string[]): string {
|
||||
return ["```" + language, ...lines, "```", ""].join("\n");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
highlighted.length = 0;
|
||||
});
|
||||
|
||||
describe("the highlighter", () => {
|
||||
it("colours the four languages this repo's own docs are written in", () => {
|
||||
const sources: Record<string, string> = {
|
||||
rust: fence("rust", "fn main() {}"),
|
||||
toml: fence("toml", "[package]", 'name = "margin-docs"'),
|
||||
swift: fence("swift", "let x = 1"),
|
||||
kotlin: fence("kotlin", "val x = 1"),
|
||||
};
|
||||
|
||||
for (const [language, source] of Object.entries(sources)) {
|
||||
const state = stateFor(source);
|
||||
expect([language, decorations(state).length > 0]).toEqual([language, true]);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts every span over the characters it was cut from", () => {
|
||||
const state = stateFor(fence("rust", 'fn main() { let x = "hi"; }', "// a comment"));
|
||||
const [block] = codeBlocks(state.doc);
|
||||
const from = block.pos + 1;
|
||||
const to = from + block.node.content.size;
|
||||
|
||||
const found = decorations(state);
|
||||
expect(found.length).toBeGreaterThan(3);
|
||||
for (const decoration of found) {
|
||||
expect(decoration.from).toBeGreaterThanOrEqual(from);
|
||||
expect(decoration.to).toBeLessThanOrEqual(to);
|
||||
expect(decoration.from).toBeLessThan(decoration.to);
|
||||
}
|
||||
|
||||
expect(spanWith(state, "hljs-keyword")).toBe("fn");
|
||||
expect(spanWith(state, "hljs-string")).toBe('"hi"');
|
||||
expect(spanWith(state, "hljs-comment")).toBe("// a comment");
|
||||
});
|
||||
|
||||
it("keeps its offsets over text that is not one code unit per character", () => {
|
||||
const state = stateFor(fence("ts", 'const flag = "🇬🇧 ok";', "\tconst tabbed = 1;"));
|
||||
|
||||
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
|
||||
'const flag = "🇬🇧 ok";\n\tconst tabbed = 1;',
|
||||
);
|
||||
expect(spanWith(state, "hljs-string")).toBe('"🇬🇧 ok"');
|
||||
});
|
||||
|
||||
it("leaves a fence tagged with a language nobody has plain, and loses nothing", () => {
|
||||
const source = fence("nosuchlanguage", "this is not code in any language", " indented ");
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const state = stateFor(source);
|
||||
|
||||
expect(decorations(state)).toEqual([]);
|
||||
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
|
||||
"this is not code in any language\n indented ",
|
||||
);
|
||||
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
|
||||
});
|
||||
|
||||
it("leaves a bare fence plain", () => {
|
||||
const state = stateFor(fence("", "just some text"));
|
||||
|
||||
expect(codeBlocks(state.doc)[0].node.attrs.language).toBe(null);
|
||||
expect(decorations(state)).toEqual([]);
|
||||
expect(highlighted).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a mermaid fence to the lane that draws it", () => {
|
||||
const state = stateFor(fence("mermaid", "graph TD;", " a-->b;"));
|
||||
|
||||
expect(decorations(state)).toEqual([]);
|
||||
expect(highlighted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not throw on code that is broken in its own language", () => {
|
||||
const source = fence("json", "{ this is not, json: ]]", '"neither" is "this"');
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const state = stateFor(source);
|
||||
|
||||
expect(codeBlocks(state.doc)[0].node.textContent).toBe(
|
||||
'{ this is not, json: ]]\n"neither" is "this"',
|
||||
);
|
||||
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
|
||||
for (const decoration of decorations(state)) {
|
||||
expect(textOf(state, decoration).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a fence too long to be read as code plain, and keeps every character", () => {
|
||||
const line = "const x = 1; // a line of code that is being repeated a great many times\n";
|
||||
const long = line.repeat(1000);
|
||||
expect(long.length).toBeGreaterThan(50_000);
|
||||
|
||||
const state = stateFor(fence("ts", long.trimEnd()));
|
||||
|
||||
expect(decorations(state)).toEqual([]);
|
||||
expect(highlighted).toEqual([]);
|
||||
expect(codeBlocks(state.doc)[0].node.textContent).toBe(long.trimEnd());
|
||||
});
|
||||
|
||||
it("does not write to the document it is painting over", () => {
|
||||
const source = ["# Notes", "", fence("rust twoslash", "fn main() {}"), "Prose.", ""].join("\n");
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const state = stateFor(source);
|
||||
|
||||
expect(state.doc.toJSON()).toEqual(parsed.doc.toJSON());
|
||||
expect(codeBlocks(state.doc)[0].node.attrs).toEqual({ language: "rust", meta: "twoslash" });
|
||||
expect(serializeMarkdown(parsed, state.doc)).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a keystroke costs", () => {
|
||||
const twoBlocks = () =>
|
||||
stateFor(
|
||||
[fence("ts", "const first = 1;"), fence("ts", "const second = 2;"), "Prose.", ""].join("\n"),
|
||||
);
|
||||
|
||||
const endOf = (block: Block) => block.pos + 1 + block.node.content.size;
|
||||
|
||||
it("re-highlights only the block the edit landed in", () => {
|
||||
const state = twoBlocks();
|
||||
const blocks = codeBlocks(state.doc);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(highlighted).toEqual(["const first = 1;", "const second = 2;"]);
|
||||
|
||||
highlighted.length = 0;
|
||||
state.apply(state.tr.insertText("2", endOf(blocks[1])));
|
||||
|
||||
expect(highlighted).toEqual(["const second = 2;2"]);
|
||||
});
|
||||
|
||||
it("carries the untouched block's spans forward, still over their own characters", () => {
|
||||
const before = twoBlocks();
|
||||
const state = before.apply(before.tr.insertText("// ", codeBlocks(before.doc)[0].pos + 1));
|
||||
|
||||
const second = codeBlocks(state.doc)[1];
|
||||
const inSecond = decorations(state).filter((decoration) => decoration.from > second.pos);
|
||||
expect(inSecond.length).toBeGreaterThan(0);
|
||||
for (const decoration of inSecond) {
|
||||
expect("const second = 2;").toContain(textOf(state, decoration));
|
||||
}
|
||||
|
||||
const keyword = decorations(state).find(
|
||||
(decoration) =>
|
||||
decoration.from > second.pos && classOf(decoration).includes("hljs-keyword"),
|
||||
);
|
||||
expect(keyword && textOf(state, keyword)).toBe("const");
|
||||
});
|
||||
|
||||
it("re-highlights a block whose language changed, and no other", () => {
|
||||
const before = twoBlocks();
|
||||
const block = codeBlocks(before.doc)[0];
|
||||
|
||||
highlighted.length = 0;
|
||||
const state = before.apply(
|
||||
before.tr.setNodeMarkup(block.pos, null, { ...block.node.attrs, language: "rust" }),
|
||||
);
|
||||
|
||||
expect(highlighted).toEqual(["const first = 1;"]);
|
||||
expect(spanWith(state, "hljs-keyword")).toBe("const");
|
||||
});
|
||||
|
||||
it("drops the spans of a block that was deleted, and highlights nothing again", () => {
|
||||
const before = twoBlocks();
|
||||
const block = codeBlocks(before.doc)[1];
|
||||
|
||||
highlighted.length = 0;
|
||||
const state = before.apply(
|
||||
before.tr.delete(block.pos, block.pos + block.node.nodeSize),
|
||||
);
|
||||
|
||||
expect(highlighted).toEqual([]);
|
||||
expect(codeBlocks(state.doc)).toHaveLength(1);
|
||||
for (const decoration of decorations(state)) {
|
||||
expect("const first = 1;").toContain(textOf(state, decoration));
|
||||
}
|
||||
});
|
||||
|
||||
it("highlights a block that was inserted after the document loaded, and no other", () => {
|
||||
const before = twoBlocks();
|
||||
const at = codeBlocks(before.doc)[1].pos;
|
||||
|
||||
highlighted.length = 0;
|
||||
const state = before.apply(
|
||||
before.tr.insert(
|
||||
at,
|
||||
before.doc.type.schema.nodes.codeBlock.create({ language: "rust", meta: null }, [
|
||||
before.doc.type.schema.text("fn main() {}"),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(highlighted).toEqual(["fn main() {}"]);
|
||||
expect(codeBlocks(state.doc)).toHaveLength(3);
|
||||
expect(spanWith(state, "hljs-keyword")).toBe("const");
|
||||
});
|
||||
|
||||
it("does not run at all for a transaction that changed no text", () => {
|
||||
const before = twoBlocks();
|
||||
|
||||
highlighted.length = 0;
|
||||
const state = before.apply(before.tr.setMeta("nothing", true));
|
||||
|
||||
expect(highlighted).toEqual([]);
|
||||
expect(decorations(state).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCodeLanguage", () => {
|
||||
function editorAtFence(source: string): Editor {
|
||||
const editor = editorFor(source);
|
||||
editor.commands.setTextSelection(codeBlocks(editor.state.doc)[0].pos + 1);
|
||||
return editor;
|
||||
}
|
||||
|
||||
it("writes the language and leaves the meta the user wrote alone", () => {
|
||||
const source = fence("ts twoslash", "const x = 1;");
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const editor = editorAtFence(source);
|
||||
|
||||
expect(setCodeLanguage(editor, "rust")).toBe(true);
|
||||
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({
|
||||
language: "rust",
|
||||
meta: "twoslash",
|
||||
});
|
||||
const written = serializeMarkdown(parsed, editor.state.doc);
|
||||
expect(written).toBe(fence("rust twoslash", "const x = 1;"));
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("clears the fence back to a bare one", () => {
|
||||
const source = fence("ts", "const x = 1;");
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const editor = editorAtFence(source);
|
||||
|
||||
expect(setCodeLanguage(editor, null)).toBe(true);
|
||||
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
|
||||
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(fence("", "const x = 1;"));
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("treats a blank language as a bare fence", () => {
|
||||
const editor = editorAtFence(fence("ts", "const x = 1;"));
|
||||
|
||||
expect(setCodeLanguage(editor, " ")).toBe(true);
|
||||
expect(codeBlocks(editor.state.doc)[0].node.attrs.language).toBe(null);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("refuses a language with a space in it, which the fence would read back as two things", () => {
|
||||
const source = fence("ts", "const x = 1;");
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const editor = editorAtFence(source);
|
||||
|
||||
expect(setCodeLanguage(editor, "ts twoslash")).toBe(false);
|
||||
expect(codeBlocks(editor.state.doc)[0].node.attrs).toEqual({ language: "ts", meta: null });
|
||||
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does nothing when the block already says that, so nothing is dirtied", () => {
|
||||
const editor = editorAtFence(fence("ts", "const x = 1;"));
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
expect(setCodeLanguage(editor, "ts")).toBe(false);
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does nothing outside a code block", () => {
|
||||
const editor = editorFor(["Just a paragraph.", ""].join("\n"));
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
expect(setCodeLanguage(editor, "rust")).toBe(false);
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
// Code block behaviour: syntax highlighting, and the language on the fence.
|
||||
//
|
||||
// Highlighting is decorations over the block's own text and never an edit to it. The spans belong
|
||||
// to the view, nothing they do reaches the tree, and a highlighted block therefore serializes back
|
||||
// to exactly the fence it was read from. lowlight rather than shiki, because a decoration set has
|
||||
// to be rebuilt synchronously inside the plugin and shiki highlights asynchronously.
|
||||
//
|
||||
// `language` and `meta` are already attributes on the schema's codeBlock, so setting a language is
|
||||
// an ordinary attribute edit and does change the document, which is the point: the fence on disk
|
||||
// changes with it. `meta` is never touched. It is whatever the user wrote after the language on
|
||||
// their own opening fence, this editor has no model for it, and it rides along untouched.
|
||||
//
|
||||
// The decoration set is rebuilt per code block rather than per document. A document is autosaved
|
||||
// half a second after the last keystroke, so the typing path is the hot one, and re-running a
|
||||
// grammar over every fence in a long file on every character typed is the obvious way to make this
|
||||
// editor feel slow. A transaction says which ranges it touched; only the code blocks those ranges
|
||||
// land in are highlighted again, and the rest are carried over by mapping the old set forward.
|
||||
//
|
||||
// A codeBlock whose language is mermaid belongs to the mermaid lane, which draws it as a diagram
|
||||
// through a node view. Nothing here decorates one.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { Transaction } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import type { LanguageFn } from "highlight.js";
|
||||
import ini from "highlight.js/lib/languages/ini";
|
||||
import kotlin from "highlight.js/lib/languages/kotlin";
|
||||
import rust from "highlight.js/lib/languages/rust";
|
||||
import swift from "highlight.js/lib/languages/swift";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
/**
|
||||
* The four languages this app's own docs folder is written in, registered by hand.
|
||||
*
|
||||
* lowlight's common set carries all four today, toml as an alias of ini, so the loop below does
|
||||
* nothing on this version. It is here because "common" is somebody else's list and it has been
|
||||
* trimmed before: if one of these ever falls out of it, the app's own documentation is the first
|
||||
* thing that stops highlighting, and that is a silly way to find out.
|
||||
*/
|
||||
const REQUIRED: ReadonlyArray<readonly [string, LanguageFn]> = [
|
||||
["rust", rust],
|
||||
["toml", ini],
|
||||
["swift", swift],
|
||||
["kotlin", kotlin],
|
||||
];
|
||||
|
||||
for (const [name, grammar] of REQUIRED) {
|
||||
if (!lowlight.registered(name)) lowlight.register(name, grammar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Past this many characters a fence is left plain.
|
||||
*
|
||||
* A block this long is a pasted file rather than code anyone is reading, and a grammar walking it
|
||||
* again on every keystroke is a stutter the user cannot explain. Nothing is lost by not colouring
|
||||
* it: the text is the document's, the decorations were only ever paint.
|
||||
*/
|
||||
const MAX_HIGHLIGHT_CHARS = 50_000;
|
||||
|
||||
type HighlightRoot = ReturnType<ReturnType<typeof createLowlight>["highlight"]>;
|
||||
type HighlightChild = HighlightRoot["children"][number];
|
||||
|
||||
const codeHighlightKey = new PluginKey<DecorationSet>("codeHighlighting");
|
||||
|
||||
/** The class names lowlight put on one span, as ProseMirror wants them: one string. */
|
||||
function classNameOf(properties: Record<string, unknown> | undefined): string {
|
||||
const value = properties?.className;
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((name): name is string => typeof name === "string").join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The language to highlight this block with, or null to leave it plain.
|
||||
*
|
||||
* A fence's info string is the user's text, not a menu selection: it can be blank, it can name a
|
||||
* language nobody has a grammar for, and it can be a typo. All three are plain text and none of
|
||||
* them is an error, so an unregistered name is answered here rather than by letting the highlighter
|
||||
* throw. Guessing is not on the list either: highlightAuto would colour a paragraph of prose as
|
||||
* whichever language it happened to resemble.
|
||||
*/
|
||||
function highlightableLanguage(node: ProseMirrorNode): string | null {
|
||||
const language = typeof node.attrs.language === "string" ? node.attrs.language.trim() : "";
|
||||
if (!language) return null;
|
||||
// Matched loosely, unlike the mermaid lane's own exact test, because the two must not both draw
|
||||
// the same block and the safe direction to be wrong in is leaving a block plain.
|
||||
if (language.toLowerCase() === "mermaid") return null;
|
||||
return lowlight.registered(language) ? language : null;
|
||||
}
|
||||
|
||||
/** `base` is the position of the block's first character, so `pos + 1` for the node at `pos`. */
|
||||
function decorationsFor(node: ProseMirrorNode, base: number): Decoration[] {
|
||||
const language = highlightableLanguage(node);
|
||||
if (!language) return [];
|
||||
|
||||
const text = node.textContent;
|
||||
if (!text || text.length > MAX_HIGHLIGHT_CHARS) return [];
|
||||
|
||||
let tree: HighlightRoot;
|
||||
try {
|
||||
tree = lowlight.highlight(language, text);
|
||||
} catch {
|
||||
// A grammar that throws on somebody's file is a highlighter's problem and never the document's.
|
||||
return [];
|
||||
}
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const walk = (children: readonly HighlightChild[]): void => {
|
||||
for (const child of children) {
|
||||
if (child.type === "text") {
|
||||
offset += child.value.length;
|
||||
} else if (child.type === "element") {
|
||||
const from = offset;
|
||||
walk(child.children);
|
||||
const className = classNameOf(child.properties);
|
||||
if (className && offset > from) {
|
||||
decorations.push(Decoration.inline(base + from, base + offset, { class: className }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(tree.children);
|
||||
|
||||
// The highlighter is a third party walking the user's text, and a decoration that runs past the
|
||||
// end of the block throws inside the view rather than merely looking wrong. If what came back
|
||||
// does not measure the same as what went in, the offsets cannot be trusted and the block stays
|
||||
// plain.
|
||||
return offset === text.length ? decorations : [];
|
||||
}
|
||||
|
||||
function highlightWholeDoc(doc: ProseMirrorNode): DecorationSet {
|
||||
const decorations: Decoration[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== "codeBlock") return true;
|
||||
decorations.push(...decorationsFor(node, pos + 1));
|
||||
return false;
|
||||
});
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
/**
|
||||
* The code blocks a transaction landed in, by position in the new document.
|
||||
*
|
||||
* Every step carries a map of the ranges it replaced. Mapping a step's range through the steps that
|
||||
* came after it puts it in the final document's coordinates, where the blocks it overlaps are the
|
||||
* ones whose text or attributes could have changed. An attribute-only edit counts: setting the
|
||||
* language rewrites the node, which shows up here as a touched range around it, which is what makes
|
||||
* the fence recolour the moment its language changes.
|
||||
*/
|
||||
function touchedCodeBlocks(tr: Transaction, doc: ProseMirrorNode): Map<number, ProseMirrorNode> {
|
||||
const blocks = new Map<number, ProseMirrorNode>();
|
||||
const end = doc.content.size;
|
||||
|
||||
tr.mapping.maps.forEach((stepMap, index) => {
|
||||
const rest = tr.mapping.slice(index + 1);
|
||||
stepMap.forEach((_oldFrom, _oldTo, newFrom, newTo) => {
|
||||
const from = Math.max(0, Math.min(end, rest.map(newFrom, -1)));
|
||||
const to = Math.max(from, Math.min(end, rest.map(newTo, 1)));
|
||||
doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.type.name !== "codeBlock") return true;
|
||||
blocks.set(pos, node);
|
||||
return false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export const CodeHighlighting = Extension.create({
|
||||
name: "codeHighlighting",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<DecorationSet>({
|
||||
key: codeHighlightKey,
|
||||
state: {
|
||||
init: (_config, state) => highlightWholeDoc(state.doc),
|
||||
apply(tr, value) {
|
||||
if (!tr.docChanged) return value;
|
||||
|
||||
const doc = tr.doc;
|
||||
const touched = touchedCodeBlocks(tr, doc);
|
||||
let next = value.map(tr.mapping, doc);
|
||||
if (!touched.size) return next;
|
||||
|
||||
const added: Decoration[] = [];
|
||||
for (const [pos, node] of touched) {
|
||||
const from = pos + 1;
|
||||
const to = from + node.content.size;
|
||||
// The spans mapped forward through the edit are the ones this block had before it,
|
||||
// stretched over text the grammar has not seen. They go before the new ones do.
|
||||
const stale = next.find(from, to);
|
||||
if (stale.length) next = next.remove(stale);
|
||||
added.push(...decorationsFor(node, from));
|
||||
}
|
||||
return added.length ? next.add(doc, added) : next;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => codeHighlightKey.getState(state) ?? DecorationSet.empty,
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/** null clears the fence back to a bare ```. False when the cursor is not in a code block. */
|
||||
export function setCodeLanguage(editor: Editor, language: string | null): boolean {
|
||||
if (!editor.isActive("codeBlock")) return false;
|
||||
|
||||
const next = language === null ? null : language.trim() || null;
|
||||
|
||||
// A fence's info string is one word of language and everything after it is `meta`, so a language
|
||||
// with a space in it would be read back off disk as a different language plus a meta the user
|
||||
// never wrote. Refusing leaves the file saying what it already says.
|
||||
if (next !== null && /\s/.test(next)) return false;
|
||||
|
||||
// Setting the language a block already has would dirty the document and spend an autosave
|
||||
// rewriting the file the user is looking at, for no change at all.
|
||||
const current = (editor.getAttributes("codeBlock").language as string | null | undefined) ?? null;
|
||||
if (current === next) return false;
|
||||
|
||||
// Read out of the chain rather than off run(), because focus answers a different question, and
|
||||
// answers it with false whenever there is no view to focus.
|
||||
let changed = false;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ commands }) => {
|
||||
changed = commands.updateAttributes("codeBlock", { language: next });
|
||||
return changed;
|
||||
})
|
||||
.run();
|
||||
return changed;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// The seam the block lanes plug into, and the only file that knows all five of them exist.
|
||||
//
|
||||
// extensions.ts generates one TipTap extension per entry in the frozen schema, which covers what a
|
||||
// node IS. What a node DOES, its ProseMirror plugins, its node views, its keymap and its input
|
||||
// rules, has no place on a mechanically generated extension, and five unrelated blocks sharing one
|
||||
// file would mean five reasons to edit it and five chances to break somebody else's block while
|
||||
// doing so. So each lane is one Extension in one file, listed here once. extensions.ts spreads this
|
||||
// array without knowing what is in it, and nothing else imports the lane files.
|
||||
//
|
||||
// A lane may add plugins, node views, keyboard shortcuts and input rules. It may not add, remove or
|
||||
// alter a node or a mark. The schema is src/model/schema.ts, the markdown bridge is written against
|
||||
// it, and an editor whose schema has drifted from the contract is an editor that cannot hold a
|
||||
// document the bridge just parsed, which is somebody's file lost on the next save.
|
||||
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
import { CodeHighlighting, setCodeLanguage } from "./code";
|
||||
import { MathRendering, insertMath } from "./math";
|
||||
import { MermaidRendering, insertMermaid } from "./mermaid";
|
||||
import { Tables, tableCommand } from "./tables";
|
||||
import { Toggles } from "./toggle";
|
||||
|
||||
/**
|
||||
* In precedence order, lowest first. TipTap reverses the extension list before it collects
|
||||
* ProseMirror plugins, so the last entry here contributes the first plugin the view asks, and for a
|
||||
* node view the first plugin asked is the one that gets the node. Mermaid is last because a mermaid
|
||||
* diagram is a codeBlock: it and the highlighter are looking at the same node type, and the diagram
|
||||
* is the more specific of the two.
|
||||
*
|
||||
* Position is the weaker of the two levers, and it is worth knowing which because the stronger one
|
||||
* is now in use. TipTap sorts the reversed list by each extension's `priority` before it collects
|
||||
* anything, so a higher priority beats any position in this array; every lane here leaves it at the
|
||||
* default and is ordered by position alone. src/editor/paste.ts is the one that does not, and it
|
||||
* says why: its handlers guard the document against every other plugin's, so being ahead of the
|
||||
* table plugins below cannot be left to where two arrays happen to put it.
|
||||
*
|
||||
* Toggles goes first rather than beside the lane it reads most like. The toggle node is claimed by
|
||||
* nobody else, so where it sits changes nothing about which plugin gets that node view, and the
|
||||
* four below it are in an order that was argued over: put anywhere else it would move one of them
|
||||
* and leave the sentence above no longer true of the array under it.
|
||||
*/
|
||||
export const BLOCK_EXTENSIONS: Extensions = [
|
||||
Toggles,
|
||||
Tables,
|
||||
MathRendering,
|
||||
CodeHighlighting,
|
||||
MermaidRendering,
|
||||
];
|
||||
|
||||
/**
|
||||
* What the editor handle's block commands delegate to. Each returns false when it has nothing to
|
||||
* act on where the cursor is, which is what a toolbar button pressed in the wrong place should do,
|
||||
* and each is responsible for its own focus the way every other command in the handle is.
|
||||
*/
|
||||
export { insertMath, insertMermaid, setCodeLanguage, tableCommand };
|
||||
@@ -0,0 +1,343 @@
|
||||
// The math lane's tests, which stop at the edge of the DOM.
|
||||
//
|
||||
// vite.config.ts runs vitest in the node environment, so there is no document for a view to mount
|
||||
// in and the node view in math.ts cannot be built from here. What a formula looks like on screen,
|
||||
// and the field that opens on it when it is selected, belong to the Playwright suite. What belongs
|
||||
// here is the half that touches the document, because that is the half that can cost somebody a
|
||||
// file: the two ways a formula gets made, and the LaTeX already in the document surviving both of
|
||||
// them character for character.
|
||||
//
|
||||
// The last group tests KaTeX rather than this app. It is here because the node view rests on two
|
||||
// promises the library makes and could quietly stop keeping on an upgrade: that it does not throw
|
||||
// under these options, and that what it hands back when it cannot parse something still contains
|
||||
// the source it was given.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { EditorState, NodeSelection } from "@tiptap/pm/state";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
import katex from "katex";
|
||||
import { parseMarkdown, serializeMarkdown } from "../../markdown";
|
||||
import { createEditorExtensions } from "../extensions";
|
||||
import { insertMath } from "./math";
|
||||
|
||||
const extensions = () =>
|
||||
createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} });
|
||||
|
||||
function editorWith(content: JSONContent): Editor {
|
||||
return new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
|
||||
}
|
||||
|
||||
/**
|
||||
* The same editor with the extensions' ProseMirror plugins actually installed, which TipTap only
|
||||
* does when it mounts a view and there is no DOM here to mount into. src/editor/Editor.tsx swaps a
|
||||
* state built this way in for every document it opens, so this is what the app runs minus the
|
||||
* screen. Only the table tests need it, because a cell selection is prosemirror-tables' own.
|
||||
*/
|
||||
function editorWithPlugins(content: JSONContent): Editor {
|
||||
const editor = editorWith(content);
|
||||
editor.view.updateState(
|
||||
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
/** A file, opened, with the bytes it would be written back as. */
|
||||
function open(source: string) {
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const editor = editorWithPlugins(parsed.doc.toJSON());
|
||||
return { editor, written: () => serializeMarkdown(parsed, editor.state.doc) };
|
||||
}
|
||||
|
||||
/** The document position just inside a cell, for a table that is the document's first block. */
|
||||
function inCell(editor: Editor, row: number, column: number): number {
|
||||
const table = editor.state.doc.firstChild!;
|
||||
return 1 + TableMap.get(table).positionAt(row, column, table) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter, through the keymap the extension really installs rather than through a function this file
|
||||
* reached into. A headless editor has no view to take a key event, so the plugins' own handlers are
|
||||
* called in the order ProseMirror would call them, with the proxy view TipTap answers with and the
|
||||
* two things prosemirror-keymap reads off an event: the key name, and the four modifier flags.
|
||||
*/
|
||||
function pressEnter(editor: Editor): void {
|
||||
const event = {
|
||||
key: "Enter",
|
||||
keyCode: 13,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
for (const plugin of editor.extensionManager.plugins) {
|
||||
// Called through the plugin, which is the "this" ProseMirror types the prop as wanting.
|
||||
if (plugin.props.handleKeyDown?.call(plugin, editor.view, event)) return;
|
||||
}
|
||||
}
|
||||
|
||||
const CODE = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: "ts", meta: null },
|
||||
content: [{ type: "text", text: "const x = 1;" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RAW = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "raw",
|
||||
attrs: { source: "<figure><img src='x.png'></figure>" },
|
||||
content: [{ type: "text", text: "<figure><img src='x.png'></figure>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("insertMath", () => {
|
||||
it("puts an empty display equation in and selects it", () => {
|
||||
const editor = editorWith({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
|
||||
expect(insertMath(editor, true)).toBe(true);
|
||||
const block = editor.state.doc.firstChild;
|
||||
expect(block?.type.name).toBe("mathBlock");
|
||||
// Not empty. This assertion used to read "" and that was the bug: an empty formula is a box on
|
||||
// screen the file has no way to spell, and inline it went out as $$$$ and came back as text, so
|
||||
// the first autosave took it away without telling anybody. A new formula is made with the
|
||||
// placeholder in it, which is a formula that survives being written.
|
||||
expect(block?.attrs.latex).toBe("\\square");
|
||||
// Selected is what opens the field on it, so it is the half of the command that matters.
|
||||
expect(editor.state.selection instanceof NodeSelection).toBe(true);
|
||||
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The caret mid paragraph is the case the first version of this got wrong. A display formula
|
||||
// dropped there splits the paragraph and lands between the halves, so the position the insert was
|
||||
// asked for is not the position the formula ends up at, and selecting the wrong one means a
|
||||
// formula on screen with no way into its field. The end of a paragraph is the one place the two
|
||||
// answers agree, which is why the test above did not catch it.
|
||||
it("selects the display equation even when the caret was in the middle of a paragraph", () => {
|
||||
const editor = editorWith({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "before after" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(8);
|
||||
|
||||
expect(insertMath(editor, true)).toBe(true);
|
||||
expect(editor.state.doc.child(0).textContent).toBe("before ");
|
||||
expect(editor.state.doc.child(1).type.name).toBe("mathBlock");
|
||||
expect(editor.state.doc.child(2).textContent).toBe("after");
|
||||
expect(editor.state.selection instanceof NodeSelection).toBe(true);
|
||||
expect((editor.state.selection as NodeSelection).node.type.name).toBe("mathBlock");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("puts an inline formula in without disturbing the text around it", () => {
|
||||
const editor = editorWith({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(2);
|
||||
|
||||
expect(insertMath(editor, false)).toBe(true);
|
||||
const paragraph = editor.state.doc.firstChild;
|
||||
expect(paragraph?.type.name).toBe("paragraph");
|
||||
expect([...Array(paragraph?.childCount ?? 0)].map((_, i) => paragraph?.child(i).type.name)).toEqual([
|
||||
"text",
|
||||
"mathInline",
|
||||
"text",
|
||||
]);
|
||||
expect(paragraph?.textContent).toBe("ab");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("refuses inside a code block, and leaves the fence exactly as it was", () => {
|
||||
const editor = editorWith(CODE);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
expect(insertMath(editor, false)).toBe(false);
|
||||
expect(insertMath(editor, true)).toBe(false);
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("refuses inside a raw block, which is somebody's bytes and not a place for a formula", () => {
|
||||
const editor = editorWith(RAW);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
expect(insertMath(editor, false)).toBe(false);
|
||||
expect(insertMath(editor, true)).toBe(false);
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The three below are one bug each, reproduced from the bytes they cost. All three were live in a
|
||||
// build whose table, rule and diagram inserts were already guarded: this command had a private
|
||||
// copy of the guard that had never been given the isolating rule, and a private guard is a guard
|
||||
// that is only as good as the last person who remembered it existed. It is gone, and this command
|
||||
// now asks the one in src/editor/fits.ts that every other insert asks.
|
||||
|
||||
it("refuses with the caret in a table cell, and leaves the file byte identical", () => {
|
||||
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n";
|
||||
const { editor, written } = open(source);
|
||||
editor.commands.setTextSelection(inCell(editor, 1, 0));
|
||||
|
||||
// What this used to do: split the table around the formula, leave the body row empty and the
|
||||
// moved cells in a second table with no header, and hand the autosave
|
||||
// "| h1 | h2 |\n| - | - |\n| | |\n\n$$\n$$\n\n| a | b |\n| - | - |\n" half a second later.
|
||||
expect(insertMath(editor, true)).toBe(false);
|
||||
expect(written()).toBe(source);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("refuses over a dragged cell selection, and every cell keeps its text", () => {
|
||||
const source = "| h1 | h2 |\n| - | - |\n| a | b |\n| c | d |\n";
|
||||
const { editor, written } = open(source);
|
||||
const map = TableMap.get(editor.state.doc.firstChild!);
|
||||
const table = editor.state.doc.firstChild!;
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(
|
||||
CellSelection.create(editor.state.doc, 1 + map.positionAt(0, 0, table), 1 + map.positionAt(2, 1, table)),
|
||||
),
|
||||
);
|
||||
|
||||
// An inline formula fits in a cell perfectly well, which is why the position rule alone let
|
||||
// this through: over a rectangle of cells the insert does not go in a cell, it replaces the
|
||||
// content of all six of them at once. Six cells of somebody's text for one empty formula.
|
||||
expect(insertMath(editor, false)).toBe(false);
|
||||
expect(insertMath(editor, true)).toBe(false);
|
||||
expect(written()).toBe(source);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("makes a formula the save can keep, which an empty one is not", () => {
|
||||
const { editor, written } = open("hello\n");
|
||||
editor.commands.setTextSelection(6);
|
||||
|
||||
expect(insertMath(editor, false)).toBe(true);
|
||||
const file = written();
|
||||
expect(file).toBe("hello$$\\square$$\n");
|
||||
|
||||
// The whole point of the placeholder, asserted the only way that means anything: the file goes
|
||||
// back through the parser and there is still a formula in it. An empty one wrote "hello$$$$",
|
||||
// which comes back as four dollar signs of literal text, and the box the user was looking at
|
||||
// was gone with nobody told.
|
||||
const reopened = parseMarkdown(file, "/notes/a.md");
|
||||
const found: string[] = [];
|
||||
reopened.doc.descendants((node) => {
|
||||
if (node.type.name === "mathInline") found.push(node.attrs.latex as string);
|
||||
});
|
||||
expect(found).toEqual(["\\square"]);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the fence rule", () => {
|
||||
it("turns a paragraph holding just $$ into a math block, selected", () => {
|
||||
const editor = editorWith({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "$$" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
|
||||
pressEnter(editor);
|
||||
|
||||
expect(editor.state.doc.childCount).toBe(1);
|
||||
expect(editor.state.doc.firstChild?.type.name).toBe("mathBlock");
|
||||
// The placeholder here too, for the reason on the insertMath test above: a formula made by
|
||||
// typing a fence has the same claim to still being there after a save as one made by a button.
|
||||
expect(editor.state.doc.firstChild?.attrs.latex).toBe("\\square");
|
||||
expect(editor.state.selection instanceof NodeSelection).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a paragraph that says anything else alone", () => {
|
||||
for (const text of ["$$x", "a $$", "$", "$5 and $10"]) {
|
||||
const editor = editorWith({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(text.length + 1);
|
||||
|
||||
pressEnter(editor);
|
||||
|
||||
// Enter still splits the paragraph, which is the base keymap's business and not this lane's.
|
||||
// What is asserted is only that no formula was made out of somebody's prose.
|
||||
let found = false;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "mathBlock" || node.type.name === "mathInline") found = true;
|
||||
});
|
||||
expect([text, found]).toEqual([text, false]);
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the LaTeX already in the document", () => {
|
||||
const source = [
|
||||
"Before.",
|
||||
"",
|
||||
"$$",
|
||||
"\\frac{a}{b} = \\sum_{i=0}^{n} x_i",
|
||||
"$$",
|
||||
"",
|
||||
"After $$x^2$$ here.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
it("comes back byte for byte after a formula is inserted somewhere else", () => {
|
||||
const parsed = parseMarkdown(source, "/notes/a.md");
|
||||
const editor = editorWith(parsed.doc.toJSON());
|
||||
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
|
||||
|
||||
// Into the first paragraph, which is the one place in the file this is allowed to change.
|
||||
editor.commands.setTextSelection(4);
|
||||
expect(insertMath(editor, false)).toBe(true);
|
||||
|
||||
const written = serializeMarkdown(parsed, editor.state.doc);
|
||||
expect(written).toContain("$$\n\\frac{a}{b} = \\sum_{i=0}^{n} x_i\n$$");
|
||||
expect(written).toContain("After $$x^2$$ here.");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KaTeX under the options this lane renders with", () => {
|
||||
// The same object math.ts builds its render call from. Repeated rather than exported, because
|
||||
// what is being pinned here is the library's behaviour under them and not their spelling.
|
||||
const options = {
|
||||
throwOnError: false,
|
||||
strict: false,
|
||||
trust: false,
|
||||
errorColor: "var(--danger)",
|
||||
} as const;
|
||||
|
||||
it("draws a formula, with the source it was given still in the markup", () => {
|
||||
const markup = katex.renderToString("\\frac{a}{b}", options);
|
||||
expect(markup).toContain("katex");
|
||||
expect(markup).toContain("\\frac{a}{b}");
|
||||
});
|
||||
|
||||
it("does not throw on LaTeX it cannot parse, and shows the source in the error colour", () => {
|
||||
// Two shapes, both of them the source and the colour. LaTeX KaTeX cannot get through the
|
||||
// parser at all comes back as one .katex-error span holding the whole expression; a command it
|
||||
// parses and has never heard of is drawn as the text of the command, in the same colour.
|
||||
for (const broken of ["\\frac{", "\\notacommand", "^", "\\begin{matrix}", "\\sqrt{}}{"]) {
|
||||
const markup = katex.renderToString(broken, options);
|
||||
expect([broken, markup.includes(broken)]).toEqual([broken, true]);
|
||||
expect([broken, markup.includes("var(--danger)")]).toEqual([broken, true]);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the error colour through as the custom property it was handed", () => {
|
||||
// KaTeX puts the colour in an attribute on the element it draws, where a stylesheet cannot
|
||||
// reach it, so the token has to survive the trip out through the markup exactly as written.
|
||||
expect(katex.renderToString("\\frac{", options)).toContain("var(--danger)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
// Math: KaTeX over the mathInline and mathBlock nodes the bridge produces.
|
||||
//
|
||||
// Both are atoms carrying their LaTeX as an attribute, so rendering one is a node view drawing an
|
||||
// attribute and editing one is that same node view handing the source back. Nothing here parses,
|
||||
// normalises or rewrites the LaTeX: what round trips to disk is the attribute exactly as it was
|
||||
// read, and KaTeX only ever gets a copy of it.
|
||||
//
|
||||
// LaTeX KaTeX cannot render is shown as the source with the error beside it, never as an empty
|
||||
// box and never dropped. A formula this editor fails to draw is still the user's formula, and it
|
||||
// has to survive being opened and saved by an editor that could not display it.
|
||||
//
|
||||
// An atom has no editable text of its own, so the field the source is typed into is this file's to
|
||||
// draw and this file's to write back. It appears while the node is selected, which is what both a
|
||||
// click on a formula and an arrow key into one produce, and every keystroke in it is a transaction
|
||||
// like any other. The document is therefore never holding a formula the field has already moved
|
||||
// past: an autosave that lands mid edit writes what is on screen, and closing the file does not
|
||||
// take the last few characters with it.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode, NodeType } from "@tiptap/pm/model";
|
||||
import { NodeSelection, Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
||||
import type { Command, Transaction } from "@tiptap/pm/state";
|
||||
import type { EditorView, NodeView } from "@tiptap/pm/view";
|
||||
import katex from "katex";
|
||||
import { place } from "../fits";
|
||||
|
||||
// KaTeX's stylesheet, and the twenty faces it names, are pulled into the bundle from here rather
|
||||
// than from main.tsx alongside the app's own sheets, because this is the file that cannot work
|
||||
// without them. The app runs under a CSP of font-src 'self', so a font fetched from KaTeX's CDN
|
||||
// never arrives and every formula is drawn in a fallback face at metrics the layout was not
|
||||
// measured for. Importing the sheet is what makes Vite emit the woff2 files as local assets and
|
||||
// rewrite the URLs on to them, so this import is load bearing and is not a stray dependency.
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
/** A paragraph holding exactly this becomes a math block when Enter is pressed in it. */
|
||||
const FENCE = "$$";
|
||||
|
||||
/**
|
||||
* What a new formula is made with, since a new formula is never made empty.
|
||||
*
|
||||
* An empty formula is a box on screen that the file has no way to spell. Inline, it goes out as
|
||||
* `$$$$`, which is not math to anything that reads it back, so the box the user is looking at is
|
||||
* gone the next time the document is opened and they were never told. That is the failure this
|
||||
* editor exists not to have: something on screen that the save quietly does not keep.
|
||||
*
|
||||
* Three ways out of it were on the table. Refusing to insert until there is content cannot work,
|
||||
* because the insert is how the content gets typed. Keeping the node out of the saved document
|
||||
* until it has LaTeX is the same disappearance one layer down, since an autosave then writes a file
|
||||
* without a formula the user can see. So the node is created with content: `\square` is the glyph
|
||||
* mathematics already uses for the term that has not been written yet, KaTeX draws it, and it round
|
||||
* trips as `$$\square$$` like any other formula. Nothing vanishes, because there is nothing empty.
|
||||
*
|
||||
* The field opens with it selected, so typing over it is the same keystroke it would have been in
|
||||
* an empty box, and the user who walks away is left with a formula they can see rather than one
|
||||
* they cannot.
|
||||
*/
|
||||
const PLACEHOLDER = "\\square";
|
||||
|
||||
/** Past these the field scrolls rather than growing. A formula this long is not being read. */
|
||||
const MAX_ROWS = 16;
|
||||
const MAX_COLS = 64;
|
||||
const MIN_COLS = 4;
|
||||
|
||||
/**
|
||||
* KaTeX is never allowed to throw, and never allowed to be the reason a formula is not on screen.
|
||||
*
|
||||
* `throwOnError` false is what turns a parse failure into markup: KaTeX draws the source it could
|
||||
* not read in the error colour with the reason on the element's title, which is the whole of the
|
||||
* error state for LaTeX it understands well enough to refuse. `strict` false is the same bargain
|
||||
* one level down, for the LaTeX it can read and would rather complain about, a unicode letter in
|
||||
* math mode being the usual one; the alternative is a console full of warnings about somebody's
|
||||
* own file. `trust` stays off because the markup goes into the page with innerHTML, and it is what
|
||||
* decides whether \href in a document that arrived from somewhere else becomes a link.
|
||||
*
|
||||
* The error colour is a custom property rather than a hex value because KaTeX writes it into a
|
||||
* style attribute on the element it draws, and an inline style is not something a stylesheet can
|
||||
* take back.
|
||||
*/
|
||||
const KATEX_OPTIONS = {
|
||||
throwOnError: false,
|
||||
strict: false,
|
||||
trust: false,
|
||||
errorColor: "var(--danger)",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Where a node of this type ended up, looked for in the ranges the steps from `since` on wrote.
|
||||
*
|
||||
* Asked this way rather than by mapping the insertion point forward, which is the obvious move and
|
||||
* is wrong. A block formula dropped into the middle of a paragraph splits it, and the position it
|
||||
* was asked for stays with the first half, several places short of the formula. Mapping it forward
|
||||
* then finds no formula there and nothing gets selected, which is a formula on screen with no way
|
||||
* into its field. The end of a paragraph is the one place the two answers agree, which is why
|
||||
* every test that put the caret there passed.
|
||||
*/
|
||||
function placedAt(tr: Transaction, since: number, type: NodeType): number | null {
|
||||
let found: number | null = null;
|
||||
|
||||
for (let step = since; step < tr.steps.length && found === null; step += 1) {
|
||||
const forward = tr.mapping.slice(step + 1);
|
||||
tr.mapping.maps[step].forEach((_from, _to, newFrom, newTo) => {
|
||||
if (found !== null) return;
|
||||
const size = tr.doc.content.size;
|
||||
const from = Math.min(size, Math.max(0, forward.map(newFrom, -1)));
|
||||
const to = Math.min(size, Math.max(from, forward.map(newTo, 1)));
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (found === null && node.type === type) found = pos;
|
||||
return found === null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder formula where the cursor is, selected so that its field opens on it.
|
||||
*
|
||||
* Whether it can go there at all is `place`'s question and is asked before this runs, which is why
|
||||
* there is no check of its own here. There used to be one, a private copy of the walk in fits.ts
|
||||
* that had never been given the isolating rule, and it answered yes with the caret in a table cell:
|
||||
* the insert then split the table around the formula and emptied the row it had been in, and the
|
||||
* autosave wrote that to the user's file half a second later with no keystroke behind it.
|
||||
*/
|
||||
function placeMath(type: NodeType): Command {
|
||||
return (state, dispatch) => {
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
const before = tr.steps.length;
|
||||
// Marks carry on to an inline formula, since **$x$** is a thing the file can say and the
|
||||
// bridge already reads and writes. A block one is in a part of the document where no mark
|
||||
// can go, and handing it the marks under the cursor would make it unplaceable.
|
||||
tr.replaceSelectionWith(type.create({ latex: PLACEHOLDER }), type.isInline);
|
||||
// Selecting it is what opens its field, on the placeholder, which the field selects whole so
|
||||
// the first thing typed replaces it.
|
||||
const placed = placedAt(tr, before, type);
|
||||
if (placed !== null) tr.setSelection(NodeSelection.create(tr.doc, placed));
|
||||
dispatch(tr.scrollIntoView());
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `$$` alone in a paragraph, then Enter.
|
||||
*
|
||||
* Not an input rule, though it reads like one: an input rule fires on text input and Enter is not
|
||||
* text, so there would be nothing to run it. There is deliberately no rule for `$…$` either. A
|
||||
* dollar sign is money or a shell prompt far more often than it is mathematics, and turning
|
||||
* "$5 and $10" into an equation as somebody types is exactly the unasked for rewrite this editor
|
||||
* does not do. The bridge takes the same line one layer down, where single dollar math is off in
|
||||
* the parser.
|
||||
*/
|
||||
const openMathBlock: Command = (state, dispatch) => {
|
||||
const { $from, empty } = state.selection;
|
||||
if (!empty) return false;
|
||||
if ($from.parent.type.name !== "paragraph" || $from.parent.textContent !== FENCE) return false;
|
||||
|
||||
const type = state.schema.nodes.mathBlock;
|
||||
const depth = $from.depth;
|
||||
const index = $from.index(depth - 1);
|
||||
if (!type || !$from.node(depth - 1).canReplaceWith(index, index + 1, type)) return false;
|
||||
|
||||
if (dispatch) {
|
||||
const from = $from.before(depth);
|
||||
// The placeholder, for the reason written on it: this is the other way a formula is made, and
|
||||
// a formula made by typing a fence has the same claim to still being there after a save as one
|
||||
// made from the toolbar.
|
||||
const tr = state.tr.replaceWith(from, $from.after(depth), type.create({ latex: PLACEHOLDER }));
|
||||
tr.setSelection(NodeSelection.create(tr.doc, from));
|
||||
dispatch(tr.scrollIntoView());
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* One formula: what KaTeX drew, and the field the LaTeX behind it is typed into.
|
||||
*
|
||||
* The two are siblings inside the element the node's own toDOM describes, and which of them is on
|
||||
* screen is a data attribute the stylesheet reads. Neither is content in ProseMirror's sense: the
|
||||
* node is an atom, there is no contentDOM, and every mutation inside here is declared to be this
|
||||
* file's own so that nothing KaTeX draws can be read back into the document.
|
||||
*/
|
||||
class MathView implements NodeView {
|
||||
readonly dom: HTMLElement;
|
||||
|
||||
private readonly view: EditorView;
|
||||
private readonly getPos: () => number | undefined;
|
||||
private readonly display: boolean;
|
||||
private readonly render: HTMLElement;
|
||||
private readonly field: HTMLTextAreaElement;
|
||||
private node: ProseMirrorNode;
|
||||
private editing = false;
|
||||
|
||||
constructor(
|
||||
node: ProseMirrorNode,
|
||||
view: EditorView,
|
||||
getPos: () => number | undefined,
|
||||
display: boolean,
|
||||
) {
|
||||
this.node = node;
|
||||
this.view = view;
|
||||
this.getPos = getPos;
|
||||
this.display = display;
|
||||
|
||||
const owner = view.dom.ownerDocument;
|
||||
this.dom = owner.createElement(display ? "div" : "span");
|
||||
this.dom.className = display ? "math-block" : "math-inline";
|
||||
if (display) this.dom.setAttribute("data-math-block", "");
|
||||
|
||||
this.render = owner.createElement(display ? "div" : "span");
|
||||
this.render.className = "math-render";
|
||||
this.dom.appendChild(this.render);
|
||||
|
||||
this.field = owner.createElement("textarea");
|
||||
this.field.className = "math-source";
|
||||
this.field.spellcheck = false;
|
||||
this.field.setAttribute("aria-label", display ? "Display equation source" : "Inline math source");
|
||||
this.field.addEventListener("input", this.onInput);
|
||||
this.field.addEventListener("keydown", this.onKeyDown);
|
||||
this.dom.appendChild(this.field);
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private get latex(): string {
|
||||
const value = this.node.attrs.latex;
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode): boolean {
|
||||
// A node of another type is another node view; ProseMirror builds a fresh one rather than
|
||||
// asking this one to become something it was not written to be.
|
||||
if (node.type !== this.node.type) return false;
|
||||
this.node = node;
|
||||
this.draw();
|
||||
return true;
|
||||
}
|
||||
|
||||
selectNode(): void {
|
||||
// A document being looked at rather than edited gets no field, so it gets the outline
|
||||
// ProseMirror would have drawn on its own: it says the formula is selected without offering to
|
||||
// change it. Node views that define this one are asked instead of that outline, not as well.
|
||||
if (!this.view.editable) {
|
||||
this.dom.classList.add("ProseMirror-selectednode");
|
||||
return;
|
||||
}
|
||||
if (this.editing) return;
|
||||
this.editing = true;
|
||||
this.draw();
|
||||
this.take();
|
||||
}
|
||||
|
||||
deselectNode(): void {
|
||||
this.dom.classList.remove("ProseMirror-selectednode");
|
||||
if (!this.editing) return;
|
||||
this.editing = false;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that lands inside the field is the field's own. ProseMirror handling the mousedown
|
||||
* that opens it would put a node selection where the caret was going, and the field would never
|
||||
* take focus at all.
|
||||
*/
|
||||
stopEvent(event: Event): boolean {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLElement && this.field.contains(target);
|
||||
}
|
||||
|
||||
/** The element is this file's from end to end, so nothing read off it is news to the document. */
|
||||
ignoreMutation(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Also what cancels the frame `take` queued: the element is on its way out, and focusing it
|
||||
// then would put the caret at a position the document no longer has.
|
||||
this.editing = false;
|
||||
this.field.removeEventListener("input", this.onInput);
|
||||
this.field.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
/** Everything on the element that depends on the node or on whether it is being edited. */
|
||||
private draw(): void {
|
||||
const latex = this.latex;
|
||||
// Mirrored on to the element the way the node's own toDOM writes it, so that anything reading
|
||||
// the page back, a copy, a drag, a mutation ProseMirror decides to re-parse after all, takes
|
||||
// the source out of the attribute the parse rule names rather than out of what KaTeX drew.
|
||||
this.dom.setAttribute("data-latex", latex);
|
||||
// The empty string and nothing else, because this flag is what tells the user the formula has
|
||||
// no spelling and will not be saved, and a formula of one space is saved: the writer drops an
|
||||
// equation only when its latex is empty. `paint` below asks a different question, which is
|
||||
// whether KaTeX has anything to draw, and whitespace is a fair no to that one.
|
||||
this.flag("data-math-empty", latex === "");
|
||||
this.flag("data-editing", this.editing);
|
||||
// The field is the source of truth while it is being typed in. Writing to it here would take
|
||||
// the caret to the end of a formula the user is in the middle of.
|
||||
if (!this.editing) {
|
||||
this.field.value = latex;
|
||||
this.size();
|
||||
}
|
||||
this.paint(latex);
|
||||
}
|
||||
|
||||
private flag(name: string, on: boolean): void {
|
||||
if (on) this.dom.setAttribute(name, "");
|
||||
else this.dom.removeAttribute(name);
|
||||
}
|
||||
|
||||
private paint(latex: string): void {
|
||||
if (latex.trim() === "") {
|
||||
this.render.textContent = "";
|
||||
this.render.removeAttribute("data-math-error");
|
||||
this.render.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.render.innerHTML = katex.renderToString(latex, {
|
||||
...KATEX_OPTIONS,
|
||||
displayMode: this.display,
|
||||
});
|
||||
this.render.removeAttribute("data-math-error");
|
||||
this.render.removeAttribute("title");
|
||||
} catch (error) {
|
||||
// throwOnError covers the LaTeX KaTeX parses and then refuses to typeset. This is the rest of
|
||||
// it: input it never expected, on which it throws something that is not a parse error. What
|
||||
// goes on the page is the source as it stands, because that is what the file holds and what
|
||||
// there is to fix.
|
||||
this.render.textContent = latex;
|
||||
this.render.setAttribute("data-math-error", "");
|
||||
this.render.title = String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sized by the textarea's own rows and cols, so nothing here measures anything or sets a style. */
|
||||
private size(): void {
|
||||
const lines = this.field.value.split("\n");
|
||||
const widest = lines.reduce((most, line) => Math.max(most, line.length), 0);
|
||||
this.field.rows = Math.min(MAX_ROWS, lines.length);
|
||||
this.field.cols = Math.min(MAX_COLS, Math.max(MIN_COLS, widest + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus, on the next frame rather than now.
|
||||
*
|
||||
* ProseMirror is part way through drawing the selection this call came from and finishes it by
|
||||
* putting the document's own selection around the node, and TipTap's focus command may have a
|
||||
* frame of its own already queued in front of that. Either would take the caret straight back
|
||||
* out of the field.
|
||||
*/
|
||||
private take(): void {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.editing) return;
|
||||
// Already in it, which is what a keystroke that rewrote the node looks like from here. Moving
|
||||
// the caret then would jump it to the end of a formula being edited in the middle.
|
||||
if (this.field.ownerDocument.activeElement === this.field) return;
|
||||
this.field.focus({ preventScroll: true });
|
||||
const end = this.field.value.length;
|
||||
// A formula that is still nothing but the placeholder is one nobody has typed into yet, so
|
||||
// the placeholder is selected and the first keystroke replaces it. Anything else gets the
|
||||
// caret at the end, because it is somebody's formula and a keystroke must not wipe it.
|
||||
const start = this.field.value === PLACEHOLDER ? 0 : end;
|
||||
this.field.setSelectionRange(start, end);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly onInput = (): void => {
|
||||
this.size();
|
||||
this.commit(this.field.value);
|
||||
};
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
// A display equation is written over several lines often enough that Enter has to be a newline
|
||||
// inside one, so it is inline math that Enter leaves and a block that needs the modifier.
|
||||
const leaving =
|
||||
event.key === "Escape" ||
|
||||
(event.key === "Enter" && (!this.display || event.metaKey || event.ctrlKey));
|
||||
if (leaving) {
|
||||
event.preventDefault();
|
||||
this.leave();
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && this.field.value === "") {
|
||||
event.preventDefault();
|
||||
this.discard();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The field's text on to the node, as a transaction like any other keystroke in the document.
|
||||
*
|
||||
* Not held back until the field is left. A formula the field is holding and the document is not
|
||||
* is one an autosave writes the previous version of and a switch to another file loses outright,
|
||||
* and neither is worth the tidier undo history that batching it would buy.
|
||||
*/
|
||||
private commit(latex: string): void {
|
||||
const pos = this.getPos();
|
||||
if (pos === undefined) return;
|
||||
const { state } = this.view;
|
||||
const node = state.doc.nodeAt(pos);
|
||||
if (!node || node.type !== this.node.type || node.attrs.latex === latex) return;
|
||||
// Null for the type and nothing for the marks, so an inline formula inside a bold run comes
|
||||
// back out of this still bold. setNodeMarkup keeps the marks it was not given new ones for.
|
||||
this.view.dispatch(state.tr.setNodeMarkup(pos, null, { ...node.attrs, latex }));
|
||||
}
|
||||
|
||||
/** Puts the caret back in the document just past the node, which is what re-renders it. */
|
||||
private leave(): void {
|
||||
const pos = this.getPos();
|
||||
const { state } = this.view;
|
||||
if (pos !== undefined) {
|
||||
const after = Math.min(pos + this.node.nodeSize, state.doc.content.size);
|
||||
this.view.dispatch(state.tr.setSelection(Selection.near(state.doc.resolve(after), 1)));
|
||||
}
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
/** Backspace in an empty field takes the formula with it, the field being all there is of it. */
|
||||
private discard(): void {
|
||||
const pos = this.getPos();
|
||||
const { state } = this.view;
|
||||
if (pos === undefined) return;
|
||||
if (!(state.selection instanceof NodeSelection) || state.selection.from !== pos) return;
|
||||
this.view.dispatch(state.tr.deleteSelection().scrollIntoView());
|
||||
this.view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export const MathRendering = Extension.create({
|
||||
name: "mathRendering",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("mathViews"),
|
||||
props: {
|
||||
nodeViews: {
|
||||
mathInline: (node, view, getPos) => new MathView(node, view, getPos, false),
|
||||
mathBlock: (node, view, getPos) => new MathView(node, view, getPos, true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const editor = this.editor;
|
||||
|
||||
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
|
||||
// its transaction whatever the command answered. Enter is pressed everywhere in the document
|
||||
// and a key that did nothing here has to leave nothing at all behind it.
|
||||
return {
|
||||
Enter: () => openMathBlock(editor.state, editor.view.dispatch),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* `display` picks mathBlock over mathInline. False where neither can be placed, which is a toolbar
|
||||
* button pressed somewhere a formula cannot go and means nothing happens.
|
||||
*
|
||||
* The guard is `place`'s and is the same one every other insert in the editor asks, deliberately:
|
||||
* this command had a private one and it was the private one that was missing a rule.
|
||||
*/
|
||||
export function insertMath(editor: Editor, display: boolean): boolean {
|
||||
const type = editor.schema.nodes[display ? "mathBlock" : "mathInline"];
|
||||
if (!type) return false;
|
||||
return place(editor, type, (chain) =>
|
||||
chain.command(({ state, dispatch }) => placeMath(type)(state, dispatch)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// What can be asserted about a mermaid block without a browser, which is most of what matters.
|
||||
//
|
||||
// The node view itself needs a DOM and a real ProseMirror view, and this suite runs in node, so the
|
||||
// drawing is not what is tested here. What is tested is everything the drawing is not allowed to
|
||||
// disturb: that the extension adds nothing to the schema, that a ```mermaid fence is still an
|
||||
// ordinary code block that round trips byte for byte through the editor, that exactly one plugin in
|
||||
// the whole build claims the code block node view, and that the decoration telling a block the caret
|
||||
// is inside it lands on the right blocks and only those.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
import type { Plugin } from "@tiptap/pm/state";
|
||||
import type { DecorationSet } from "@tiptap/pm/view";
|
||||
import { createEditorExtensions } from "../extensions";
|
||||
import { parseMarkdown, serializeMarkdown } from "../../markdown";
|
||||
import { MermaidRendering, insertMermaid } from "./mermaid";
|
||||
|
||||
const PATH = "/notes/diagrams.md";
|
||||
const FENCE = "```";
|
||||
|
||||
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
|
||||
|
||||
function makeEditor(content?: object): Editor {
|
||||
return new Editor({
|
||||
element: null,
|
||||
injectCSS: false,
|
||||
extensions: extensions(),
|
||||
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
}
|
||||
|
||||
/** An editor holding what the bridge made of `source`, which is how a document really arrives. */
|
||||
function editorFor(source: string): Editor {
|
||||
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
|
||||
}
|
||||
|
||||
function blockAt(editor: Editor, index: number): { node: ProseMirrorNode; pos: number } {
|
||||
const doc = editor.state.doc;
|
||||
let pos = 0;
|
||||
for (let i = 0; i < index; i += 1) pos += doc.child(i).nodeSize;
|
||||
return { node: doc.child(index), pos };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one plugin that draws diagrams, found the way the view finds it: by what it offers.
|
||||
*
|
||||
* The plugins are asked of the extensions rather than of the state, because TipTap only installs
|
||||
* them when it mounts a view and there is no DOM here to mount one in.
|
||||
*/
|
||||
function nodeViewPlugins(editor: Editor): Plugin[] {
|
||||
return editor.extensionManager.plugins.filter(
|
||||
(plugin) => plugin.props.nodeViews?.codeBlock !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function cursorDecorations(editor: Editor): DecorationSet | null {
|
||||
const [plugin] = nodeViewPlugins(editor);
|
||||
// `this` matters: ProseMirror calls a props function with the plugin as its receiver.
|
||||
const found = plugin.props.decorations?.call(plugin, editor.state);
|
||||
return (found as DecorationSet | null | undefined) ?? null;
|
||||
}
|
||||
|
||||
function decoratedRanges(editor: Editor): Array<[number, number]> {
|
||||
const set = cursorDecorations(editor);
|
||||
if (!set) return [];
|
||||
return set.find().map((decoration) => [decoration.from, decoration.to]);
|
||||
}
|
||||
|
||||
const DIAGRAM = [
|
||||
"# Diagrams",
|
||||
"",
|
||||
`${FENCE}mermaid`,
|
||||
"graph TD;",
|
||||
" A-->B;",
|
||||
FENCE,
|
||||
"",
|
||||
`${FENCE}ts`,
|
||||
"const x = 1;",
|
||||
FENCE,
|
||||
"",
|
||||
"After.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("the mermaid extension", () => {
|
||||
it("is the one the registry names", () => {
|
||||
expect(MermaidRendering.name).toBe("mermaidRendering");
|
||||
});
|
||||
|
||||
it("adds no node and no mark, so the bridge and the editor still agree", () => {
|
||||
const plain = new Editor({
|
||||
element: null,
|
||||
injectCSS: false,
|
||||
extensions: extensions().filter((extension) => extension.name !== "mermaidRendering"),
|
||||
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
const withMermaid = makeEditor();
|
||||
|
||||
expect(Object.keys(withMermaid.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
|
||||
expect(Object.keys(withMermaid.schema.marks)).toEqual(Object.keys(plain.schema.marks));
|
||||
|
||||
plain.destroy();
|
||||
withMermaid.destroy();
|
||||
});
|
||||
|
||||
it("is the only plugin in the build that claims the code block node view", () => {
|
||||
const editor = makeEditor();
|
||||
// Two plugins offering a node view for one node is a silent bug: ProseMirror takes the first
|
||||
// one asked and the other never runs. The code lane leaves this to mermaid on purpose.
|
||||
expect(nodeViewPlugins(editor)).toHaveLength(1);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a ```mermaid fence in a document", () => {
|
||||
it("is an ordinary code block carrying its own language", () => {
|
||||
const editor = editorFor(DIAGRAM);
|
||||
const { node } = blockAt(editor, 1);
|
||||
|
||||
expect(node.type.name).toBe("codeBlock");
|
||||
expect(node.attrs.language).toBe("mermaid");
|
||||
expect(node.textContent).toBe("graph TD;\n A-->B;");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("round trips byte for byte through the editor", () => {
|
||||
const parsed = parseMarkdown(DIAGRAM, PATH);
|
||||
const editor = makeEditor(parsed.doc.toJSON());
|
||||
|
||||
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(DIAGRAM);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps its indentation, its blank lines and its meta on the way back", () => {
|
||||
const source = [
|
||||
`${FENCE}mermaid theme=forest`,
|
||||
"sequenceDiagram",
|
||||
" Alice->>John: Hello",
|
||||
"",
|
||||
" John-->>Alice: Hi",
|
||||
FENCE,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseMarkdown(source, PATH);
|
||||
const editor = makeEditor(parsed.doc.toJSON());
|
||||
const { node } = blockAt(editor, 0);
|
||||
|
||||
expect(node.attrs.meta).toBe("theme=forest");
|
||||
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(source);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the decoration that says the caret is inside", () => {
|
||||
it("is absent while the cursor is somewhere else", () => {
|
||||
const editor = editorFor(DIAGRAM);
|
||||
editor.commands.setTextSelection(1);
|
||||
|
||||
expect(decoratedRanges(editor)).toEqual([]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("covers the block the cursor is in, and nothing else", () => {
|
||||
const editor = editorFor(DIAGRAM);
|
||||
const { node, pos } = blockAt(editor, 1);
|
||||
editor.commands.setTextSelection(pos + 1);
|
||||
|
||||
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("ignores a code block that is not a diagram", () => {
|
||||
const editor = editorFor(DIAGRAM);
|
||||
const { pos } = blockAt(editor, 2);
|
||||
editor.commands.setTextSelection(pos + 1);
|
||||
|
||||
expect(decoratedRanges(editor)).toEqual([]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does not fire on the paragraph that follows the fence", () => {
|
||||
const editor = editorFor(DIAGRAM);
|
||||
const { pos } = blockAt(editor, 3);
|
||||
editor.commands.setTextSelection(pos + 1);
|
||||
|
||||
expect(decoratedRanges(editor)).toEqual([]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("covers the block when it is selected whole rather than typed in", () => {
|
||||
const editor = editorFor(DIAGRAM);
|
||||
const { node, pos } = blockAt(editor, 1);
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, pos)),
|
||||
);
|
||||
|
||||
expect(decoratedRanges(editor)).toEqual([[pos, pos + node.nodeSize]]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("covers every diagram a whole document selection touches", () => {
|
||||
const source = [
|
||||
`${FENCE}mermaid`,
|
||||
"graph TD;",
|
||||
FENCE,
|
||||
"",
|
||||
"Between.",
|
||||
"",
|
||||
`${FENCE}mermaid`,
|
||||
"graph LR;",
|
||||
FENCE,
|
||||
"",
|
||||
].join("\n");
|
||||
const editor = editorFor(source);
|
||||
editor.commands.selectAll();
|
||||
|
||||
expect(decoratedRanges(editor)).toHaveLength(2);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a fence whose language only looks like mermaid alone", () => {
|
||||
// The code lane leaves this block plain too, so a capitalised info string is neither drawn nor
|
||||
// coloured. It stays the text the user wrote, which is the safe way for the two to disagree.
|
||||
const source = [`${FENCE}Mermaid`, "graph TD;", FENCE, ""].join("\n");
|
||||
const editor = editorFor(source);
|
||||
editor.commands.setTextSelection(1);
|
||||
|
||||
expect(blockAt(editor, 0).node.attrs.language).toBe("Mermaid");
|
||||
expect(decoratedRanges(editor)).toEqual([]);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertMermaid", () => {
|
||||
it("turns the empty paragraph the cursor is on into an empty fence", () => {
|
||||
const editor = makeEditor();
|
||||
|
||||
expect(insertMermaid(editor)).toBe(true);
|
||||
expect(editor.state.doc.childCount).toBe(1);
|
||||
|
||||
const { node } = blockAt(editor, 0);
|
||||
expect(node.type.name).toBe("codeBlock");
|
||||
expect(node.attrs.language).toBe("mermaid");
|
||||
expect(node.attrs.meta).toBe(null);
|
||||
expect(node.textContent).toBe("");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("writes a ```mermaid fence and nothing else", () => {
|
||||
const parsed = parseMarkdown("", PATH);
|
||||
const editor = makeEditor(parsed.doc.toJSON());
|
||||
insertMermaid(editor);
|
||||
|
||||
expect(serializeMarkdown(parsed, editor.state.doc)).toBe(`${FENCE}mermaid\n${FENCE}\n`);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("splits the paragraph it was called from without losing a character of it", () => {
|
||||
// The same thing the toolbar's rule and table buttons have always done with a caret in the
|
||||
// middle of a line. What matters is that the words are all still there, on both sides of it.
|
||||
const editor = editorFor("Some prose.\n");
|
||||
editor.commands.setTextSelection(3);
|
||||
|
||||
expect(insertMermaid(editor)).toBe(true);
|
||||
expect(editor.state.doc.childCount).toBe(3);
|
||||
expect(editor.state.doc.child(0).textContent).toBe("So");
|
||||
expect(editor.state.doc.child(1).attrs.language).toBe("mermaid");
|
||||
expect(editor.state.doc.child(2).textContent).toBe("me prose.");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("writes something the bridge can read back, even inside a list", () => {
|
||||
const parsed = parseMarkdown("- one\n- two\n", PATH);
|
||||
const editor = makeEditor(parsed.doc.toJSON());
|
||||
editor.commands.setTextSelection(4);
|
||||
insertMermaid(editor);
|
||||
|
||||
const written = serializeMarkdown(parsed, editor.state.doc);
|
||||
const reread = parseMarkdown(written, PATH);
|
||||
expect(written).toContain(`${FENCE}mermaid`);
|
||||
expect(serializeMarkdown(reread, reread.doc)).toBe(written);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does nothing, and says so, in a table cell", () => {
|
||||
// Left to itself ProseMirror would split the table in two around the block and leave a row with
|
||||
// no cells in it, which is a table the serializer has nothing to write.
|
||||
const editor = editorFor("| a | b |\n| - | - |\n| 1 | 2 |\n");
|
||||
expect(editor.state.doc.child(0).type.name).toBe("table");
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3)),
|
||||
);
|
||||
const before = editor.state.doc;
|
||||
|
||||
expect(insertMermaid(editor)).toBe(false);
|
||||
expect(editor.state.doc).toBe(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does nothing, and says so, inside another fence", () => {
|
||||
const editor = editorFor(`${FENCE}ts\nconst x = 1;\n${FENCE}\n`);
|
||||
editor.commands.setTextSelection(3);
|
||||
const before = editor.state.doc;
|
||||
|
||||
expect(insertMermaid(editor)).toBe(false);
|
||||
expect(editor.state.doc).toBe(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does nothing, and says so, inside a raw block", () => {
|
||||
const editor = editorFor("<figure><img src='x.png'></figure>\n");
|
||||
expect(editor.state.doc.child(0).type.name).toBe("raw");
|
||||
editor.commands.setTextSelection(3);
|
||||
const before = editor.state.doc;
|
||||
|
||||
expect(insertMermaid(editor)).toBe(false);
|
||||
expect(editor.state.doc).toBe(before);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
// Mermaid diagrams, which are a fenced code block whose language is `mermaid` and nothing else.
|
||||
//
|
||||
// There is no mermaid node in the schema and there will not be one. On disk a diagram is ```mermaid
|
||||
// and the bridge reads it as a codeBlock like any other fence, so every byte of it round trips as
|
||||
// that block's text whether or not it draws. This lane only changes how such a block is shown.
|
||||
//
|
||||
// Mermaid renders asynchronously, which a ProseMirror view update is not, so the node view draws
|
||||
// the fence first and swaps the SVG in when it arrives. A diagram that fails to parse stays as the
|
||||
// code the user wrote, with the error beside it: a broken diagram is a typo to fix, not a block to
|
||||
// hide.
|
||||
//
|
||||
// Nothing below ever writes to the document. The one transaction this file dispatches sets a text
|
||||
// selection, which is a caret move and not an edit, and it is what makes clicking a drawn diagram
|
||||
// put the cursor in the source that drew it. A render result is painted into DOM that sits outside
|
||||
// contentDOM and is declared to ProseMirror as not the document's, so a picture mermaid hands back
|
||||
// can never be read into the tree and saved over somebody's fence.
|
||||
//
|
||||
// ProseMirror resolves node views by node name and the first plugin asked wins, so this file is
|
||||
// handed every code block in the document, not only the mermaid ones. The other kind gets a node
|
||||
// view built from the schema's own toDOM, which is the same `pre > code` a code block had before
|
||||
// this lane existed: the same element prose.css styles and the same one the code lane's decorations
|
||||
// land on.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
|
||||
import type { Mermaid, MermaidConfig } from "mermaid";
|
||||
import { place } from "../fits";
|
||||
|
||||
/**
|
||||
* The one info string that draws. Matched exactly, case included.
|
||||
*
|
||||
* The code lane matches the same word case insensitively when it decides what to leave plain, so
|
||||
* ```Mermaid is highlighted by nobody and drawn by nobody: it stays the fence the user typed. That
|
||||
* is the safe direction for the two lanes to disagree in. Both drawing it and colouring it would
|
||||
* mean two plugins fighting over one block.
|
||||
*/
|
||||
const LANGUAGE = "mermaid";
|
||||
|
||||
const DRAWING = "Drawing diagram…";
|
||||
|
||||
const FAILED = "Mermaid could not draw this diagram.";
|
||||
|
||||
const mermaidKey = new PluginKey("mermaidRendering");
|
||||
|
||||
/** On a node decoration, this marks the code block the selection is currently inside. */
|
||||
const CURSOR_INSIDE = { mermaidCursor: true };
|
||||
|
||||
function isDiagram(node: ProseMirrorNode): boolean {
|
||||
return node.type.name === "codeBlock" && node.attrs.language === LANGUAGE;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// The library, loaded once and only if a diagram is ever drawn
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
let loading: Promise<Mermaid> | null = null;
|
||||
let configured: string | null = null;
|
||||
let drawings = 0;
|
||||
|
||||
/**
|
||||
* Mermaid is several megabytes and a dependency graph to match, so this is the only place it is
|
||||
* mentioned outside a type position and the import is dynamic. The bundler gives it a chunk of its
|
||||
* own, and a user who never writes a diagram never fetches it.
|
||||
*
|
||||
* A failed load clears the promise rather than keeping it, so a chunk that did not arrive once is
|
||||
* asked for again by the next block instead of poisoning every diagram in the app.
|
||||
*/
|
||||
function load(): Promise<Mermaid> {
|
||||
if (!loading) {
|
||||
loading = import("mermaid")
|
||||
.then((module) => module.default)
|
||||
.catch((error) => {
|
||||
loading = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return loading;
|
||||
}
|
||||
|
||||
function themeName(): string {
|
||||
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's palette, handed to mermaid as its own theme variables.
|
||||
*
|
||||
* `base` is the one mermaid theme meant to be recoloured; the others are fixed palettes that would
|
||||
* put somebody else's lavender and yellow in the middle of this page. The values are read off the
|
||||
* token layer at render time rather than named here, so a diagram is drawn in the same ink as the
|
||||
* document around it and follows tokens.css when that changes.
|
||||
*/
|
||||
function themeVariables(): Record<string, string | boolean> {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const variables: Record<string, string | boolean> = {
|
||||
darkMode: themeName() === "dark",
|
||||
fontFamily: "var(--font-ui)",
|
||||
};
|
||||
|
||||
const palette: ReadonlyArray<readonly [string, string]> = [
|
||||
["background", "--paper"],
|
||||
["primaryColor", "--code-surface"],
|
||||
["primaryTextColor", "--ink"],
|
||||
["primaryBorderColor", "--doc-rule-strong"],
|
||||
["secondaryColor", "--shell"],
|
||||
["tertiaryColor", "--raised"],
|
||||
["lineColor", "--ink-soft"],
|
||||
["textColor", "--ink"],
|
||||
// The card a label on an arrow sits on. Left to itself the base theme picks near black for it
|
||||
// in dark mode, which puts a hole in the middle of the diagram.
|
||||
["edgeLabelBackground", "--code-surface"],
|
||||
];
|
||||
|
||||
for (const [variable, token] of palette) {
|
||||
const value = style.getPropertyValue(token).trim();
|
||||
// An empty custom property means the stylesheet is not loaded yet. Mermaid derives its shades
|
||||
// from these by colour arithmetic, and "" is not a colour, so a missing token is left to the
|
||||
// theme's own default rather than passed on.
|
||||
if (value) variables[variable] = value;
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
function configFor(): MermaidConfig {
|
||||
return {
|
||||
// The whole point of this file: nothing scans the page for diagrams, every render is asked for
|
||||
// by a node view that knows which block it belongs to.
|
||||
startOnLoad: false,
|
||||
// Strict is mermaid's own default and the right one here. The text being drawn came out of a
|
||||
// file on disk, so it is sanitised and its click handlers are dropped.
|
||||
securityLevel: "strict",
|
||||
// Without this a parse failure leaves mermaid's own error diagram behind in the page and a
|
||||
// stray temporary div in the body. The error belongs in this block, drawn by the code below.
|
||||
suppressErrorRendering: true,
|
||||
theme: "base",
|
||||
fontFamily: "var(--font-ui)",
|
||||
themeVariables: themeVariables(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One diagram, as an SVG string. Throws whatever mermaid threw.
|
||||
*
|
||||
* The id has to be unique per diagram: mermaid scopes the stylesheet it puts inside each SVG with
|
||||
* `#id`, so two diagrams sharing one would style each other.
|
||||
*/
|
||||
async function toSvg(text: string): Promise<string> {
|
||||
const mermaid = await load();
|
||||
|
||||
const theme = themeName();
|
||||
if (theme !== configured) {
|
||||
mermaid.initialize(configFor());
|
||||
configured = theme;
|
||||
}
|
||||
|
||||
// Parsing first keeps a syntax error away from the renderer entirely, which is the difference
|
||||
// between an error this file can show and a half drawn diagram.
|
||||
await mermaid.parse(text);
|
||||
const { svg } = await mermaid.render(`mermaid-diagram-${(drawings += 1)}`, text);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (error && typeof error === "object") {
|
||||
// Mermaid's parse errors are plain objects carrying the offending line under `str`.
|
||||
const detail = error as { str?: unknown; message?: unknown };
|
||||
if (typeof detail.str === "string") return detail.str;
|
||||
if (typeof detail.message === "string") return detail.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// The theme watch
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
const live = new Set<DiagramView>();
|
||||
|
||||
let watcher: MutationObserver | null = null;
|
||||
let watched: string | null = null;
|
||||
|
||||
/**
|
||||
* A drawn diagram is a picture with the palette baked into it, so the theme changing under it is
|
||||
* the one event that invalidates a render nothing else touched. One observer serves every block,
|
||||
* and it exists only while there is a diagram on screen to redraw.
|
||||
*/
|
||||
function watchTheme(): void {
|
||||
if (watcher) return;
|
||||
watched = themeName();
|
||||
watcher = new MutationObserver(() => {
|
||||
const theme = themeName();
|
||||
if (theme === watched) return;
|
||||
watched = theme;
|
||||
configured = null;
|
||||
for (const view of live) view.redraw();
|
||||
});
|
||||
watcher.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
}
|
||||
|
||||
function unwatchTheme(): void {
|
||||
if (!watcher || live.size > 0) return;
|
||||
watcher.disconnect();
|
||||
watcher = null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// The node views
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every code block that is not a diagram, rendered by the schema rather than by hand.
|
||||
*
|
||||
* Going through the node's own serializer is what makes this a no-op: the DOM here is the DOM
|
||||
* ProseMirror would have built for a code block if this file did not exist, down to whether
|
||||
* `data-language` is written at all, so nothing about an ordinary fence changes because the mermaid
|
||||
* lane happens to be installed.
|
||||
*/
|
||||
class SourceView implements NodeView {
|
||||
readonly dom: HTMLElement;
|
||||
readonly contentDOM: HTMLElement | null;
|
||||
private node: ProseMirrorNode;
|
||||
|
||||
constructor(node: ProseMirrorNode) {
|
||||
const serializer = DOMSerializer.fromSchema(node.type.schema);
|
||||
const rendered = DOMSerializer.renderSpec(document, serializer.nodes[node.type.name](node));
|
||||
this.dom = rendered.dom;
|
||||
this.contentDOM = rendered.contentDOM ?? null;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
update(next: ProseMirrorNode): boolean {
|
||||
// Same markup means the same element, so ProseMirror updates the text inside contentDOM and
|
||||
// this view stands. Anything else is a rebuild, the language crossing into mermaid included,
|
||||
// and a rebuild is what the standard node view would have done with the same change.
|
||||
if (!next.sameMarkup(this.node)) return false;
|
||||
this.node = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type DiagramState = "empty" | "source" | "pending" | "diagram" | "error";
|
||||
|
||||
/**
|
||||
* One mermaid block: the picture, and the source that made it.
|
||||
*
|
||||
* Both are always in the DOM and which one is shown is a CSS state, because the source is the
|
||||
* document's own content and hiding it by removing it would be an edit. The cursor being inside
|
||||
* the block arrives as a node decoration from the plugin below rather than being asked for here,
|
||||
* since a node view is only told about the selection when something else redraws it.
|
||||
*/
|
||||
class DiagramView implements NodeView {
|
||||
readonly dom: HTMLElement;
|
||||
readonly contentDOM: HTMLElement;
|
||||
|
||||
private readonly figure: HTMLElement;
|
||||
private readonly drawing: HTMLElement;
|
||||
private readonly note: HTMLElement;
|
||||
private readonly view: EditorView;
|
||||
private readonly getPos: () => number | undefined;
|
||||
|
||||
private node: ProseMirrorNode;
|
||||
private inside: boolean;
|
||||
|
||||
/** Bumped by anything that makes a render in flight the answer to a question nobody asked. */
|
||||
private token = 0;
|
||||
/** The text the picture on screen was drawn from, or null when there is no picture. */
|
||||
private drawn: string | null = null;
|
||||
private failure: string | null = null;
|
||||
private gone = false;
|
||||
|
||||
constructor(
|
||||
node: ProseMirrorNode,
|
||||
view: EditorView,
|
||||
getPos: () => number | undefined,
|
||||
decorations: readonly Decoration[],
|
||||
) {
|
||||
this.node = node;
|
||||
this.view = view;
|
||||
this.getPos = getPos;
|
||||
this.inside = hasCursor(decorations);
|
||||
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "mermaid-block";
|
||||
|
||||
this.figure = document.createElement("div");
|
||||
this.figure.className = "mermaid-figure";
|
||||
// Not part of the document, so the caret has no business in it and ProseMirror is told as much
|
||||
// here as well as through ignoreMutation below.
|
||||
this.figure.contentEditable = "false";
|
||||
|
||||
this.drawing = document.createElement("div");
|
||||
this.drawing.className = "mermaid-drawing";
|
||||
|
||||
this.note = document.createElement("div");
|
||||
this.note.className = "mermaid-note";
|
||||
|
||||
this.figure.append(this.drawing, this.note);
|
||||
|
||||
const source = document.createElement("pre");
|
||||
source.className = "mermaid-source";
|
||||
source.setAttribute("data-language", LANGUAGE);
|
||||
this.contentDOM = document.createElement("code");
|
||||
source.appendChild(this.contentDOM);
|
||||
|
||||
this.dom.append(this.figure, source);
|
||||
|
||||
this.figure.addEventListener("mousedown", this.enter);
|
||||
|
||||
live.add(this);
|
||||
watchTheme();
|
||||
this.apply();
|
||||
}
|
||||
|
||||
update(next: ProseMirrorNode, decorations: readonly Decoration[]): boolean {
|
||||
// The language leaving mermaid is a different kind of block with different DOM, so this view is
|
||||
// finished and ProseMirror builds the plain one in its place.
|
||||
if (!isDiagram(next)) return false;
|
||||
|
||||
const edited = next.textContent !== this.node.textContent;
|
||||
this.node = next;
|
||||
this.inside = hasCursor(decorations);
|
||||
|
||||
if (edited) {
|
||||
// Whatever is being drawn was drawn from text that is no longer in this block, and the error
|
||||
// on screen, if there is one, is about a line the user may have just fixed.
|
||||
this.token += 1;
|
||||
this.failure = null;
|
||||
}
|
||||
|
||||
this.apply();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The theme changed, so the picture is right about the diagram and wrong about the ink. */
|
||||
redraw(): void {
|
||||
this.failure = null;
|
||||
this.drawn = null;
|
||||
this.apply();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.gone = true;
|
||||
this.figure.removeEventListener("mousedown", this.enter);
|
||||
live.delete(this);
|
||||
unwatchTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* The figure is the view's own drawing, not the document. Reading an SVG mermaid just handed over
|
||||
* back into the tree would replace the user's fence with a transcription of its own picture, so
|
||||
* every mutation outside contentDOM is none of ProseMirror's business.
|
||||
*/
|
||||
ignoreMutation(mutation: ViewMutationRecord): boolean {
|
||||
return !this.contentDOM.contains(mutation.target);
|
||||
}
|
||||
|
||||
stopEvent(event: Event): boolean {
|
||||
const target = event.target;
|
||||
return target instanceof Node ? !this.contentDOM.contains(target) : false;
|
||||
}
|
||||
|
||||
/** Clicking the picture puts the caret in the source that drew it, which is how a diagram is edited. */
|
||||
private enter = (event: MouseEvent): void => {
|
||||
const pos = this.getPos();
|
||||
if (pos === undefined) return;
|
||||
event.preventDefault();
|
||||
const { state } = this.view;
|
||||
const inside = Math.min(pos + 1, state.doc.content.size);
|
||||
this.view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, inside)));
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/** What should be on screen for the block as it is now, and a render if that is not known yet. */
|
||||
private apply(): void {
|
||||
const text = this.node.textContent;
|
||||
|
||||
if (!text.trim()) {
|
||||
this.show("empty");
|
||||
return;
|
||||
}
|
||||
// Shown whether or not the caret is in the block, because a diagram that will not draw is a
|
||||
// line to go and fix and the message is how anybody knows which line.
|
||||
if (this.failure !== null) {
|
||||
this.note.textContent = `${FAILED}\n\n${this.failure}`;
|
||||
this.show("error");
|
||||
return;
|
||||
}
|
||||
if (this.inside) {
|
||||
this.show("source");
|
||||
return;
|
||||
}
|
||||
if (this.drawn === text) {
|
||||
this.show("diagram");
|
||||
return;
|
||||
}
|
||||
this.draw(text);
|
||||
}
|
||||
|
||||
private draw(text: string): void {
|
||||
const token = (this.token += 1);
|
||||
|
||||
// A diagram already on screen stays there while the next one is drawn, so a theme change or a
|
||||
// finished edit does not blink the block out of the page and back into it.
|
||||
if (this.drawing.firstChild) {
|
||||
this.show("diagram");
|
||||
this.dom.setAttribute("data-busy", "");
|
||||
} else {
|
||||
this.note.textContent = DRAWING;
|
||||
this.show("pending");
|
||||
}
|
||||
|
||||
toSvg(text).then(
|
||||
(svg) => {
|
||||
if (this.stale(token)) return;
|
||||
this.dom.removeAttribute("data-busy");
|
||||
// Mermaid sanitises what it returns, and a script arriving through innerHTML does not run
|
||||
// in any case, so the SVG goes in as markup and the error below never does.
|
||||
this.drawing.innerHTML = svg;
|
||||
this.drawn = text;
|
||||
this.failure = null;
|
||||
this.apply();
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.stale(token)) return;
|
||||
this.dom.removeAttribute("data-busy");
|
||||
this.drawing.textContent = "";
|
||||
this.drawn = null;
|
||||
this.failure = messageOf(error);
|
||||
this.apply();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid answers whenever it answers, and by then the block may have been edited, the document
|
||||
* may have been closed and this view may have been thrown away. The token covers every one of
|
||||
* those: it is bumped by an edit, by a theme change and by destroy, so an answer to a question
|
||||
* nobody is asking any more is dropped rather than painted somewhere it no longer belongs.
|
||||
*/
|
||||
private stale(token: number): boolean {
|
||||
return this.gone || token !== this.token || this.view.isDestroyed;
|
||||
}
|
||||
|
||||
private show(state: DiagramState): void {
|
||||
this.dom.setAttribute("data-state", state);
|
||||
}
|
||||
}
|
||||
|
||||
function hasCursor(decorations: readonly Decoration[]): boolean {
|
||||
return decorations.some((decoration) => decoration.spec?.mermaidCursor === true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// The plugin
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A node decoration on every mermaid block the selection touches.
|
||||
*
|
||||
* This is how a node view is told the caret is inside it. A decoration changing is one of the two
|
||||
* things that make ProseMirror ask a node view to update, and the selection moving on its own is
|
||||
* not the other, so without this a block would keep drawing the diagram with the cursor in it.
|
||||
*/
|
||||
function cursorDecorations(state: EditorState): DecorationSet | null {
|
||||
const { from, to } = state.selection;
|
||||
const found: Decoration[] = [];
|
||||
|
||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.type.name !== "codeBlock") return true;
|
||||
if (isDiagram(node)) found.push(Decoration.node(pos, pos + node.nodeSize, {}, CURSOR_INSIDE));
|
||||
return false;
|
||||
});
|
||||
|
||||
return found.length > 0 ? DecorationSet.create(state.doc, found) : null;
|
||||
}
|
||||
|
||||
export const MermaidRendering = Extension.create({
|
||||
name: "mermaidRendering",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: mermaidKey,
|
||||
props: {
|
||||
nodeViews: {
|
||||
codeBlock: (node, view, getPos, decorations) =>
|
||||
isDiagram(node)
|
||||
? new DiagramView(node, view, getPos, decorations)
|
||||
: new SourceView(node),
|
||||
},
|
||||
decorations: cursorDecorations,
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Inserts an empty ```mermaid fence. False where a code block cannot go.
|
||||
*
|
||||
* Through `place` rather than asking `fits` about each end itself, which is what this did while it
|
||||
* was the only insert in its own file. Both spellings refuse the same things today, but only one of
|
||||
* them refuses the next thing the guard learns: `fits` gained a cell selection rule after a drag
|
||||
* across a table lost six cells to an insert that had asked it the older way, and a caller holding
|
||||
* its own copy of the question is a caller that does not get told. There is one gate and every
|
||||
* insert goes through it.
|
||||
*/
|
||||
export function insertMermaid(editor: Editor): boolean {
|
||||
return place(editor, editor.schema.nodes.codeBlock, (chain) =>
|
||||
chain.insertContent({ type: "codeBlock", attrs: { language: LANGUAGE, meta: null } }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
// A table is the one block in this editor whose behaviour is a library's rather than this app's,
|
||||
// which makes it the one block where "it works" is easy to assume and easy to be wrong about. Two
|
||||
// things are asserted here that a passing prosemirror-tables would not give for free.
|
||||
//
|
||||
// The first is alignment, which is this file's own and not the library's. GFM keeps alignment in
|
||||
// the delimiter row, one entry per column, so the test that matters is not what the attribute says
|
||||
// but what the serializer writes: a column whose cells disagree is a table the file cannot hold.
|
||||
//
|
||||
// The second is the header row, for the same reason from the other end. GFM has exactly one and it
|
||||
// is the first row, so an edit that leaves body cells in row zero is an edit whose result the file
|
||||
// cannot spell, and the screen would go on showing it until the file was next opened.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import type { Transaction } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice } from "@tiptap/pm/model";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
import { createEditorExtensions } from "../extensions";
|
||||
import { serializeMarkdown } from "../../markdown";
|
||||
import { tableCommand, typingKey } from "./tables";
|
||||
|
||||
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
|
||||
|
||||
function makeEditor(content: JSONContent = EMPTY): Editor {
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
injectCSS: false,
|
||||
extensions: createEditorExtensions({ documentPath: () => "/notes/a.md", onError: () => {} }),
|
||||
content,
|
||||
});
|
||||
|
||||
// TipTap only installs the extensions' ProseMirror plugins when it mounts a view, and there is no
|
||||
// DOM here to mount into. Swapping in a state built with them is what src/editor/Editor.tsx does
|
||||
// on every document it installs, so this is the same editor the app runs, minus the screen.
|
||||
editor.view.updateState(
|
||||
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* One key, offered to the plugins in the order the view would offer it and stopping at the first
|
||||
* that claims it. Which plugin answered is the whole question in half these tests, so the walk is
|
||||
* the real one rather than a call into the binding this file happens to be about.
|
||||
*/
|
||||
function press(editor: Editor, key: string, shift = false): boolean {
|
||||
const event = {
|
||||
key,
|
||||
keyCode: key === "Tab" ? 9 : 8,
|
||||
shiftKey: shift,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
preventDefault: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
const view = editor.view as unknown as EditorView;
|
||||
|
||||
for (const plugin of editor.state.plugins) {
|
||||
const handler = plugin.props?.handleKeyDown;
|
||||
if (handler && handler.call(plugin, view, event)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* One printable character, offered to the plugins the way the view offers one and, when nobody
|
||||
* claims it, put in the way the view would put it. True when a plugin claimed it.
|
||||
*
|
||||
* `tr.insertText` with no range is `Selection.replace`, and over a cell selection that replaces
|
||||
* every range in the selection: the character goes into the last cell of the rectangle and the
|
||||
* rest are emptied. That fallback is the bug, so it is run here rather than described.
|
||||
*/
|
||||
function type(editor: Editor, character: string): boolean {
|
||||
const view = editor.view as unknown as EditorView;
|
||||
const { $from, $to } = editor.state.selection;
|
||||
const deflt = () => editor.state.tr.insertText(character).scrollIntoView();
|
||||
|
||||
for (const plugin of editor.state.plugins) {
|
||||
const handler = plugin.props?.handleTextInput;
|
||||
if (handler && handler.call(plugin, view, $from.pos, $to.pos, character, deflt)) return true;
|
||||
}
|
||||
editor.view.dispatch(deflt());
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A table alone in a document. The first row is header cells, as every GFM table's is. */
|
||||
function tableDoc(rows: string[][]): JSONContent {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: rows.map((cells, row) => ({
|
||||
type: "tableRow",
|
||||
content: cells.map((text) => ({
|
||||
type: row === 0 ? "tableHeader" : "tableCell",
|
||||
...(text ? { content: [{ type: "text", text }] } : {}),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The document's first table and where it starts, wherever in the tree it happens to sit. */
|
||||
function tableAt(editor: Editor): { node: ProseMirrorNode; pos: number } {
|
||||
let found: { node: ProseMirrorNode; pos: number } | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (found !== null) return false;
|
||||
if (node.type.name === "table") found = { node, pos };
|
||||
return found === null;
|
||||
});
|
||||
if (found === null) throw new Error("this document has no table in it");
|
||||
return found;
|
||||
}
|
||||
|
||||
const table = (editor: Editor) => tableAt(editor).node;
|
||||
|
||||
/** The document position just inside a cell. */
|
||||
function inCell(editor: Editor, row: number, column: number): number {
|
||||
const { node, pos } = tableAt(editor);
|
||||
return pos + 1 + TableMap.get(node).positionAt(row, column, node) + 1;
|
||||
}
|
||||
|
||||
function cursorIn(editor: Editor, row: number, column: number): void {
|
||||
editor.commands.setTextSelection(inCell(editor, row, column));
|
||||
}
|
||||
|
||||
function selectCells(editor: Editor, from: [number, number], to: [number, number]): void {
|
||||
const anchor = inCell(editor, from[0], from[1]) - 1;
|
||||
const head = inCell(editor, to[0], to[1]) - 1;
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(CellSelection.create(editor.state.doc, anchor, head)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The table as a grid of whatever `of` reads off a cell. */
|
||||
function grid<T>(editor: Editor, of: (cell: ProseMirrorNode) => T): T[][] {
|
||||
const out: T[][] = [];
|
||||
table(editor).forEach((row) => {
|
||||
const cells: T[] = [];
|
||||
row.forEach((cell) => cells.push(of(cell)));
|
||||
out.push(cells);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const shape = (editor: Editor) => grid(editor, (cell) => cell.textContent);
|
||||
const kinds = (editor: Editor) => grid(editor, (cell) => cell.type.name);
|
||||
const aligns = (editor: Editor) => grid(editor, (cell) => cell.attrs.align as string | null);
|
||||
|
||||
/** A clipboard carrying one word of plain text, which is what a slice with something in it is. */
|
||||
function textSlice(editor: Editor, text: string): Slice {
|
||||
return new Slice(Fragment.from(editor.schema.text(text)), 0, 0);
|
||||
}
|
||||
|
||||
/** What the bridge would write for this document, in a file that holds nothing else. */
|
||||
function written(editor: Editor): string {
|
||||
const doc = editor.state.doc;
|
||||
return serializeMarkdown({ frontmatter: null, doc, source: "", path: "/notes/a.md" }, doc);
|
||||
}
|
||||
|
||||
const GRID = [
|
||||
["a", "b", "c"],
|
||||
["1", "2", "3"],
|
||||
["4", "5", "6"],
|
||||
];
|
||||
|
||||
describe("Tab in a table", () => {
|
||||
it("moves to the next cell and wraps on to the next row", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(press(editor, "Tab")).toBe(true);
|
||||
expect(editor.state.selection.from).toBe(inCell(editor, 0, 1));
|
||||
expect(press(editor, "Tab")).toBe(true);
|
||||
expect(press(editor, "Tab")).toBe(true);
|
||||
expect(editor.state.selection.from).toBe(inCell(editor, 1, 0));
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("moves back on Shift-Tab, and stops at the first cell", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 0);
|
||||
|
||||
expect(press(editor, "Tab", true)).toBe(true);
|
||||
expect(editor.state.selection.from).toBe(inCell(editor, 0, 2));
|
||||
|
||||
// Nowhere to go, so the binding declines and the key falls through untouched by it.
|
||||
cursorIn(editor, 0, 0);
|
||||
press(editor, "Tab", true);
|
||||
expect(shape(editor)).toEqual(GRID);
|
||||
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The precedence assertion. shortcuts.ts also binds Tab, for sinking a list item, and it is the
|
||||
// only other binding on the key: a fourth row here is proof that this lane's is asked first and
|
||||
// that the list one does not answer inside a table.
|
||||
it("grows the table out of the last cell, into a body row", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 2, 2);
|
||||
|
||||
expect(press(editor, "Tab")).toBe(true);
|
||||
expect(shape(editor)).toEqual([...GRID, ["", "", ""]]);
|
||||
expect(kinds(editor)[3]).toEqual(["tableCell", "tableCell", "tableCell"]);
|
||||
expect(editor.state.selection.from).toBe(inCell(editor, 3, 0));
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("grows a table that is nothing but its header row", () => {
|
||||
const editor = makeEditor(tableDoc([["a", "b"]]));
|
||||
cursorIn(editor, 0, 1);
|
||||
|
||||
expect(press(editor, "Tab")).toBe(true);
|
||||
expect(kinds(editor)).toEqual([
|
||||
["tableHeader", "tableHeader"],
|
||||
["tableCell", "tableCell"],
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves Tab alone outside a table", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(2);
|
||||
|
||||
expect(press(editor, "Tab")).toBe(false);
|
||||
expect(editor.state.doc.textContent).toBe("x");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backspace over a cell selection", () => {
|
||||
it("empties the cells and keeps the table the shape it was", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
selectCells(editor, [1, 0], [2, 1]);
|
||||
|
||||
expect(press(editor, "Backspace")).toBe(true);
|
||||
expect(shape(editor)).toEqual([
|
||||
["a", "b", "c"],
|
||||
["", "", "3"],
|
||||
["", "", "6"],
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// A printable character over the same rectangle, which until this lane claimed it did what
|
||||
// Backspace does and then wrote the character into the corner of the wreckage.
|
||||
//
|
||||
// Reported and reproduced in Chromium: a real mouse drag from (0,0) to (1,1) of a 2x2 body and the
|
||||
// three keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" in the last
|
||||
// one. Four cells of somebody's table for three letters, none of them the cell the drag started
|
||||
// in. It is the destruction src/editor/paste.ts refuses for a Cmd+V that lands on the same
|
||||
// selection, arriving by the one route with no guard on it.
|
||||
describe("typing over a cell selection", () => {
|
||||
it("leaves every cell in the rectangle exactly as it was", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
const before = written(editor);
|
||||
selectCells(editor, [1, 0], [2, 1]);
|
||||
|
||||
expect(type(editor, "z")).toBe(true);
|
||||
|
||||
expect(shape(editor)).toEqual(GRID);
|
||||
expect(written(editor)).toBe(before);
|
||||
// And the rectangle is still selected, so Backspace, the toolbar and a click into one cell are
|
||||
// all still where the user left them.
|
||||
expect(editor.state.selection instanceof CellSelection).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The other half, twice, because a guard that claimed every character everywhere would pass the
|
||||
// test above and would be an editor nobody can type in.
|
||||
it("still types into a single cell, and still types outside a table", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 0);
|
||||
|
||||
expect(type(editor, "z")).toBe(false);
|
||||
expect(shape(editor)[1][0]).toBe("z1");
|
||||
editor.destroy();
|
||||
|
||||
const prose = makeEditor();
|
||||
expect(type(prose, "z")).toBe(false);
|
||||
expect(prose.state.doc.textContent).toBe("z");
|
||||
prose.destroy();
|
||||
});
|
||||
|
||||
// And the half this project keeps getting wrong: an answer nothing asks for is not an answer.
|
||||
// ProseMirror stops at the first plugin that claims a character, so a guard behind the plugin
|
||||
// that would have destroyed the cells is a guard the running editor never reaches. TipTap's own
|
||||
// input rules claim handleTextInput too, which is what this is measured against.
|
||||
it("is the first plugin in the list that claims a typed character", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
const claimants = editor.state.plugins.flatMap((plugin, index) =>
|
||||
plugin.props?.handleTextInput ? [index] : [],
|
||||
);
|
||||
const guard = editor.state.plugins.findIndex((plugin) => plugin.spec.key === typingKey);
|
||||
|
||||
expect(guard).toBeGreaterThanOrEqual(0);
|
||||
expect(claimants[0]).toBe(guard);
|
||||
expect(claimants.length).toBeGreaterThan(1);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the row and column ops", () => {
|
||||
it("adds and removes rows where the cursor is", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 0);
|
||||
|
||||
expect(tableCommand(editor, "addRowAfter")).toBe(true);
|
||||
expect(shape(editor)).toEqual([["a", "b", "c"], ["1", "2", "3"], ["", "", ""], ["4", "5", "6"]]);
|
||||
|
||||
cursorIn(editor, 2, 0);
|
||||
expect(tableCommand(editor, "deleteRow")).toBe(true);
|
||||
expect(shape(editor)).toEqual(GRID);
|
||||
|
||||
cursorIn(editor, 1, 0);
|
||||
expect(tableCommand(editor, "addRowBefore")).toBe(true);
|
||||
expect(shape(editor)).toEqual([["a", "b", "c"], ["", "", ""], ["1", "2", "3"], ["4", "5", "6"]]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("adds and removes columns where the cursor is", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 1);
|
||||
|
||||
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
|
||||
expect(shape(editor)[0]).toEqual(["a", "b", "", "c"]);
|
||||
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
|
||||
|
||||
cursorIn(editor, 0, 2);
|
||||
expect(tableCommand(editor, "deleteColumn")).toBe(true);
|
||||
expect(shape(editor)).toEqual(GRID);
|
||||
|
||||
cursorIn(editor, 0, 0);
|
||||
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
|
||||
expect(shape(editor)[1]).toEqual(["", "1", "2", "3"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("takes the whole table out, leaving a document behind", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "before" }] }, ...tableDoc(GRID).content!],
|
||||
});
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 4);
|
||||
|
||||
expect(tableCommand(editor, "deleteTable")).toBe(true);
|
||||
expect(editor.state.doc.childCount).toBe(1);
|
||||
expect(editor.state.doc.textContent).toBe("before");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("takes a table that is the whole document out, leaving something behind", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(tableCommand(editor, "deleteTable")).toBe(true);
|
||||
expect(editor.state.doc.childCount).toBe(1);
|
||||
expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
|
||||
expect(editor.state.doc.textContent).toBe("");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// prosemirror-tables builds a new cell from the one it is standing beside, so all three of these
|
||||
// leave a table whose first row is not the row the serializer will write as the header. What is
|
||||
// on screen after the edit has to be what the file says, or the edit is taken back the next time
|
||||
// the document is opened.
|
||||
it("keeps the header first when a row goes in above it", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(tableCommand(editor, "addRowBefore")).toBe(true);
|
||||
expect(kinds(editor)).toEqual([
|
||||
["tableHeader", "tableHeader", "tableHeader"],
|
||||
["tableCell", "tableCell", "tableCell"],
|
||||
["tableCell", "tableCell", "tableCell"],
|
||||
["tableCell", "tableCell", "tableCell"],
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps the header first when a column goes in in front of it", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(tableCommand(editor, "addColumnBefore")).toBe(true);
|
||||
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader", "tableHeader"]);
|
||||
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell", "tableCell"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The last row and the last column decline rather than emptying the table out, which is
|
||||
// prosemirror-tables' own answer and the right one: a table with no rows is not something GFM can
|
||||
// write, and Delete table is the op that means what this would have meant.
|
||||
it("will not take the last row or the last column", () => {
|
||||
for (const op of ["deleteRow", "deleteColumn"] as const) {
|
||||
const editor = makeEditor(tableDoc([["a"]]));
|
||||
cursorIn(editor, 0, 0);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
|
||||
expect([op, editor.state.doc.toJSON()]).toEqual([op, before]);
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a header row when the header row is the one deleted", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(tableCommand(editor, "deleteRow")).toBe(true);
|
||||
expect(shape(editor)).toEqual([GRID[1], GRID[2]]);
|
||||
expect(kinds(editor)[0]).toEqual(["tableHeader", "tableHeader", "tableHeader"]);
|
||||
expect(kinds(editor)[1]).toEqual(["tableCell", "tableCell", "tableCell"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does nothing at all with the cursor outside a table", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(2);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
for (const op of ["addRowAfter", "deleteRow", "addColumnAfter", "deleteColumn", "deleteTable", "alignCenter"] as const) {
|
||||
expect([op, tableCommand(editor, op)]).toEqual([op, false]);
|
||||
}
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("alignment", () => {
|
||||
it("writes the whole column, header included, and nothing beside it", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 2, 1);
|
||||
|
||||
expect(tableCommand(editor, "alignCenter")).toBe(true);
|
||||
expect(aligns(editor)).toEqual([
|
||||
[null, "center", null],
|
||||
[null, "center", null],
|
||||
[null, "center", null],
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("reaches the serializer's delimiter row, and comes back off it", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 0);
|
||||
tableCommand(editor, "alignRight");
|
||||
cursorIn(editor, 1, 2);
|
||||
tableCommand(editor, "alignCenter");
|
||||
|
||||
expect(written(editor)).toBe(
|
||||
["| a | b | c |", "| -: | - | :-: |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
|
||||
);
|
||||
|
||||
cursorIn(editor, 1, 0);
|
||||
expect(tableCommand(editor, "alignClear")).toBe(true);
|
||||
cursorIn(editor, 1, 2);
|
||||
expect(tableCommand(editor, "alignClear")).toBe(true);
|
||||
expect(written(editor)).toBe(
|
||||
["| a | b | c |", "| - | - | - |", "| 1 | 2 | 3 |", "| 4 | 5 | 6 |", ""].join("\n"),
|
||||
);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("covers every column a cell selection touches", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
selectCells(editor, [0, 0], [1, 1]);
|
||||
|
||||
expect(tableCommand(editor, "alignLeft")).toBe(true);
|
||||
expect(aligns(editor)).toEqual([
|
||||
["left", "left", null],
|
||||
["left", "left", null],
|
||||
["left", "left", null],
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("declines a column that already reads that way", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(tableCommand(editor, "alignLeft")).toBe(true);
|
||||
expect(tableCommand(editor, "alignLeft")).toBe(false);
|
||||
expect(tableCommand(editor, "alignClear")).toBe(true);
|
||||
expect(tableCommand(editor, "alignClear")).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("survives a row added above the header row", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 2);
|
||||
tableCommand(editor, "alignRight");
|
||||
cursorIn(editor, 0, 0);
|
||||
|
||||
expect(tableCommand(editor, "addRowBefore")).toBe(true);
|
||||
expect(aligns(editor)).toEqual([
|
||||
[null, null, "right"],
|
||||
[null, null, "right"],
|
||||
[null, null, "right"],
|
||||
[null, null, "right"],
|
||||
]);
|
||||
expect(written(editor).split("\n")[1]).toBe("| - | - | -: |");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("stays with its own column when a column beside it goes", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 2);
|
||||
tableCommand(editor, "alignCenter");
|
||||
cursorIn(editor, 1, 0);
|
||||
|
||||
expect(tableCommand(editor, "deleteColumn")).toBe(true);
|
||||
expect(aligns(editor)[0]).toEqual([null, "center"]);
|
||||
expect(written(editor).split("\n")[1]).toBe("| - | :-: |");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does not follow a new column in beside it", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 0);
|
||||
tableCommand(editor, "alignCenter");
|
||||
|
||||
expect(tableCommand(editor, "addColumnAfter")).toBe(true);
|
||||
expect(aligns(editor)[0]).toEqual(["center", null, null, null]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// prosemirror-tables builds a new cell from the attribute's default, so every one of these would
|
||||
// leave a column disagreeing with itself. The delimiter row is written off the first row, which
|
||||
// makes the row added above it the one that would take the whole table's alignment off.
|
||||
it("survives a row added under it", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 1);
|
||||
tableCommand(editor, "alignCenter");
|
||||
cursorIn(editor, 2, 2);
|
||||
press(editor, "Tab");
|
||||
|
||||
expect(aligns(editor)[3]).toEqual([null, "center", null]);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// Dragging a column edge is the one edit the file has nowhere to put. It is asserted rather than
|
||||
// assumed because the failure is invisible: the markdown is identical, so a resize looks saved and
|
||||
// is gone the next time the document is opened. See the note at the top of tables.ts.
|
||||
describe("a resized column", () => {
|
||||
it("changes the document without changing a byte of the markdown", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
const before = written(editor);
|
||||
|
||||
const pos = inCell(editor, 0, 0) - 1;
|
||||
const cell = editor.state.doc.nodeAt(pos)!;
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setNodeMarkup(pos, null, { ...cell.attrs, colwidth: [180] }),
|
||||
);
|
||||
|
||||
expect(table(editor).firstChild!.firstChild!.attrs.colwidth).toEqual([180]);
|
||||
expect(written(editor)).toBe(before);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// A table indented under a bullet, which is the one place a table has structure around it that
|
||||
// another extension's keys will act on. Every key this lane binds is asked here, because what is
|
||||
// behind each of them is a list command that reshapes the list rather than the table: a key that
|
||||
// falls through from inside a cell can take the bullet out from under the table the cursor is in.
|
||||
const NESTED: JSONContent = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "T" }] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "item" }] },
|
||||
...tableDoc([["a", "b"], ["c", "d"]]).content!,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "next" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** The node names from the document down to the cursor, which is what a lifted list item loses. */
|
||||
function path(editor: Editor): string[] {
|
||||
const { $from } = editor.state.selection;
|
||||
const names: string[] = [];
|
||||
for (let depth = 1; depth <= $from.depth; depth += 1) names.push($from.node(depth).type.name);
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("a table nested in a list item", () => {
|
||||
it("stays where it is when Shift-Tab is pressed in the first cell", () => {
|
||||
const editor = makeEditor(NESTED);
|
||||
cursorIn(editor, 0, 0);
|
||||
const before = editor.state.doc.toJSON();
|
||||
const at = editor.state.selection.from;
|
||||
|
||||
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
|
||||
press(editor, "Tab", true);
|
||||
|
||||
// Nowhere to go, so nothing moves. What must not happen is the key reaching the list command
|
||||
// behind this lane's binding, which lifts the item and dissolves the list around the table.
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
expect(editor.state.selection.from).toBe(at);
|
||||
expect(path(editor)).toEqual(["bulletList", "listItem", "table", "tableRow", "tableHeader"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps its list when Backspace is pressed at the start of the first cell", () => {
|
||||
const editor = makeEditor(NESTED);
|
||||
cursorIn(editor, 0, 0);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
press(editor, "Backspace");
|
||||
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps its list when Delete is pressed at the end of the last cell", () => {
|
||||
const editor = makeEditor(NESTED);
|
||||
const map = TableMap.get(table(editor));
|
||||
const row = map.height - 1;
|
||||
const column = map.width - 1;
|
||||
const cell = table(editor).nodeAt(map.map[row * map.width + column])!;
|
||||
editor.commands.setTextSelection(inCell(editor, row, column) + cell.content.size);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
press(editor, "Delete");
|
||||
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// A paste is not this lane's feature and prosemirror-tables claims one, which is exactly why the
|
||||
// assertion is here: this file installs that plugin, so what it does with a paste is this file's
|
||||
// answer to give. Over a rectangle of dragged cells the library replaces the content of every cell
|
||||
// in the rectangle with the slice, whatever the slice is. One word replaced six cells. An image on
|
||||
// the clipboard carries no HTML and no text, so the slice ProseMirror hands along is the empty one
|
||||
// and six cells were emptied by a paste that put nothing anywhere.
|
||||
//
|
||||
// Both were reproduced against the plugin order this file used to run: pasting the word `z` over
|
||||
// a two by three rectangle gave [["z","z","z"],["z","z","z"],["4","5","6"]], and Slice.empty over
|
||||
// the same rectangle gave [["","",""],["","",""],["4","5","6"]]. The app's clipboard plugin now
|
||||
// sits in front of the library's and refuses both, and stands aside for the one paste the library
|
||||
// does better, which is cells over cells.
|
||||
//
|
||||
// The walk below is EditorView.prototype.someProp rather than a loop over `editor.state.plugins`
|
||||
// written here. They agree today, and the point is that this asks the question the running editor
|
||||
// asks instead of a modelled one: the props the component puts on the view come first, then the
|
||||
// direct plugins, then the state's, and a guard written in the wrong one of those three answers
|
||||
// nothing. Four guards in this project have been shipped and never reached, and every test that
|
||||
// missed one was a test that called the handler itself.
|
||||
describe("a paste over a dragged rectangle of cells", () => {
|
||||
const paste = (editor: Editor, slice: Slice): boolean => {
|
||||
const event = { preventDefault: () => {} } as unknown as ClipboardEvent;
|
||||
const view = {
|
||||
get state() {
|
||||
return editor.state;
|
||||
},
|
||||
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
|
||||
directPlugins: [],
|
||||
_props: {},
|
||||
someProp: EditorView.prototype.someProp,
|
||||
focus: () => {},
|
||||
dom: null,
|
||||
composing: false,
|
||||
dragging: null,
|
||||
editable: true,
|
||||
} as unknown as EditorView;
|
||||
return view.someProp("handlePaste", (f) => f(view, event, slice)) === true;
|
||||
};
|
||||
|
||||
/** The clipboard a real copy out of a table puts there, which is the one shape to stand aside for. */
|
||||
const cellSlice = (editor: Editor, from: [number, number], to: [number, number]): Slice => {
|
||||
selectCells(editor, from, to);
|
||||
return editor.state.selection.content();
|
||||
};
|
||||
|
||||
it("leaves every cell alone when the clipboard carried nothing", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
const before = written(editor);
|
||||
selectCells(editor, [0, 0], [1, 2]);
|
||||
|
||||
expect(paste(editor, Slice.empty)).toBe(true);
|
||||
|
||||
expect(shape(editor)).toEqual(GRID);
|
||||
expect(written(editor)).toBe(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves every cell alone when the clipboard carried a word", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
const before = written(editor);
|
||||
selectCells(editor, [0, 0], [1, 2]);
|
||||
|
||||
expect(paste(editor, textSlice(editor, "z"))).toBe(true);
|
||||
|
||||
// Six cells for one word is not a paste anybody meant, and it is not undoable in the file: the
|
||||
// save lands half a second later whether or not the user has noticed yet.
|
||||
expect(shape(editor)).toEqual(GRID);
|
||||
expect(written(editor)).toBe(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("still lays out a rectangle of cells copied out of a table", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
const copied = cellSlice(editor, [1, 0], [1, 1]);
|
||||
selectCells(editor, [2, 0], [2, 1]);
|
||||
|
||||
expect(paste(editor, copied)).toBe(true);
|
||||
|
||||
// The library's own edit, kept because it is better than anything this app would do with it: it
|
||||
// lays the cells out over the rectangle and keeps every boundary the user copied. Refusing this
|
||||
// would be the guard destroying a paste in order to guard it.
|
||||
expect(shape(editor)).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "2", "3"],
|
||||
["1", "2", "6"],
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("puts a word into the one cell the caret is in", () => {
|
||||
const editor = makeEditor(tableDoc(GRID));
|
||||
cursorIn(editor, 1, 1);
|
||||
editor.commands.setTextSelection(inCell(editor, 1, 1) + 1);
|
||||
|
||||
// Nobody claims it, so ProseMirror's own handler runs and does the ordinary thing. Refusing a
|
||||
// paste at a caret in a cell would have been this guard overreaching in the other direction.
|
||||
expect(paste(editor, textSlice(editor, "z"))).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
// Table behaviour: everything about editing a GFM table that is not its shape.
|
||||
//
|
||||
// The shape is already in src/model/schema.ts and generated into an extension by extensions.ts, so
|
||||
// nothing here declares a node. What is missing is the behaviour prosemirror-tables carries: the
|
||||
// cell selection, Tab between cells, and the row and column edits a toolbar asks for by name. That
|
||||
// library ships inside @tiptap/pm/tables and reads the `tableRole` extensions.ts already puts on
|
||||
// each spec, so it plugs in whole rather than being reimplemented.
|
||||
//
|
||||
// Alignment is the one thing the library has no idea about, and it runs through everything below.
|
||||
// `align` is a cell attribute the bridge reads back out of the GFM delimiter row, and that row is
|
||||
// per column: markdown cannot say that one cell is centred and the rest of its column is not. So an
|
||||
// align op writes the whole column, and a row added into a column has to be told what that column
|
||||
// says, because prosemirror-tables builds its new cells from the attribute's default. The
|
||||
// serializer reads the delimiter row off the table's first row, which makes a row added above the
|
||||
// first one the worst case: left alone it would take the whole table's alignment off the next time
|
||||
// the file was written.
|
||||
//
|
||||
// The other thing markdown cannot follow is the shape of the header. A GFM table has exactly one
|
||||
// header row, it is the first one, and there is no spelling for a table without one, so the ops
|
||||
// here keep the document to that shape rather than offering edits the file cannot hold. That is
|
||||
// also why there is no header row toggle: both directions of it are a change the next open of the
|
||||
// file silently takes back.
|
||||
//
|
||||
// One thing the library does that the file cannot follow either: dragging a column edge writes
|
||||
// `colwidth` on to every cell in that column, and GFM has no column widths for the serializer to
|
||||
// put them in. The drag is a real document change all the same, and src/document.ts is where it
|
||||
// stops being one: a transaction that only moved something the markdown cannot spell does not mark
|
||||
// the buffer dirty, so the drag never reaches the debounce and no save is scheduled behind it.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import type { Command, Transaction } from "@tiptap/pm/state";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
TableMap,
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRow,
|
||||
columnResizing,
|
||||
deleteCellSelection,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
goToNextCell,
|
||||
isInTable,
|
||||
selectedRect,
|
||||
tableEditing,
|
||||
} from "@tiptap/pm/tables";
|
||||
import type { TableRect } from "@tiptap/pm/tables";
|
||||
import type { ColumnAlign } from "../../model/doc";
|
||||
import { overCells } from "../fits";
|
||||
import type { TableOp } from "../index";
|
||||
|
||||
/**
|
||||
* The typing guard, named so that a test can find it in the plugin list and say where in that list
|
||||
* it sits. Being right about a rectangle of cells is worth nothing if something else is asked
|
||||
* first, which is the mistake this lane has already made once with a paste.
|
||||
*/
|
||||
export const typingKey = new PluginKey("tableTyping");
|
||||
|
||||
/** What each column says, read where the serializer reads it: the table's first row. */
|
||||
function columnAlignments(table: ProseMirrorNode): ColumnAlign[] {
|
||||
const map = TableMap.get(table);
|
||||
return Array.from(
|
||||
{ length: map.width },
|
||||
(_unused, column) => (table.nodeAt(map.map[column])?.attrs.align ?? null) as ColumnAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every cell of every column made to agree with `alignment`, whatever the edit left behind. */
|
||||
function restoreAlignments(tr: Transaction, tablePos: number, alignment: ColumnAlign[]): void {
|
||||
const table = tr.doc.nodeAt(tablePos);
|
||||
if (!table) return;
|
||||
const map = TableMap.get(table);
|
||||
const columns = Math.min(map.width, alignment.length);
|
||||
const done = new Set<number>();
|
||||
|
||||
for (let row = 0; row < map.height; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const pos = map.map[row * map.width + column];
|
||||
if (done.has(pos)) continue;
|
||||
done.add(pos);
|
||||
const cell = table.nodeAt(pos);
|
||||
// Null for the type: a header cell that is centred is still a header cell, and setNodeMarkup
|
||||
// is the only way to change an attribute and keep both the type and the content.
|
||||
if (cell && cell.attrs.align !== alignment[column]) {
|
||||
tr.setNodeMarkup(tablePos + 1 + pos, null, { ...cell.attrs, align: alignment[column] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row zero holding header cells and every other row holding body cells, whatever the edit left.
|
||||
*
|
||||
* GFM has one shape for a table: the first row is the header and the delimiter row under it is what
|
||||
* makes the block a table at all. serialize.ts writes the first row as the header whichever kind of
|
||||
* cell it is holding, so a table that says otherwise on screen is a table that comes back different
|
||||
* the next time the file is opened. prosemirror-tables copies the type of the cell it is building
|
||||
* beside, which is how a row added above the header, or a column added in front of it, leaves body
|
||||
* cells in row zero.
|
||||
*/
|
||||
function normaliseHeaderRow(tr: Transaction, tablePos: number): void {
|
||||
const table = tr.doc.nodeAt(tablePos);
|
||||
if (!table || table.type.name !== "table") return;
|
||||
const map = TableMap.get(table);
|
||||
const types = table.type.schema.nodes;
|
||||
const done = new Set<number>();
|
||||
|
||||
for (let row = 0; row < map.height; row += 1) {
|
||||
const want = row === 0 ? types.tableHeader : types.tableCell;
|
||||
for (let column = 0; column < map.width; column += 1) {
|
||||
const pos = map.map[row * map.width + column];
|
||||
if (done.has(pos)) continue;
|
||||
done.add(pos);
|
||||
const cell = table.nodeAt(pos);
|
||||
// Both cell types hold inline content, so this changes the type and keeps the text and the
|
||||
// alignment that were in it.
|
||||
if (cell && cell.type !== want) tr.setNodeMarkup(tablePos + 1 + pos, want, cell.attrs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab out of the last cell should land in the row it has just made, not stay where it was. */
|
||||
function cursorIntoLastRow(tr: Transaction, tablePos: number): void {
|
||||
const table = tr.doc.nodeAt(tablePos);
|
||||
if (!table) return;
|
||||
const map = TableMap.get(table);
|
||||
const cell = tablePos + 1 + map.positionAt(map.height - 1, 0, table);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(cell + 1))).scrollIntoView();
|
||||
}
|
||||
|
||||
/**
|
||||
* A row added where `at` says, with the alignments the table already had put back over it.
|
||||
*
|
||||
* One transaction rather than a command each, so that Tab out of the last cell is one Cmd+Z rather
|
||||
* than two, and so that the document is never momentarily a table whose column disagrees with its
|
||||
* own delimiter row.
|
||||
*/
|
||||
function insertRow(
|
||||
at: (rect: TableRect) => number,
|
||||
then?: (tr: Transaction, tablePos: number) => void,
|
||||
): Command {
|
||||
return (state, dispatch) => {
|
||||
if (!isInTable(state)) return false;
|
||||
if (dispatch) {
|
||||
const rect = selectedRect(state);
|
||||
const alignment = columnAlignments(rect.table);
|
||||
const tr = addRow(state.tr, rect, at(rect));
|
||||
// tableStart is the position just inside the table, so one before it is the table itself,
|
||||
// and every row went in after that point rather than before it.
|
||||
const tablePos = rect.tableStart - 1;
|
||||
restoreAlignments(tr, tablePos, alignment);
|
||||
normaliseHeaderRow(tr, tablePos);
|
||||
if (then) then(tr, tablePos);
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
const addRowAbove = insertRow((rect) => rect.top);
|
||||
const addRowBelow = insertRow((rect) => rect.bottom);
|
||||
const addRowAtEnd = insertRow((rect) => rect.map.height, cursorIntoLastRow);
|
||||
|
||||
/** Tab: the next cell along, or the row that has to be made first when there is no next cell. */
|
||||
const nextCellOrNewRow: Command = (state, dispatch) =>
|
||||
goToNextCell(1)(state, dispatch) || addRowAtEnd(state, dispatch);
|
||||
|
||||
/**
|
||||
* One of prosemirror-tables' own structural commands, with the header row put back over whatever it
|
||||
* produced, in the transaction the command built rather than a second one behind it.
|
||||
*
|
||||
* The library is asked with a dispatch that only catches the transaction, so a command that answers
|
||||
* false still leaves nothing behind, and a table this edit removed outright is a table
|
||||
* `normaliseHeaderRow` declines to find.
|
||||
*/
|
||||
function normalising(command: Command): Command {
|
||||
return (state, dispatch, view) => {
|
||||
if (!isInTable(state)) return false;
|
||||
if (!dispatch) return command(state, undefined, view);
|
||||
|
||||
const tablePos = selectedRect(state).tableStart - 1;
|
||||
let caught: Transaction | null = null;
|
||||
const acted = command(
|
||||
state,
|
||||
(tr) => {
|
||||
caught = tr;
|
||||
},
|
||||
view,
|
||||
);
|
||||
if (!acted || caught === null) return acted;
|
||||
|
||||
normaliseHeaderRow(caught, tablePos);
|
||||
dispatch(caught);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole column the selection covers, header cell included.
|
||||
*
|
||||
* Setting only the cell under the cursor would show an alignment on screen that the next save
|
||||
* silently takes back off, and leaving the header out would lose the alignment outright, since the
|
||||
* first row is the one the delimiter row is written from.
|
||||
*/
|
||||
function alignColumn(align: ColumnAlign): Command {
|
||||
return (state, dispatch) => {
|
||||
if (!isInTable(state)) return false;
|
||||
const { left, right, map, table, tableStart } = selectedRect(state);
|
||||
|
||||
// Positions relative to the table, and a set because a cell that spans columns appears in the
|
||||
// map once per column it covers.
|
||||
const cells = new Set<number>();
|
||||
for (let row = 0; row < map.height; row += 1) {
|
||||
for (let column = left; column < right; column += 1) {
|
||||
const pos = map.map[row * map.width + column];
|
||||
if (table.nodeAt(pos)?.attrs.align !== align) cells.add(pos);
|
||||
}
|
||||
}
|
||||
if (cells.size === 0) return false;
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
for (const pos of cells) {
|
||||
const cell = table.nodeAt(pos);
|
||||
if (cell) tr.setNodeMarkup(tableStart + pos, null, { ...cell.attrs, align });
|
||||
}
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The command, with the key claimed for as long as the cursor is in a table, whether or not the
|
||||
* command found anything to do with it.
|
||||
*
|
||||
* A binding that answers false hands the key on to whatever is bound behind it, and behind this
|
||||
* lane's Tab and Shift-Tab is shortcuts.ts's list pair, which reshapes the list around the table
|
||||
* rather than anything inside it. A table indented under a bullet is an ordinary thing to write,
|
||||
* and Shift-Tab in its first cell has nowhere to go: the answer to that is the cursor staying where
|
||||
* it is, not the item being lifted out and the list dissolved by a key pressed to move back a cell.
|
||||
*/
|
||||
function claimedInTable(command: Command): Command {
|
||||
return (state, dispatch, view) => {
|
||||
if (!isInTable(state)) return false;
|
||||
command(state, dispatch, view);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every op the handle can name, as the ProseMirror command that performs it.
|
||||
*
|
||||
* There is no header row op. GFM writes the first row of a table as its header and has no spelling
|
||||
* for a table without one or for a second one, so both directions of a toggle are an edit the
|
||||
* serializer cannot carry and the next open of the file does not show. An op the file cannot hold
|
||||
* is an op that is not offered.
|
||||
*/
|
||||
const TABLE_OPS: { [op in TableOp]: Command } = {
|
||||
addRowBefore: addRowAbove,
|
||||
addRowAfter: addRowBelow,
|
||||
deleteRow: normalising(deleteRow),
|
||||
addColumnBefore: normalising(addColumnBefore),
|
||||
addColumnAfter: normalising(addColumnAfter),
|
||||
deleteColumn: normalising(deleteColumn),
|
||||
deleteTable,
|
||||
alignLeft: alignColumn("left"),
|
||||
alignCenter: alignColumn("center"),
|
||||
alignRight: alignColumn("right"),
|
||||
alignClear: alignColumn(null),
|
||||
};
|
||||
|
||||
/**
|
||||
* A printable character typed over a rectangle of dragged cells, which does nothing.
|
||||
*
|
||||
* ProseMirror offers a character to `handleTextInput` whenever the selection is not an ordinary one
|
||||
* inside a single textblock, and when nobody claims it the character goes in through
|
||||
* `tr.insertText`, which is `Selection.replace`. A cell selection replaces every range it holds:
|
||||
* the character lands in the LAST cell of the rectangle and the other cells are emptied. Measured,
|
||||
* in a browser, on the table this lane was reported against: a drag across a 2x2 body and the three
|
||||
* keystrokes "zqx" took "| 1 | 2 |\n| 3 | 4 |" to four empty cells with "zqx" sitting in the last
|
||||
* of them. Four cells of somebody's table for three characters, and none of them the cell the drag
|
||||
* started in.
|
||||
*
|
||||
* That is the destruction src/editor/paste.ts refuses for a Cmd+V arriving at the same selection,
|
||||
* and it arrived here by the one route with no guard on it at all.
|
||||
*
|
||||
* Emptying the cells is what Backspace over a rectangle does, in the keymap at the end of this
|
||||
* file, and that is right: delete is the verb that was pressed and the rows and the columns survive
|
||||
* it. A letter is not that verb. A rectangle is a selection of whole cells rather than of text, so
|
||||
* there is no text for a character to replace and no one cell it belongs in: putting it in the
|
||||
* first cell or the last one both throw away cells the user never aimed at, and neither is what
|
||||
* they asked for. So nothing happens, the key is claimed so that nothing else does it either, and
|
||||
* the rectangle stays selected, which leaves Backspace, the toolbar and a click into one cell all
|
||||
* exactly where they were.
|
||||
*/
|
||||
const typing = new Plugin({
|
||||
key: typingKey,
|
||||
props: {
|
||||
handleTextInput: (view) => overCells(view.state),
|
||||
},
|
||||
});
|
||||
|
||||
export const Tables = Extension.create({
|
||||
name: "tables",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
// There was a third plugin in front of these two once, and it answered one paste: an empty
|
||||
// slice over a rectangle of cells, which is what a clipboard holding only an image looks like
|
||||
// by the time it reaches a handler. `tableEditing` takes that empty slice and empties every
|
||||
// cell in the rectangle, so a PNG pasted over a dragged table deleted the table's text.
|
||||
//
|
||||
// It was here because src/editor/paste.ts already refused exactly that and was never asked:
|
||||
// these plugins came ninth in the list and that one came fifteenth. The guard sitting in front
|
||||
// of the plugin it guards was the right instinct and the wrong fix, because it only covered
|
||||
// the empty slice, and the same ordering handed the library every non-empty paste over a cell
|
||||
// selection too. One word pasted over four dragged cells replaced all four.
|
||||
//
|
||||
// So the paste ordering is fixed instead: paste.ts asks for the highest priority in the editor,
|
||||
// is asked first for every paste, and stands aside only for cells pasted into a table, which is
|
||||
// the one paste those two are better at. `typing` below is not a second copy of that question,
|
||||
// it is a different event: nothing in this editor claimed a typed character, and a typed
|
||||
// character over a rectangle is the same destruction arriving by the one route nobody guarded.
|
||||
return [
|
||||
typing,
|
||||
// columnResizing before tableEditing, which takes mousedown for the cell selection drag: a
|
||||
// press on a column edge is a resize, and the plugin that decides that has to be asked first.
|
||||
columnResizing(),
|
||||
tableEditing(),
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const editor = this.editor;
|
||||
|
||||
// ProseMirror's own calling convention rather than editor.commands.command, which dispatches
|
||||
// its transaction whatever the command answered. These four are asked on every Tab and every
|
||||
// Backspace in the document, and a key pressed outside a table has to leave nothing behind.
|
||||
const run = (command: Command) => () =>
|
||||
command(editor.state, editor.view.dispatch, editor.view);
|
||||
|
||||
return {
|
||||
// Tab out of the last cell grows the table, which is the only way to add a row without
|
||||
// reaching for the toolbar. Shift-Tab has no matching gesture: there is no row before the
|
||||
// first one to make, so in the first cell it moves nothing and answers for the key anyway.
|
||||
// Both are claimed the same way so that neither can be handed on to a list command; see
|
||||
// claimedInTable above for what that costs the document when it is.
|
||||
Tab: run(claimedInTable(nextCellOrNewRow)),
|
||||
"Shift-Tab": run(claimedInTable(goToNextCell(-1))),
|
||||
|
||||
// Said here rather than left to tableEditing's own binding further down the plugin list.
|
||||
// A cell selection has to be emptied and not removed: deleting it as a selection would take
|
||||
// the rows and columns the cells were in along with the text that was in them.
|
||||
//
|
||||
// Not claimed the way the two above are: with a plain cursor in a cell there is nothing to
|
||||
// empty, and a Backspace that stopped here would be a Backspace that never deletes a
|
||||
// character. What is behind these is StarterKit's list keymap, which reads the cursor's
|
||||
// parent as its list item and finds a table row instead, so it declines from inside a cell
|
||||
// and the key reaches the editing it was pressed for. src/editor/blocks/tables.test.ts holds
|
||||
// that assertion, since it is the library's behaviour rather than this file's.
|
||||
Backspace: run(deleteCellSelection),
|
||||
Delete: run(deleteCellSelection),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** False when the cursor is not in a table, or the op has nothing to act on where it is. */
|
||||
export function tableCommand(editor: Editor, op: TableOp): boolean {
|
||||
const command = TABLE_OPS[op];
|
||||
// Read out of the chain rather than off the chain's own result, because focus is in the chain too
|
||||
// and answers a different question, with a false of its own whenever there is no view to focus.
|
||||
let acted = false;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ state, dispatch }) => {
|
||||
acted = command(state, dispatch);
|
||||
return acted;
|
||||
})
|
||||
.run();
|
||||
return acted;
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
// What can be asserted about a toggle without a browser, which is the half that costs somebody a
|
||||
// file.
|
||||
//
|
||||
// vite.config.ts runs vitest in the node environment, so there is no page here and nothing a
|
||||
// browser does with one is on trial: the arrow's drawing, the caret's travel through a title and
|
||||
// the placeholder on an untitled one belong to the Playwright suite. What belongs here is
|
||||
// everything that reaches disk. The two attributes a toggle carries are the two things this lane
|
||||
// writes, and both of them go out through an html block that the bridge will only read back if it
|
||||
// is spelled one exact way, so a title the editor mangles on the way in is a `<details>` that opens
|
||||
// as a raw block the next time the file is looked at.
|
||||
//
|
||||
// The node view is built here all the same, against the page written out at the bottom of this
|
||||
// file, because two of the things it decides reach disk as surely as the attributes do: which
|
||||
// clicks on the summary write `open`, and which commands are allowed to run while the caret is
|
||||
// somewhere the document's selection is not. Both were bugs a green suite did not see, and neither
|
||||
// is a question about a browser. They are questions about what these handlers do with what a
|
||||
// browser sends them.
|
||||
//
|
||||
// The entity group is the one to keep. A summary holding `&`, `<` or `>` is escaped by the
|
||||
// serializer and unescaped by the parser, and the parser refuses to model any toggle those two do
|
||||
// not agree about character for character. An extra round of escaping would not throw, would not
|
||||
// fail a type check and would not lose the document: it would grow another `amp;` in somebody's
|
||||
// heading on every save.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
import type { Plugin, Transaction } from "@tiptap/pm/state";
|
||||
import { DecorationSet } from "@tiptap/pm/view";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { createEditorExtensions } from "../extensions";
|
||||
import { parseMarkdown, serializeMarkdown } from "../../markdown";
|
||||
import { Toggles, setToggleOpen, setToggleSummary } from "./toggle";
|
||||
|
||||
const PATH = "/notes/writing.md";
|
||||
|
||||
const extensions = () => createEditorExtensions({ documentPath: () => PATH, onError: () => {} });
|
||||
|
||||
const EMPTY: JSONContent = { type: "doc", content: [{ type: "paragraph" }] };
|
||||
|
||||
/**
|
||||
* An editor with the lanes' plugins in its state.
|
||||
*
|
||||
* TipTap only installs them when it mounts a view and there is no DOM here to mount into, so the
|
||||
* state is rebuilt with them the way src/editor/Editor.tsx installs every document it opens. It
|
||||
* matters more here than it does for a keymap: this lane's plugin also appends a transaction, and a
|
||||
* plugin that is not in the state is never asked to.
|
||||
*/
|
||||
function makeEditor(content: JSONContent = EMPTY): Editor {
|
||||
const editor = new Editor({ element: null, injectCSS: false, extensions: extensions(), content });
|
||||
editor.view.updateState(
|
||||
EditorState.create({ doc: editor.state.doc, plugins: editor.extensionManager.plugins }),
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
function editorFor(source: string): Editor {
|
||||
return makeEditor(parseMarkdown(source, PATH).doc.toJSON());
|
||||
}
|
||||
|
||||
/** What the bridge would write for the document as it stands now. */
|
||||
function written(source: string, editor: Editor): string {
|
||||
return serializeMarkdown(parseMarkdown(source, PATH), editor.state.doc);
|
||||
}
|
||||
|
||||
/** Where the first toggle in the document is, and what it holds. */
|
||||
function toggleIn(editor: Editor): { pos: number; open: boolean; summary: string; body: string } {
|
||||
let found: { pos: number; open: boolean; summary: string; body: string } | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (found || node.type.name !== "toggle") return !found;
|
||||
found = {
|
||||
pos,
|
||||
open: node.attrs.open as boolean,
|
||||
summary: node.attrs.summary as string,
|
||||
body: node.textContent,
|
||||
};
|
||||
return false;
|
||||
});
|
||||
if (!found) throw new Error("no toggle in the document");
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The plugins offering a node view for the toggle node, found the way the view finds them. */
|
||||
function nodeViewPlugins(editor: Editor): Plugin[] {
|
||||
return editor.extensionManager.plugins.filter(
|
||||
(plugin) => plugin.props.nodeViews?.toggle !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY = "Tom & Jerry <3>";
|
||||
const PLAIN = "Tom & Jerry <3>";
|
||||
|
||||
const DOC = [
|
||||
"# Writing",
|
||||
"",
|
||||
"<details>",
|
||||
`<summary>${SUMMARY}</summary>`,
|
||||
"",
|
||||
"No em dashes.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"After.",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("the toggle extension", () => {
|
||||
it("is the one the registry names", () => {
|
||||
expect(Toggles.name).toBe("toggles");
|
||||
});
|
||||
|
||||
it("adds no node and no mark, so the bridge and the editor still agree", () => {
|
||||
const plain = new Editor({
|
||||
element: null,
|
||||
injectCSS: false,
|
||||
extensions: extensions().filter((extension) => extension.name !== "toggles"),
|
||||
content: EMPTY,
|
||||
});
|
||||
const withToggles = makeEditor();
|
||||
|
||||
expect(Object.keys(withToggles.schema.nodes)).toEqual(Object.keys(plain.schema.nodes));
|
||||
expect(Object.keys(withToggles.schema.marks)).toEqual(Object.keys(plain.schema.marks));
|
||||
|
||||
plain.destroy();
|
||||
withToggles.destroy();
|
||||
});
|
||||
|
||||
// The node view is the whole feature: without one the browser's own disclosure takes the click,
|
||||
// the open attribute never moves and a keystroke aimed at the title lands in the body. Two
|
||||
// plugins claiming the node would be the same bug from the other end, since ProseMirror takes the
|
||||
// first one asked and the other never runs.
|
||||
it("is the only plugin in the build that claims the toggle node view", () => {
|
||||
const editor = makeEditor();
|
||||
|
||||
expect(nodeViewPlugins(editor)).toHaveLength(1);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a <details> read off disk", () => {
|
||||
it("arrives as a toggle carrying its summary as text, not as entities", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const toggle = toggleIn(editor);
|
||||
|
||||
expect(toggle.summary).toBe(PLAIN);
|
||||
expect(toggle.open).toBe(false);
|
||||
expect(toggle.body).toBe("No em dashes.");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("is written back byte for byte when nothing was edited", () => {
|
||||
const editor = editorFor(DOC);
|
||||
|
||||
expect(written(DOC, editor)).toBe(DOC);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("keeps the whole file byte identical when only the summary is edited", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos } = toggleIn(editor);
|
||||
|
||||
expect(setToggleSummary(pos, "Tom & Jerry <4>")(editor.state, editor.view.dispatch)).toBe(true);
|
||||
expect(written(DOC, editor)).toBe(DOC.replace("<3>", "<4>"));
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The failure this is shaped to catch is invisible: an editor that put the escaped form on the
|
||||
// node would write `&amp;` here, the file would still parse, and the title would grow a word
|
||||
// every time the document was saved.
|
||||
it("does not escape the ampersand it already escaped once", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos } = toggleIn(editor);
|
||||
|
||||
// The one keystroke: a character on the end of a title that already holds all three of the
|
||||
// characters the serializer has to spell as entities.
|
||||
setToggleSummary(pos, `${PLAIN}!`)(editor.state, editor.view.dispatch);
|
||||
const once = written(DOC, editor);
|
||||
expect(once).toBe(DOC.replace(SUMMARY, `${SUMMARY}!`));
|
||||
|
||||
// And back through the bridge, which is where a second round of escaping would show up.
|
||||
const again = makeEditor(parseMarkdown(once, PATH).doc.toJSON());
|
||||
expect(toggleIn(again).summary).toBe(`${PLAIN}!`);
|
||||
expect(written(once, again)).toBe(once);
|
||||
again.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("writes the open marker on to the tag, and takes it off again", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos } = toggleIn(editor);
|
||||
|
||||
expect(setToggleOpen(pos, true)(editor.state, editor.view.dispatch)).toBe(true);
|
||||
expect(written(DOC, editor)).toBe(DOC.replace("<details>", "<details open>"));
|
||||
|
||||
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(true);
|
||||
expect(written(DOC, editor)).toBe(DOC);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves the body alone whatever happens to the two attributes", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos, body } = toggleIn(editor);
|
||||
|
||||
setToggleSummary(pos, "Something else")(editor.state, editor.view.dispatch);
|
||||
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
|
||||
|
||||
expect(toggleIn(editor).body).toBe(body);
|
||||
expect(editor.state.doc.textContent).toBe(editorFor(DOC).state.doc.textContent);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two commands", () => {
|
||||
it("decline a position that is not a toggle, and one that already reads that way", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos, summary } = toggleIn(editor);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
expect(setToggleOpen(0, true)(editor.state, editor.view.dispatch)).toBe(false);
|
||||
expect(setToggleSummary(0, "x")(editor.state, editor.view.dispatch)).toBe(false);
|
||||
expect(setToggleOpen(pos, false)(editor.state, editor.view.dispatch)).toBe(false);
|
||||
expect(setToggleSummary(pos, summary)(editor.state, editor.view.dispatch)).toBe(false);
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// A closed <details> does not draw its children, so a caret left in one is a caret nobody can see
|
||||
// and the next keystroke goes somewhere invisible.
|
||||
it("bring the caret out of a body that is being closed", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos } = toggleIn(editor);
|
||||
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
|
||||
|
||||
const inside = pos + 2;
|
||||
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, inside)));
|
||||
expect(editor.state.selection.from).toBe(inside);
|
||||
|
||||
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
|
||||
const node = editor.state.doc.nodeAt(pos)!;
|
||||
expect(editor.state.selection.from >= pos + node.nodeSize).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leave the caret where it is when it was never inside", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos } = toggleIn(editor);
|
||||
setToggleOpen(pos, true)(editor.state, editor.view.dispatch);
|
||||
editor.commands.setTextSelection(2);
|
||||
|
||||
setToggleOpen(pos, false)(editor.state, editor.view.dispatch);
|
||||
expect(editor.state.selection.from).toBe(2);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// What the pill's Toggle button runs, which is `toggleWrap("toggle")` in setBlock in
|
||||
// src/editor/Editor.tsx. A toggle takes the attribute's own default, which is closed, so without
|
||||
// the plugin's appended transaction the paragraph the user just wrapped is behind an arrow and
|
||||
// reads as a deletion.
|
||||
describe("wrapping a block in a toggle", () => {
|
||||
const PROSE: JSONContent = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Keep this visible." }] }],
|
||||
};
|
||||
|
||||
it("leaves it open, with the block still on screen inside it", () => {
|
||||
const editor = makeEditor(PROSE);
|
||||
editor.commands.setTextSelection(2);
|
||||
|
||||
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
|
||||
const toggle = toggleIn(editor);
|
||||
expect(toggle.open).toBe(true);
|
||||
expect(toggle.summary).toBe("");
|
||||
expect(toggle.body).toBe("Keep this visible.");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("makes something the bridge writes and reads back as the same toggle", () => {
|
||||
const editor = makeEditor(PROSE);
|
||||
editor.commands.setTextSelection(2);
|
||||
editor.chain().toggleWrap("toggle").run();
|
||||
setToggleSummary(toggleIn(editor).pos, "House style")(editor.state, editor.view.dispatch);
|
||||
|
||||
const source = written("Keep this visible.\n", editor);
|
||||
expect(source).toBe(
|
||||
["<details open>", "<summary>House style</summary>", "", "Keep this visible.", "", "</details>", ""].join("\n"),
|
||||
);
|
||||
|
||||
const reopened = makeEditor(parseMarkdown(source, PATH).doc.toJSON());
|
||||
const toggle = toggleIn(reopened);
|
||||
expect([toggle.open, toggle.summary, toggle.body]).toEqual([true, "House style", "Keep this visible."]);
|
||||
reopened.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("takes it back out on a second press, which is what the same button means", () => {
|
||||
const editor = makeEditor(PROSE);
|
||||
editor.commands.setTextSelection(2);
|
||||
editor.chain().toggleWrap("toggle").run();
|
||||
|
||||
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
|
||||
expect(editor.state.doc.toJSON()).toEqual(PROSE);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// The guard on the appended transaction, and the reason it is written the way it is. Opening a
|
||||
// document restores the caret it was last left at, and that is a selection with no edit behind it:
|
||||
// a toggle opened by one would be a byte written into a file nobody has touched.
|
||||
describe("a caret that moves without an edit", () => {
|
||||
it("never opens the toggle it lands in, and never dirties the document", () => {
|
||||
const editor = editorFor(DOC);
|
||||
const { pos } = toggleIn(editor);
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 2)),
|
||||
);
|
||||
|
||||
expect(toggleIn(editor).open).toBe(false);
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
expect(written(DOC, editor)).toBe(DOC);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The few parts of a page the node view reaches for, written out rather than depended on.
|
||||
*
|
||||
* A DOM implementation is not a dependency this project has, and the handful of calls the node view
|
||||
* makes into one does not earn it: it creates elements, hangs listeners on them, asks which one the
|
||||
* page thinks has the caret, and asks for a frame. What a browser does with an element is
|
||||
* Playwright's question. What the handlers in toggle.ts do with what a browser sends them is this
|
||||
* file's, and that is the whole of what these model.
|
||||
*/
|
||||
interface PageEvent {
|
||||
type: string;
|
||||
target: PageElement;
|
||||
key?: string;
|
||||
isComposing?: boolean;
|
||||
defaultPrevented: boolean;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
class PageElement {
|
||||
className = "";
|
||||
contentEditable = "inherit";
|
||||
textContent = "";
|
||||
parent: PageElement | null = null;
|
||||
|
||||
private readonly attributes = new Set<string>();
|
||||
private readonly listeners = new Map<string, ((event: PageEvent) => void)[]>();
|
||||
|
||||
constructor(
|
||||
readonly tagName: string,
|
||||
readonly ownerDocument: Page,
|
||||
) {}
|
||||
|
||||
/** Only ever asked whether there is one, which is how a leftover <br> in a title is found. */
|
||||
get firstChild(): object | null {
|
||||
return this.textContent === "" ? null : { nodeName: "#text" };
|
||||
}
|
||||
|
||||
setAttribute(name: string, _value: string): void {
|
||||
this.attributes.add(name);
|
||||
}
|
||||
|
||||
hasAttribute(name: string): boolean {
|
||||
return this.attributes.has(name);
|
||||
}
|
||||
|
||||
toggleAttribute(name: string, on: boolean): void {
|
||||
if (on) this.attributes.add(name);
|
||||
else this.attributes.delete(name);
|
||||
}
|
||||
|
||||
appendChild(child: PageElement): void {
|
||||
child.parent = this;
|
||||
}
|
||||
|
||||
append(...children: PageElement[]): void {
|
||||
for (const child of children) this.appendChild(child);
|
||||
}
|
||||
|
||||
contains(other: PageElement | null): boolean {
|
||||
for (let element = other; element; element = element.parent) if (element === this) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.ownerDocument.activeElement = this;
|
||||
this.fire("focus");
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
if (this.ownerDocument.activeElement === this) this.ownerDocument.activeElement = null;
|
||||
this.fire("blur");
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: PageEvent) => void): void {
|
||||
const list = this.listeners.get(type) ?? [];
|
||||
list.push(listener);
|
||||
this.listeners.set(type, list);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: (event: PageEvent) => void): void {
|
||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((one) => one !== listener));
|
||||
}
|
||||
|
||||
/** Bubbling, because the row's listeners are what an event on the title inside it reaches. */
|
||||
fire(type: string, extra: Partial<PageEvent> = {}): PageEvent {
|
||||
const event: PageEvent = {
|
||||
type,
|
||||
target: this,
|
||||
defaultPrevented: false,
|
||||
preventDefault: () => {
|
||||
event.defaultPrevented = true;
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
for (let element: PageElement | null = this; element; element = element.parent) {
|
||||
for (const listener of element.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
class Page {
|
||||
activeElement: PageElement | null = null;
|
||||
readonly created: PageElement[] = [];
|
||||
|
||||
private readonly frames: (() => void)[] = [];
|
||||
|
||||
readonly defaultView = {
|
||||
requestAnimationFrame: (run: () => void): number => this.frames.push(run),
|
||||
};
|
||||
|
||||
createElement(tagName: string): PageElement {
|
||||
const element = new PageElement(tagName, this);
|
||||
this.created.push(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Nothing here models a caret inside a title, only which element holds one. */
|
||||
getSelection(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The frame a browser would run next, which is also where TipTap's own focus call lands. */
|
||||
runFrames(): number {
|
||||
const queued = this.frames.splice(0);
|
||||
for (const run of queued) run();
|
||||
return queued.length;
|
||||
}
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
page: Page;
|
||||
details: PageElement;
|
||||
summary: PageElement;
|
||||
title: PageElement;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** The node view for the toggle at `pos`, built the way the editor's view builds one. */
|
||||
function mount(editor: Editor, pos: number): Mounted {
|
||||
const page = new Page();
|
||||
const view = {
|
||||
dom: page.createElement("div"),
|
||||
get state(): EditorState {
|
||||
return editor.state;
|
||||
},
|
||||
dispatch: (tr: Transaction) => editor.view.dispatch(tr),
|
||||
editable: true,
|
||||
focus: () => {},
|
||||
} as unknown as EditorView;
|
||||
|
||||
const build = nodeViewPlugins(editor)[0]?.props.nodeViews?.toggle;
|
||||
const node = editor.state.doc.nodeAt(pos);
|
||||
if (!build || !node) throw new Error("nothing to build a node view from");
|
||||
const nodeView = build(node, view, () => pos, [], DecorationSet.empty);
|
||||
|
||||
const find = (match: (element: PageElement) => boolean): PageElement => {
|
||||
const element = page.created.find(match);
|
||||
if (!element) throw new Error("the node view did not build that element");
|
||||
return element;
|
||||
};
|
||||
|
||||
return {
|
||||
page,
|
||||
details: find((element) => element.tagName === "details"),
|
||||
summary: find((element) => element.tagName === "summary"),
|
||||
title: find((element) => element.hasAttribute("data-toggle-summary")),
|
||||
destroy: () => nodeView.destroy?.(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the text of this block ends, which is where a click in it leaves the caret. */
|
||||
function endOf(editor: Editor, text: string): number {
|
||||
let found: number | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (found !== null) return false;
|
||||
if (node.isTextblock && node.textContent === text) found = pos + 1 + node.content.size;
|
||||
return found === null;
|
||||
});
|
||||
if (found === null) throw new Error(`no block reading ${text}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const PAGE_DOC = [
|
||||
"# Doc",
|
||||
"",
|
||||
"First paragraph.",
|
||||
"",
|
||||
"Second paragraph.",
|
||||
"",
|
||||
"<details open>",
|
||||
"<summary>My title</summary>",
|
||||
"",
|
||||
"Toggle body.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
// A `<details>` is a disclosure control and the browser works one from the keyboard whether this
|
||||
// file wants it to or not: a space typed anywhere inside the summary sends the row a click of its
|
||||
// own. Taken as a press, that closed the toggle under the caret on every other space of a title and
|
||||
// wrote the flip to the file each time.
|
||||
describe("a space typed in a title", () => {
|
||||
it("leaves the toggle as it was, and the space in the title", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
|
||||
ui.title.focus();
|
||||
ui.title.fire("keydown", { key: " " });
|
||||
// The character the browser puts in the element, and then the click it sends the row after it.
|
||||
ui.title.textContent = "My title ";
|
||||
ui.title.fire("input");
|
||||
ui.summary.fire("click");
|
||||
|
||||
const toggle = toggleIn(editor);
|
||||
expect(toggle.open).toBe(true);
|
||||
expect(toggle.summary).toBe("My title ");
|
||||
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "My title "));
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("does not find a press that never became a click waiting for it", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
|
||||
// Pressed on the row, and then the pointer left and no click ever came of it.
|
||||
ui.summary.fire("mousedown");
|
||||
ui.title.focus();
|
||||
ui.title.fire("keydown", { key: " " });
|
||||
ui.summary.fire("click");
|
||||
|
||||
expect(toggleIn(editor).open).toBe(true);
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a press on the summary row", () => {
|
||||
it("still flips the toggle and writes it, which is what the arrow is for", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
|
||||
ui.summary.fire("mousedown");
|
||||
ui.summary.fire("click");
|
||||
|
||||
expect(toggleIn(editor).open).toBe(false);
|
||||
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("<details open>", "<details>"));
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("flips it from the keyboard when the row itself is what has focus", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
|
||||
ui.page.activeElement = ui.summary;
|
||||
ui.summary.fire("click");
|
||||
|
||||
expect(toggleIn(editor).open).toBe(false);
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// The caret in a title is not in the document: ProseMirror's selection is still wherever it was
|
||||
// when the caret went in there, so a toolbar button pressed while a title is being typed runs its
|
||||
// command against a paragraph the user is not looking at.
|
||||
describe("a command aimed at the document while the caret is in a title", () => {
|
||||
it("changes nothing, and the file with it", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
|
||||
ui.title.focus();
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
// What the pill's Horizontal rule runs, less the focus() in front of it, which wants a browser.
|
||||
editor.commands.insertContent({ type: "horizontalRule" });
|
||||
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves the caret in the title, so a second press is refused like the first", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
|
||||
ui.title.focus();
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
editor.commands.insertContent({ type: "horizontalRule" });
|
||||
// TipTap's focus command queues a view.focus() for the next frame, and it lands behind the
|
||||
// refusal: without the caret being put back, the second press of the same button has a caret in
|
||||
// the document again and edits the place the first press was refused for.
|
||||
ui.title.blur();
|
||||
expect(ui.page.runFrames()).toBe(1);
|
||||
expect(ui.page.activeElement).toBe(ui.title);
|
||||
|
||||
editor.commands.insertContent({ type: "horizontalRule" });
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("is taken back the moment the caret leaves the title", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
editor.commands.setTextSelection(endOf(editor, "Second paragraph."));
|
||||
ui.title.focus();
|
||||
ui.title.blur();
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
editor.commands.insertContent({ type: "horizontalRule" });
|
||||
|
||||
expect(editor.state.doc.toJSON()).not.toEqual(before);
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("is never the title's own writing, which is the one edit that is where the caret is", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
const ui = mount(editor, toggleIn(editor).pos);
|
||||
ui.title.focus();
|
||||
|
||||
ui.title.textContent = "Renamed";
|
||||
ui.title.fire("input");
|
||||
|
||||
expect(toggleIn(editor).summary).toBe("Renamed");
|
||||
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("My title", "Renamed"));
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
const TWO_TOGGLES = [
|
||||
"<details>",
|
||||
"<summary>Closed</summary>",
|
||||
"",
|
||||
"Hidden body.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<details open>",
|
||||
"<summary>My title</summary>",
|
||||
"",
|
||||
"Toggle body.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
/** Every toggle in the document, in the order they are written. */
|
||||
function togglesIn(editor: Editor): { pos: number; open: boolean; summary: string }[] {
|
||||
const found: { pos: number; open: boolean; summary: string }[] = [];
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== "toggle") return true;
|
||||
found.push({ pos, open: node.attrs.open as boolean, summary: node.attrs.summary as string });
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
// The plugin opens the collapsed toggle the caret ends an edit inside, because a caret behind a
|
||||
// closed arrow is one nobody can see. The caret in a title is not in the document at all, so the
|
||||
// selection that rule reads is stale and the toggle it names is one nobody is in.
|
||||
describe("typing in a title while the selection is left inside another toggle", () => {
|
||||
it("does not open the toggle it is left in, and writes no byte into that one", () => {
|
||||
const editor = editorFor(TWO_TOGGLES);
|
||||
const [closed, titled] = togglesIn(editor);
|
||||
const ui = mount(editor, titled.pos);
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, closed.pos + 3)),
|
||||
);
|
||||
ui.title.focus();
|
||||
|
||||
ui.title.textContent = "Renamed";
|
||||
ui.title.fire("input");
|
||||
|
||||
expect(togglesIn(editor)[0].open).toBe(false);
|
||||
expect(written(TWO_TOGGLES, editor)).toBe(TWO_TOGGLES.replace("My title", "Renamed"));
|
||||
ui.destroy();
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// src/markdown/parse.ts pairs `<details>` among the root's own children and nowhere else, so a
|
||||
// toggle anywhere but the top level of the document goes to disk as something the next open of the
|
||||
// file reads back as one raw block: the bytes are kept and both constructs stop being editable.
|
||||
describe("a toggle the bridge could not read back", () => {
|
||||
const CALLOUT = "> [!NOTE]\n> Callout body.\n";
|
||||
|
||||
it("is not made inside a callout", () => {
|
||||
const editor = editorFor(CALLOUT);
|
||||
editor.commands.setTextSelection(endOf(editor, "Callout body."));
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
editor.chain().toggleWrap("toggle").run();
|
||||
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
expect(written(CALLOUT, editor)).toBe(CALLOUT);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("is not made inside another toggle", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
|
||||
const before = editor.state.doc.toJSON();
|
||||
|
||||
// Attributes the toggle already there does not carry, because that is the call that nests:
|
||||
// TipTap's isNodeActive wants them to match before it takes a second press as taking one out,
|
||||
// so anything else wraps a second toggle around the inside of the first.
|
||||
editor.chain().toggleWrap("toggle", { open: false }).run();
|
||||
|
||||
expect(editor.state.doc.toJSON()).toEqual(before);
|
||||
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
// The rule is about a toggle being put somewhere, and an edit inside one that is already there
|
||||
// reaches into the same node without moving it. Reading that as a toggle being nested would
|
||||
// refuse every keystroke in every toggle body in the document.
|
||||
it("does not stand in the way of an edit inside a toggle that is already there", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
editor.commands.setTextSelection(endOf(editor, "Toggle body."));
|
||||
|
||||
editor.commands.insertContent({ type: "text", text: " More." });
|
||||
|
||||
expect(toggleIn(editor).body).toBe("Toggle body. More.");
|
||||
expect(written(PAGE_DOC, editor)).toBe(PAGE_DOC.replace("Toggle body.", "Toggle body. More."));
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("is still made at the top level, where it is read back as itself", () => {
|
||||
const editor = editorFor(PAGE_DOC);
|
||||
editor.commands.setTextSelection(endOf(editor, "First paragraph."));
|
||||
|
||||
expect(editor.chain().toggleWrap("toggle").run()).toBe(true);
|
||||
expect(togglesIn(editor)).toHaveLength(2);
|
||||
const source = written(PAGE_DOC, editor);
|
||||
expect(parseMarkdown(source, PATH).doc.childCount).toBe(editor.state.doc.childCount);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
// Toggles: the `<details>` the bridge reads off disk, made into something that can be opened,
|
||||
// closed and named.
|
||||
//
|
||||
// The schema keeps a toggle's summary and its open state as attributes rather than as child nodes,
|
||||
// because a `<summary>` carrying markup is not modellable and a toggle whose body is content while
|
||||
// its title is not would be half a node. That decision is what makes this file necessary. An
|
||||
// attribute is not editable content, so without a node view three things happen and all of them are
|
||||
// wrong: the browser's own disclosure takes the click and moves the element out of step with the
|
||||
// node behind it, the `open` the document holds never changes and so never reaches the file, and a
|
||||
// keystroke aimed at the title lands in the first paragraph of the body instead, which is somebody
|
||||
// else's sentence quietly rewritten.
|
||||
//
|
||||
// So the element built below is the same `<details>` the schema's toDOM describes, and everything
|
||||
// in it that is not the body is this file's own. ProseMirror is told as much through stopEvent and
|
||||
// ignoreMutation: nothing outside the body is the document's, nothing typed in the title can be
|
||||
// read back into the tree, and the two attributes move only through the two commands here.
|
||||
//
|
||||
// Native disclosure is cancelled rather than leant on. A `<details>` opens and closes itself on a
|
||||
// click anywhere in its summary, and the element doing that on its own is the element disagreeing
|
||||
// with the node, which is the half that gets saved. Cancelling the click is not the whole of it: a
|
||||
// disclosure is a control, and the browser works one from the keyboard too, so a space or an Enter
|
||||
// typed anywhere inside the summary arrives here as a click on the row with no press behind it.
|
||||
// That was every other space in a title flipping the toggle and writing `open` into the file, so
|
||||
// the flip below asks for a press, or for the row itself holding the keyboard, and takes nothing
|
||||
// else as consent.
|
||||
//
|
||||
// The caret in a title is not in the document. ProseMirror's selection stays wherever it was when
|
||||
// the caret went in there, so a toolbar button pressed while a title is being typed runs its
|
||||
// command against a place the user is not looking at, which is somebody else's paragraph edited out
|
||||
// of sight. A transaction that changes the document is therefore refused while a title holds the
|
||||
// caret, bar the two this file's own surface makes, and the caret is put back where it was: the
|
||||
// tools are inert while a title is being typed, which is what they would be if the pill drew them
|
||||
// disabled. Drawing them disabled is the half of this that belongs to src/editor/Toolbar.tsx.
|
||||
//
|
||||
// And a toggle only ever sits among the document's own children. src/markdown/parse.ts pairs
|
||||
// `<details>` at the top level of a file and nowhere else, so a toggle wrapped around a paragraph
|
||||
// inside a quote goes to disk as a `<details>` inside a `>` block and comes back as one raw block:
|
||||
// the bytes are kept, and both constructs stop being editable. An edit whose result this editor
|
||||
// could not read back is refused rather than offered.
|
||||
//
|
||||
// Nothing here escapes anything. The summary attribute holds the title as plain text and the
|
||||
// serializer turns `&`, `<` and `>` into entities on the way to disk, with the parser undoing
|
||||
// exactly that on the way back; a node view that wrote markup into the attribute, or read the
|
||||
// element back with innerHTML, would put an `&` in a title that said `&` and grow another one
|
||||
// on every save.
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
|
||||
import type { Command, EditorState, Transaction } from "@tiptap/pm/state";
|
||||
import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view";
|
||||
|
||||
const NAME = "toggle";
|
||||
|
||||
/**
|
||||
* On the transactions the title and the arrow make, which are the two edits a caret sitting outside
|
||||
* the document is allowed to produce. A key rather than a string so that nothing else can spell it
|
||||
* by accident.
|
||||
*/
|
||||
const OWN_EDIT = new PluginKey<boolean>("toggleOwnEdit");
|
||||
|
||||
/**
|
||||
* The title the caret is in, or null when it is in the document like any other.
|
||||
*
|
||||
* Module level because a transaction is filtered against a state, and a state cannot be asked where
|
||||
* the caret is when the caret is not in the document. There is one editor and one document, per
|
||||
* src/editor/index.ts, and every answer taken from this is checked against the editor it came from
|
||||
* and against the page before it is acted on.
|
||||
*/
|
||||
let focused: ToggleView | null = null;
|
||||
|
||||
/** The toggle at this position, or null when the document has something else there. */
|
||||
function toggleAt(state: EditorState, pos: number): ProseMirrorNode | null {
|
||||
const node = pos >= 0 && pos < state.doc.content.size ? state.doc.nodeAt(pos) : null;
|
||||
return node && node.type.name === NAME ? node : null;
|
||||
}
|
||||
|
||||
function summaryOf(node: ProseMirrorNode): string {
|
||||
const value = node.attrs.summary;
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens or closes the toggle at `pos`. False when there is no toggle there, or when it already
|
||||
* reads that way, which is what a second press of a control that is already in that state means.
|
||||
*
|
||||
* Closing takes the caret out of the body first. A closed `<details>` does not draw its children,
|
||||
* so a selection left in there is a caret nobody can see and every keystroke after it goes
|
||||
* somewhere invisible. It comes out into the same transaction as the close, so the two cannot be
|
||||
* undone separately. A toggle with nothing either side of it has nowhere to send it, and the node
|
||||
* view puts the focus in the title instead.
|
||||
*/
|
||||
export function setToggleOpen(pos: number, open: boolean): Command {
|
||||
return (state, dispatch) => {
|
||||
const node = toggleAt(state, pos);
|
||||
if (!node || node.attrs.open === open) return false;
|
||||
if (dispatch) {
|
||||
const end = pos + node.nodeSize;
|
||||
const tr = state.tr.setNodeMarkup(pos, null, { ...node.attrs, open });
|
||||
tr.setMeta(OWN_EDIT, true);
|
||||
if (!open && state.selection.from > pos && state.selection.from < end) {
|
||||
// findFrom rather than Selection.near, which falls back to searching the other way when it
|
||||
// finds nothing and would put the caret back inside the toggle that was just closed.
|
||||
const out =
|
||||
Selection.findFrom(tr.doc.resolve(end), 1) ?? Selection.findFrom(tr.doc.resolve(pos), -1);
|
||||
if (out) tr.setSelection(out);
|
||||
}
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the title of the toggle at `pos`, as the plain text it is on the node.
|
||||
*
|
||||
* One line, always: the parser reads the opening tag and the title as a two line html block, so a
|
||||
* newline in the middle of one is a toggle that comes back from disk as unmodellable source. The
|
||||
* node view keeps Enter and paste from putting one there rather than repairing it here, because a
|
||||
* title the user can see and the attribute cannot hold is the same disagreement one layer up.
|
||||
*/
|
||||
export function setToggleSummary(pos: number, summary: string): Command {
|
||||
return (state, dispatch) => {
|
||||
const node = toggleAt(state, pos);
|
||||
if (!node || summaryOf(node) === summary) return false;
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.setNodeMarkup(pos, null, { ...node.attrs, summary }).setMeta(OWN_EDIT, true));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this transaction puts a toggle anywhere but among the document's own children.
|
||||
*
|
||||
* Asked of the ranges the steps wrote rather than of the whole document, the way math.ts asks where
|
||||
* a formula ended up, so that the answer costs a keystroke nothing.
|
||||
*
|
||||
* Each range is then widened to the whole top level block it lands in, and that is the half this
|
||||
* needs rather than an optimisation given up. A wrap is a ReplaceAroundStep, and the only bytes it
|
||||
* rewrites are the two markers it puts either side of the content: the gap between them, which is
|
||||
* everything it moved a level deeper, is not in the step's map at all. So a toggle dragged over and
|
||||
* given to the Quote button went a level down inside a range that said nothing had happened to it,
|
||||
* and the file got a `<details>` inside a `>` block that the next open reads as one raw block.
|
||||
*
|
||||
* Widening is cheap because the widened range is the block the caret is in: a keystroke walks its
|
||||
* own paragraph. A toggle at the top level is visited at depth 0 and is the ordinary case, so
|
||||
* typing inside one costs a walk of it and nothing else.
|
||||
*/
|
||||
function nestsToggle(tr: Transaction): boolean {
|
||||
let found = false;
|
||||
|
||||
for (let step = 0; step < tr.steps.length && !found; step += 1) {
|
||||
const forward = tr.mapping.slice(step + 1);
|
||||
tr.mapping.maps[step].forEach((_from, _to, newFrom, newTo) => {
|
||||
if (found) return;
|
||||
const size = tr.doc.content.size;
|
||||
const from = Math.min(size, Math.max(0, forward.map(newFrom, -1)));
|
||||
const to = Math.min(size, Math.max(from, forward.map(newTo, 1)));
|
||||
const $from = tr.doc.resolve(from);
|
||||
const $to = tr.doc.resolve(to);
|
||||
const start = $from.depth > 0 ? $from.before(1) : from;
|
||||
const end = $to.depth > 0 ? $to.after(1) : to;
|
||||
tr.doc.nodesBetween(start, end, (node, pos) => {
|
||||
if (found) return false;
|
||||
if (node.type.name === NAME) found = tr.doc.resolve(pos).depth > 0;
|
||||
return !found;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transactions a document is allowed to take, which is all of them bar two kinds of edit that
|
||||
* would land somewhere nobody aimed at.
|
||||
*
|
||||
* The first is anything but this file's own while a title holds the caret, for the reason at the
|
||||
* top: the selection those commands read is stale by then. The second is a toggle put anywhere the
|
||||
* bridge could not read one back from.
|
||||
*
|
||||
* Both answers are checked against the page and against the editor the transaction is for, so a
|
||||
* focus event that never got its blur, or a second editor that never existed, can cost this file a
|
||||
* refusal it should have made and never one it should not have. Letting an edit through is the
|
||||
* failure that is survivable.
|
||||
*/
|
||||
function allowTransaction(tr: Transaction, state: EditorState): boolean {
|
||||
if (!tr.docChanged) return true;
|
||||
if (nestsToggle(tr)) return false;
|
||||
if (focused === null || tr.getMeta(OWN_EDIT) === true) return true;
|
||||
if (!focused.isFor(state) || !focused.holdsCaret()) return true;
|
||||
focused.reclaim();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that leaves the caret inside a collapsed toggle opens it.
|
||||
*
|
||||
* The toolbar's Toggle button is why this exists. Wrapping a block makes a toggle with the
|
||||
* attribute's own default, which is closed, so the paragraph the user just wrapped would vanish
|
||||
* behind an arrow and read as a deletion. The rule is more general than that one button though: a
|
||||
* closed toggle draws nothing of its body, and an edit that puts the caret somewhere the user
|
||||
* cannot see is an edit whose next keystroke disappears.
|
||||
*
|
||||
* Only for a transaction that changed the document, and that guard is load bearing rather than an
|
||||
* optimisation. A selection cannot walk into content the browser is not drawing on its own, and a
|
||||
* document being installed restores the caret it was last left at: a toggle opened by that would
|
||||
* be a byte written into a file nobody has edited.
|
||||
*/
|
||||
function openAroundSelection(
|
||||
transactions: readonly Transaction[],
|
||||
old: EditorState,
|
||||
state: EditorState,
|
||||
): Transaction | null {
|
||||
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||
// A title being typed into leaves the selection wherever it was, so the toggle around it is one
|
||||
// nobody is inside. Opening it would write `open` into the file for a keystroke aimed elsewhere.
|
||||
if (focused !== null && focused.isFor(old) && focused.holdsCaret()) return null;
|
||||
|
||||
const { $from } = state.selection;
|
||||
let tr: Transaction | null = null;
|
||||
|
||||
// Outwards, so a toggle inside a toggle opens along with the one holding it. setNodeMarkup keeps
|
||||
// a node the size it was, which is what lets these positions stay right across the whole walk.
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name !== NAME || node.attrs.open === true) continue;
|
||||
tr = (tr ?? state.tr).setNodeMarkup($from.before(depth), null, { ...node.attrs, open: true });
|
||||
}
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
/**
|
||||
* One toggle: the row that opens it and names it, and the body, which is the only part of it that
|
||||
* is the document.
|
||||
*
|
||||
* The title is an editable island. The summary row is declared not editable so that ProseMirror and
|
||||
* the browser both leave it alone, and the title inside it is declared editable again, which is
|
||||
* what makes a caret possible there without the text ever being content. Every keystroke in it is a
|
||||
* transaction like any other, for the reason math.ts gives about its own field: a title the element
|
||||
* is holding and the document is not is one an autosave writes the previous version of.
|
||||
*/
|
||||
class ToggleView implements NodeView {
|
||||
readonly dom: HTMLElement;
|
||||
readonly contentDOM: HTMLElement;
|
||||
|
||||
private readonly summary: HTMLElement;
|
||||
private readonly title: HTMLElement;
|
||||
private readonly view: EditorView;
|
||||
private readonly getPos: () => number | undefined;
|
||||
private node: ProseMirrorNode;
|
||||
/** A press on the row waiting for the click it will become. */
|
||||
private pressed = false;
|
||||
|
||||
constructor(node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined) {
|
||||
this.node = node;
|
||||
this.view = view;
|
||||
this.getPos = getPos;
|
||||
|
||||
const owner = view.dom.ownerDocument;
|
||||
this.dom = owner.createElement("details");
|
||||
this.dom.className = "toggle";
|
||||
|
||||
this.summary = owner.createElement("summary");
|
||||
this.summary.contentEditable = "false";
|
||||
|
||||
this.title = owner.createElement("span");
|
||||
this.title.setAttribute("data-toggle-summary", "");
|
||||
this.summary.appendChild(this.title);
|
||||
|
||||
this.contentDOM = owner.createElement("div");
|
||||
this.contentDOM.setAttribute("data-toggle-body", "");
|
||||
|
||||
this.dom.append(this.summary, this.contentDOM);
|
||||
|
||||
this.summary.addEventListener("mousedown", this.onMouseDown);
|
||||
this.summary.addEventListener("click", this.onClick);
|
||||
this.title.addEventListener("focus", this.onFocus);
|
||||
this.title.addEventListener("blur", this.onBlur);
|
||||
this.title.addEventListener("beforeinput", this.onBeforeInput);
|
||||
this.title.addEventListener("input", this.onInput);
|
||||
this.title.addEventListener("keydown", this.onKeyDown);
|
||||
this.title.addEventListener("paste", this.onPaste);
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
update(next: ProseMirrorNode): boolean {
|
||||
if (next.type !== this.node.type) return false;
|
||||
this.node = next;
|
||||
this.draw();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary row is this file's, from the arrow to the title. ProseMirror seeing the mousedown
|
||||
* that puts the caret in the title would put a text selection in the body where the caret was
|
||||
* going, and the keydowns after it would run the document's commands against that selection.
|
||||
*/
|
||||
stopEvent(event: Event): boolean {
|
||||
const target = event.target;
|
||||
return target instanceof Node && this.summary.contains(target);
|
||||
}
|
||||
|
||||
/** Nothing outside the body is the document's, so nothing read off it is news to the tree. */
|
||||
ignoreMutation(mutation: ViewMutationRecord): boolean {
|
||||
return !this.contentDOM.contains(mutation.target);
|
||||
}
|
||||
|
||||
/** Whether the editor this title is in is the one a transaction is being applied to. */
|
||||
isFor(state: EditorState): boolean {
|
||||
return this.view.state === state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caret really is in this title, asked of the page rather than of the flag that says
|
||||
* so. A focus event whose blur never came would otherwise refuse edits nobody was making.
|
||||
*/
|
||||
holdsCaret(): boolean {
|
||||
const active = this.title.ownerDocument.activeElement;
|
||||
return active !== null && this.title.contains(active);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (focused === this) focused = null;
|
||||
this.summary.removeEventListener("mousedown", this.onMouseDown);
|
||||
this.summary.removeEventListener("click", this.onClick);
|
||||
this.title.removeEventListener("focus", this.onFocus);
|
||||
this.title.removeEventListener("blur", this.onBlur);
|
||||
this.title.removeEventListener("beforeinput", this.onBeforeInput);
|
||||
this.title.removeEventListener("input", this.onInput);
|
||||
this.title.removeEventListener("keydown", this.onKeyDown);
|
||||
this.title.removeEventListener("paste", this.onPaste);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caret back in the title, a frame from now, after a command was refused because it was in
|
||||
* there.
|
||||
*
|
||||
* A toolbar button keeps the caret where it is with a preventDefault on its own mousedown and
|
||||
* then asks the editor to focus, and TipTap's focus command queues that focus for the next frame.
|
||||
* It lands after the refusal, so without this the caret is dragged out of the title and into the
|
||||
* selection the refusal was there to protect, and the second press of the same button edits it.
|
||||
* The range is carried over by hand because focusing an element the caret has left does not put
|
||||
* it back where it was in the word being typed.
|
||||
*/
|
||||
reclaim(): void {
|
||||
const owner = this.title.ownerDocument;
|
||||
const win = owner.defaultView;
|
||||
if (!win) return;
|
||||
|
||||
const selection = owner.getSelection();
|
||||
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
const saved = range && this.title.contains(range.commonAncestorContainer) ? range.cloneRange() : null;
|
||||
|
||||
win.requestAnimationFrame(() => {
|
||||
if (owner.activeElement === this.title) return;
|
||||
this.title.focus();
|
||||
if (!saved) return;
|
||||
const live = owner.getSelection();
|
||||
if (!live) return;
|
||||
live.removeAllRanges();
|
||||
live.addRange(saved);
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything on the element that comes off the node. */
|
||||
private draw(): void {
|
||||
const summary = summaryOf(this.node);
|
||||
|
||||
// textContent rather than innerHTML: a title reading "<b>" is those three characters of
|
||||
// somebody's heading and not the start of a bold run. Written only when it differs, because
|
||||
// writing it while the caret is in it sends the caret to the end of a word being edited in the
|
||||
// middle.
|
||||
if (summary !== this.title.textContent) this.title.textContent = summary;
|
||||
// A browser leaves a <br> behind when the last character of an editable element goes, which is
|
||||
// a blank line standing where the placeholder should be.
|
||||
else if (summary === "" && this.title.firstChild) this.title.textContent = "";
|
||||
|
||||
// An empty inline element is nothing to aim at, and a toggle made from the toolbar starts
|
||||
// without a title. What the prompt says is prose.css's, the way the callout labels are.
|
||||
this.title.toggleAttribute("data-empty", summary === "");
|
||||
|
||||
// Only when it changes. draw runs on every keystroke in the title, and rewriting the attribute
|
||||
// that makes an element editable under a caret that is already in it is not something to ask a
|
||||
// browser to do sixty times a sentence.
|
||||
const editable = this.view.editable ? "true" : "false";
|
||||
if (this.title.contentEditable !== editable) this.title.contentEditable = editable;
|
||||
|
||||
this.dom.toggleAttribute("open", this.node.attrs.open === true);
|
||||
}
|
||||
|
||||
private readonly onFocus = (): void => {
|
||||
focused = this;
|
||||
};
|
||||
|
||||
private readonly onBlur = (): void => {
|
||||
if (focused === this) focused = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The arrow is the summary's own ::before, so a press that lands on the row itself rather than on
|
||||
* the title is a press on the chrome. Prevented so it neither focuses the row nor takes the caret
|
||||
* out of wherever it was in the document; the flip happens on the click, which is also what the
|
||||
* keyboard sends when the row itself has focus. Remembered, because a press is what tells that
|
||||
* click apart from the one the browser sends of its own accord.
|
||||
*/
|
||||
private readonly onMouseDown = (event: MouseEvent): void => {
|
||||
this.pressed = event.target === this.summary;
|
||||
if (this.pressed) event.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* A click on the row flips it, when there is a press or a keyboard behind the click.
|
||||
*
|
||||
* The browser sends the summary a click of its own every time a space or an Enter is typed inside
|
||||
* it, because that is how a disclosure is activated from the keyboard, and the caret being in the
|
||||
* title makes no difference to it. Flipping on one of those closed the toggle under the caret on
|
||||
* every other space of a title and wrote the new `open` to the file each time.
|
||||
*/
|
||||
private readonly onClick = (event: MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
const pressed = this.pressed;
|
||||
this.pressed = false;
|
||||
if (event.target !== this.summary) return;
|
||||
if (pressed || this.title.ownerDocument.activeElement === this.summary) this.flip();
|
||||
};
|
||||
|
||||
private flip(): void {
|
||||
const pos = this.getPos();
|
||||
// Opening and closing writes `open` to the file, so it is an edit and is refused for the same
|
||||
// reason typing is while a conflict is being resolved.
|
||||
if (pos === undefined || !this.view.editable) return;
|
||||
|
||||
const open = this.node.attrs.open !== true;
|
||||
setToggleOpen(pos, open)(this.view.state, this.view.dispatch);
|
||||
// A toggle that is the whole document has nowhere outside itself to send the caret, so the
|
||||
// command left it where it was. The title is the one part of a closed toggle still on screen.
|
||||
if (!open && this.holdsSelection()) this.title.focus();
|
||||
}
|
||||
|
||||
private holdsSelection(): boolean {
|
||||
const pos = this.getPos();
|
||||
if (pos === undefined) return false;
|
||||
const { from } = this.view.state.selection;
|
||||
return from > pos && from < pos + this.node.nodeSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProseMirror does not rebuild a node view when the editor stops being editable, so this is what
|
||||
* keeps the title out of a buffer that is not allowed to drift: a document waiting on a conflict
|
||||
* has already moved on disk, and nothing may be typed into it until the user has said which copy
|
||||
* wins. `commit` refuses as well, in case the browser declines to cancel the input.
|
||||
*/
|
||||
private readonly onBeforeInput = (event: Event): void => {
|
||||
if (!this.view.editable) event.preventDefault();
|
||||
};
|
||||
|
||||
private readonly onInput = (): void => {
|
||||
this.commit();
|
||||
};
|
||||
|
||||
private commit(): void {
|
||||
const pos = this.getPos();
|
||||
if (pos === undefined || !this.view.editable) return;
|
||||
setToggleSummary(pos, this.title.textContent ?? "")(this.view.state, this.view.dispatch);
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
// A press on the row that never became a click is stale the moment something is typed, and the
|
||||
// click the browser sends on a space must not find it waiting.
|
||||
this.pressed = false;
|
||||
if (event.key !== "Enter" || event.isComposing) return;
|
||||
// A newline in a title is a toggle the bridge will not recognise the next time the file is
|
||||
// opened, so Enter leaves the title for the body, which is where the next thing typed belongs.
|
||||
event.preventDefault();
|
||||
this.enter();
|
||||
};
|
||||
|
||||
private enter(): void {
|
||||
const pos = this.getPos();
|
||||
if (pos === undefined) return;
|
||||
setToggleOpen(pos, true)(this.view.state, this.view.dispatch);
|
||||
const { state } = this.view;
|
||||
const inside = Selection.findFrom(state.doc.resolve(Math.min(pos + 1, state.doc.content.size)), 1);
|
||||
if (inside) this.view.dispatch(state.tr.setSelection(inside));
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* A paste goes in as one line of plain text and nothing else.
|
||||
*
|
||||
* Left to itself the browser puts the clipboard's own markup in here, and a title holding a bold
|
||||
* run or a line break is a title whose text content, which is what reaches the attribute, is not
|
||||
* what is on screen. The two would disagree until the next save decided between them.
|
||||
*/
|
||||
private readonly onPaste = (event: ClipboardEvent): void => {
|
||||
event.preventDefault();
|
||||
if (!this.view.editable) return;
|
||||
|
||||
const text = (event.clipboardData?.getData("text/plain") ?? "").replace(/\s*[\r\n]+\s*/g, " ");
|
||||
if (!text) return;
|
||||
|
||||
const selection = this.title.ownerDocument.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!this.title.contains(range.commonAncestorContainer)) return;
|
||||
|
||||
range.deleteContents();
|
||||
const inserted = this.title.ownerDocument.createTextNode(text);
|
||||
range.insertNode(inserted);
|
||||
range.setStartAfter(inserted);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
this.commit();
|
||||
};
|
||||
}
|
||||
|
||||
export const Toggles = Extension.create({
|
||||
name: "toggles",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("toggleViews"),
|
||||
props: {
|
||||
nodeViews: {
|
||||
toggle: (node, view, getPos) => new ToggleView(node, view, getPos),
|
||||
},
|
||||
},
|
||||
filterTransaction: allowTransaction,
|
||||
appendTransaction: openAroundSelection,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user