This commit is contained in:
pj committed 2026-08-28 09:22:58 +05:30
commit 852cba1840
252 files changed
+55050

No files matched your search

+396
View File
@@ -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();
});
});
+245
View File
@@ -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;
}
+54
View File
@@ -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 };
+343
View File
@@ -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)");
});
});
+473
View File
@@ -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)),
);
}
+324
View File
@@ -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();
});
});
+519
View File
@@ -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 } }),
);
}
+755
View File
@@ -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();
});
});
+385
View File
@@ -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;
}
+773
View File
@@ -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 &amp; Jerry &lt;3&gt;";
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("&lt;3&gt;", "&lt;4&gt;"));
editor.destroy();
});
// The failure this is shaped to catch is invisible: an editor that put the escaped form on the
// node would write `&amp;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();
});
});
+540
View File
@@ -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 `&amp;` 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,
}),
];
},
});