/`
- * elements, wrapper lies are unwrapped — so turndown converts semantics, not
- * vendor quirks.
- */
-
-import TurndownService from "turndown";
-// @ts-expect-error — no published types; the joplin fork is the maintained
-// GFM ruleset (tables, strikethrough, task lists).
-import { gfm } from "@joplin/turndown-plugin-gfm";
-
-/** Marks HTML that Compose itself put on the clipboard (see copyRich.ts).
- * The paste handler sees it and uses the lossless text/plain markdown
- * instead of re-converting our own rendering. */
-export const COMPOSE_CLIPBOARD_ATTR = "data-compose-markdown";
-
-export function isComposeClipboardHtml(html: string): boolean {
- return html.includes(COMPOSE_CLIPBOARD_ATTR);
-}
-
-function isBoldStyle(style: CSSStyleDeclaration): boolean {
- const weight = style.fontWeight;
- const numeric = Number(weight);
- return weight === "bold" || weight === "bolder" || (!Number.isNaN(numeric) && numeric >= 600);
-}
-
-/** Rewrite vendor styling into semantic elements, in place. */
-function normalizeVendorDom(root: HTMLElement): void {
- // Google Docs signs its fragments with a guid-carrying whose
- // font-weight is NORMAL — unwrap it or the whole paste turns bold.
- root.querySelectorAll("b[id^='docs-internal-guid']").forEach((wrapper) => {
- wrapper.replaceWith(...Array.from(wrapper.childNodes));
- });
-
- // Docs also wraps every list item's content in a , which converts as a
- // LOOSE list (`- item` + blank lines). Unwrap sole-child paragraphs so
- // lists come out tight, the house style.
- root.querySelectorAll("li > p:only-child").forEach((p) => {
- p.replaceWith(...Array.from(p.childNodes));
- });
-
- // Styled spans → semantic elements (Docs never emits /).
- // Nested wrappers so bold+italic+strike combinations all survive.
- root.querySelectorAll("span").forEach((span) => {
- const style = span.style;
- if (!style) return;
- const tags: string[] = [];
- if (isBoldStyle(style)) tags.push("strong");
- if (style.fontStyle === "italic") tags.push("em");
- if (style.textDecoration.includes("line-through")) tags.push("s");
- if (tags.length === 0) return;
- const doc = span.ownerDocument;
- const outermost = doc.createElement(tags[0]);
- let innermost = outermost;
- for (const tag of tags.slice(1)) {
- const next = doc.createElement(tag);
- innermost.appendChild(next);
- innermost = next;
- }
- innermost.append(...Array.from(span.childNodes));
- span.replaceWith(outermost);
- });
-}
-
-function buildTurndown(): TurndownService {
- const service = new TurndownService({
- headingStyle: "atx",
- hr: "---",
- bulletListMarker: "-",
- codeBlockStyle: "fenced",
- emDelimiter: "*",
- strongDelimiter: "**",
- });
- service.use(gfm);
- // Stock turndown pads every bullet to `- ` (a 4-char unit). House style
- // is the tight `- item` / `1. item`, with continuation lines indented to
- // the marker's own width — still CommonMark-correct for nesting.
- service.addRule("tightListItem", {
- filter: "li",
- replacement: (content, node, options) => {
- const parent = node.parentNode as HTMLElement;
- let prefix = `${options.bulletListMarker} `;
- if (parent.nodeName === "OL") {
- const items = Array.from(parent.children).filter((child) => child.nodeName === "LI");
- const start = Number(parent.getAttribute("start") ?? "1");
- prefix = `${start + items.indexOf(node as Element)}. `;
- }
- const indent = " ".repeat(prefix.length);
- const inner = content
- .replace(/^\n+/, "")
- .replace(/\n+$/, "\n")
- .replace(/\n/gm, `\n${indent}`);
- return prefix + inner + (node.nextSibling && !/\n$/.test(inner) ? "\n" : "");
- },
- });
- // Images degrade honestly (#134): a remote image becomes a LINK — never a
- // hot-loading `![...]` embed — and an inline data: blob keeps only its alt
- // text (a base64 wall would bury the document).
- service.addRule("imagesAsLinks", {
- filter: "img",
- replacement: (_content, node) => {
- const img = node as HTMLImageElement;
- const alt = img.getAttribute("alt")?.trim() || "image";
- const src = img.getAttribute("src") ?? "";
- if (!src || src.startsWith("data:")) return alt === "image" ? "" : alt;
- return `[${alt}](${src})`;
- },
- });
- return service;
-}
-
-let service: TurndownService | null = null;
-
-/** Convert clipboard HTML to house-style Markdown. Returns "" when nothing
- * convertible remains (caller falls back to the native plain paste). */
-export function htmlToMarkdown(html: string): string {
- const doc = new DOMParser().parseFromString(html, "text/html");
- doc.body.querySelectorAll("style,script,meta,head,title").forEach((el) => el.remove());
- normalizeVendorDom(doc.body);
- service ??= buildTurndown();
- return service.turndown(doc.body.innerHTML).trim();
-}
diff --git a/packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts b/packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts
deleted file mode 100644
index 89c2f22..0000000
--- a/packages/rich-editor/src/codemirror/clipboard/pasteMarkdown.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Rich paste (#134): clipboard HTML converts to Markdown at the caret, so a
- * paste from Google Docs / Word / the web keeps headings, emphasis, lists,
- * links, and tables instead of flattening to plain text.
- *
- * Precedence contract:
- * - image files on the clipboard belong to imageInsertHandlers — skipped;
- * - Compose's own copies carry {@link COMPOSE_CLIPBOARD_ATTR} — skipped, so
- * the native path pastes the lossless text/plain markdown;
- * - Mod-Shift-v pastes verbatim plain text (the escape hatch).
- */
-
-import { EditorView, keymap } from "@codemirror/view";
-import { type Extension } from "@codemirror/state";
-
-import { htmlToMarkdown, isComposeClipboardHtml } from "./htmlToMarkdown";
-
-function insertText(view: EditorView, text: string): void {
- view.dispatch(view.state.replaceSelection(text), {
- userEvent: "input.paste",
- scrollIntoView: true,
- });
-}
-
-const pasteHandler = EditorView.domEventHandlers({
- paste(event, view) {
- if (event.defaultPrevented) return false;
- const data = event.clipboardData;
- if (!data) return false;
- // Image bytes → the image pipeline, not text conversion.
- if (data.files.length > 0) return false;
- const html = data.getData("text/html");
- if (!html || isComposeClipboardHtml(html)) return false;
- const markdown = htmlToMarkdown(html);
- if (!markdown) return false;
- event.preventDefault();
- insertText(view, markdown);
- return true;
- },
-});
-
-const verbatimPasteKeymap = keymap.of([
- {
- key: "Mod-Shift-v",
- run: (view) => {
- // Async clipboard read — allowed here because it rides a user gesture.
- // A denial (or an empty clipboard) quietly does nothing rather than
- // erroring into the document.
- void navigator.clipboard
- .readText()
- .then((text) => {
- if (text) insertText(view, text);
- })
- .catch(() => {});
- return true;
- },
- },
-]);
-
-/** The paste half of clipboard interop; compose after imageInsertHandlers. */
-export const markdownPaste: Extension = [pasteHandler, verbatimPasteKeymap];
diff --git a/packages/rich-editor/src/codemirror/code/codeHighlight.ts b/packages/rich-editor/src/codemirror/code/codeHighlight.ts
deleted file mode 100644
index 1f0aeba..0000000
--- a/packages/rich-editor/src/codemirror/code/codeHighlight.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Syntax colors for fenced-code content (ADR 0002). The markdown grammar's
- * own constructs (headings, emphasis, links…) are styled by the decoration
- * registry + editorTheme, NOT here — so this style deliberately covers only
- * tags that code languages emit and markdown does not. The palette itself
- * lives in [codePalette](./codePalette.ts), shared with the clipboard's
- * inline-styled renderer.
- */
-
-import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
-
-import { CODE_PALETTE } from "./codePalette";
-
-const style = HighlightStyle.define(
- CODE_PALETTE.map((spec) => ({ tag: spec.tag, color: spec.color, fontStyle: spec.fontStyle })),
-);
-
-export const codeHighlight = syntaxHighlighting(style);
diff --git a/packages/rich-editor/src/codemirror/code/codeLangAffordance.ts b/packages/rich-editor/src/codemirror/code/codeLangAffordance.ts
deleted file mode 100644
index ea5b8f5..0000000
--- a/packages/rich-editor/src/codemirror/code/codeLangAffordance.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * The opener-row language affordance (ADR 0002).
- *
- * A CLOSED fence with a language shows its styled CodeInfo text (the pill);
- * one without gets a "plain" placeholder pill widget so every block has a
- * click target. Clicking either opens the searchable language chooser;
- * right-clicking anywhere in a block opens the block menu (set language,
- * copy code). Pointer handling is delegated — one listener per editor — and
- * clicks are swallowed on mousedown so the fence caret guard (§12.9) never
- * sees them.
- */
-
-import { syntaxTree } from "@codemirror/language";
-import { type EditorState, type Extension, type Range, StateField } from "@codemirror/state";
-import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
-
-import { fenceAtLoose, showCodeBlockMenu, showLanguageMenu } from "./codeLanguageMenu";
-
-class PlainLangPill extends WidgetType {
- override eq(): boolean {
- return true;
- }
-
- override toDOM(): HTMLElement {
- const pill = document.createElement("span");
- pill.className = "cm-code-info cm-code-info--unset";
- pill.textContent = "plain";
- return pill;
- }
-
- override ignoreEvent(): boolean {
- return false;
- }
-}
-
-const PILL = Decoration.widget({ widget: new PlainLangPill(), side: 1 });
-
-function buildPills(state: EditorState): DecorationSet {
- const ranges: Range[] = [];
- syntaxTree(state).iterate({
- enter(node) {
- if (node.name !== "FencedCode") return;
- const marks = node.node.getChildren("CodeMark");
- if (marks.length < 2) return; // unclosed: the language flow is typing
- if (node.node.getChildren("CodeInfo").length > 0) return;
- ranges.push(PILL.range(marks[0].to));
- },
- });
- return Decoration.set(ranges, true);
-}
-
-const plainPillField = StateField.define({
- create: buildPills,
- update(value, tr) {
- if (tr.docChanged || syntaxTree(tr.state) !== syntaxTree(tr.startState)) {
- return buildPills(tr.state);
- }
- return value;
- },
- provide: (f) => EditorView.decorations.from(f),
-});
-
-/** The fence containing the event point, resolved through the view. */
-function fenceAtEvent(view: EditorView, event: MouseEvent): number | null {
- const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
- if (pos === null) return null;
- const node = fenceAtLoose(view.state, pos);
- return node ? node.from : null;
-}
-
-const interactionPlugin = ViewPlugin.define((view) => {
- const onMouseDown = (event: MouseEvent): void => {
- if (event.button !== 0) return;
- const target = event.target as HTMLElement;
- if (!target.closest?.(".cm-code-info")) return;
- const fencePos = fenceAtEvent(view, event);
- if (fencePos === null) return;
- // Swallow the press: the pill is a control, not text — the caret must
- // not move (and §12.9 must not re-site it).
- event.preventDefault();
- event.stopPropagation();
- showLanguageMenu({ view, x: event.clientX, y: event.clientY, fencePos });
- };
-
- const onContextMenu = (event: MouseEvent): void => {
- const fencePos = fenceAtEvent(view, event);
- if (fencePos === null) return;
- event.preventDefault();
- showCodeBlockMenu({ view, x: event.clientX, y: event.clientY, fencePos });
- };
-
- view.dom.addEventListener("mousedown", onMouseDown, true);
- view.dom.addEventListener("contextmenu", onContextMenu);
- return {
- destroy() {
- view.dom.removeEventListener("mousedown", onMouseDown, true);
- view.dom.removeEventListener("contextmenu", onContextMenu);
- },
- };
-});
-
-export const codeLanguageUI: Extension = [plainPillField, interactionPlugin];
diff --git a/packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts b/packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts
deleted file mode 100644
index 0ff88c1..0000000
--- a/packages/rich-editor/src/codemirror/code/codeLanguage.browser.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * @browser: the code-block language UI (ADR 0002) — pill click opens the
- * chooser, choosing writes the info string as one undo step, right-click
- * offers set-language and copy-code. Real WebKit, real events.
- */
-
-import { history, historyKeymap } from "@codemirror/commands";
-import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
-import { ensureSyntaxTree } from "@codemirror/language";
-import { EditorState } from "@codemirror/state";
-import { EditorView, keymap } from "@codemirror/view";
-import { userEvent } from "@vitest/browser/context";
-import { afterEach, describe, expect, it, vi } from "vitest";
-
-import { codeLanguageUI } from "./codeLangAffordance";
-
-let view: EditorView | null = null;
-
-function makeView(doc: string): EditorView {
- const state = EditorState.create({
- doc,
- extensions: [
- history(),
- keymap.of(historyKeymap),
- markdown({ base: markdownLanguage }),
- codeLanguageUI,
- ],
- });
- ensureSyntaxTree(state, doc.length, 5000);
- view = new EditorView({ state, parent: document.body });
- return view;
-}
-
-afterEach(() => {
- view?.destroy();
- view = null;
- document.querySelectorAll(".cm-code-menu").forEach((el) => el.remove());
- vi.restoreAllMocks();
-});
-
-function click(el: Element): void {
- const rect = el.getBoundingClientRect();
- const at = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 };
- for (const type of ["mousedown", "mouseup", "click"] as const) {
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...at }));
- }
-}
-
-const menu = () => document.querySelector(".cm-code-menu");
-const menuItem = (label: string) =>
- Array.from(document.querySelectorAll(".cm-code-menu button")).find(
- (b) => b.textContent?.startsWith(label),
- );
-
-describe("language pill", () => {
- it("clicking the placeholder pill and choosing a language writes the info string", async () => {
- const v = makeView("```\nconst x = 1\n```");
- const pill = v.dom.querySelector(".cm-code-info--unset");
- expect(pill).not.toBeNull();
- click(pill!);
- expect(menu()).not.toBeNull();
-
- await userEvent.fill(menu()!.querySelector("input")!, "typescript");
- menuItem("TypeScript")!.click();
-
- expect(v.state.doc.toString()).toBe("```ts\nconst x = 1\n```");
- expect(menu()).toBeNull();
- });
-
- it("the change is one undo step", async () => {
- const v = makeView("```\ncode\n```");
- click(v.dom.querySelector(".cm-code-info--unset")!);
- menuItem("Plain text")!.click();
- // Plain on plain: no doc change dispatched at all.
- expect(v.state.doc.toString()).toBe("```\ncode\n```");
-
- click(v.dom.querySelector(".cm-code-info--unset")!);
- await userEvent.fill(menu()!.querySelector("input")!, "javascript");
- menuItem("JavaScript")!.click();
- expect(v.state.doc.toString()).toBe("```js\ncode\n```");
-
- v.focus();
- await userEvent.keyboard("{Meta>}z{/Meta}");
- expect(v.state.doc.toString()).toBe("```\ncode\n```");
- });
-
- it("Enter in the search picks the top match", async () => {
- const v = makeView("```\ncode\n```");
- click(v.dom.querySelector(".cm-code-info--unset")!);
- const input = menu()!.querySelector("input")!;
- await userEvent.fill(input, "rust");
- await userEvent.keyboard("{Enter}");
- expect(v.state.doc.toString()).toBe("```rust\ncode\n```");
- });
-});
-
-describe("block right-click menu", () => {
- it("offers Set language… and Copy code; copy writes the block content", async () => {
- const v = makeView("```js\nconst x = f(1)\n```");
- const written: string[] = [];
- vi.spyOn(navigator.clipboard, "writeText").mockImplementation(async (t: string) => {
- written.push(t);
- });
-
- const line = v.dom.querySelector(".cm-line");
- line!.dispatchEvent(
- new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 40, clientY: 40 }),
- );
- // The handler resolves the fence from event coords; aim at the code line.
- const codeCoords = v.coordsAtPos(v.state.doc.toString().indexOf("const"))!;
- document.querySelectorAll(".cm-code-menu").forEach((el) => el.remove());
- v.dom
- .querySelector(".cm-content")!
- .dispatchEvent(
- new MouseEvent("contextmenu", {
- bubbles: true,
- cancelable: true,
- clientX: codeCoords.left + 2,
- clientY: (codeCoords.top + codeCoords.bottom) / 2,
- }),
- );
- expect(menu()).not.toBeNull();
- expect(menuItem("Set language…")).toBeDefined();
-
- menuItem("Copy code")!.click();
- expect(written).toEqual(["const x = f(1)"]);
- expect(menu()).toBeNull();
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts b/packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts
deleted file mode 100644
index fe25b1a..0000000
--- a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it } from "vitest";
-
-import { destroyEditors, makeEditor, text } from "../core/editorTestHarness";
-import { fenceContent, infoFor, languageEntries, setFenceInfo } from "./codeLanguageMenu";
-import { codeLanguageUI } from "./codeLangAffordance";
-
-describe("setFenceInfo", () => {
- afterEach(destroyEditors);
-
- it("adds a language to a plain fence", () => {
- const view = makeEditor("```\ncode\n```", 0);
- view.dispatch({ changes: setFenceInfo(view.state, 0, "js")! });
- expect(text(view)).toBe("```js\ncode\n```");
- });
-
- it("replaces an existing language (and stray trailing text)", () => {
- const view = makeEditor("```js extra\ncode\n```", 0);
- view.dispatch({ changes: setFenceInfo(view.state, 2, "ts")! });
- expect(text(view)).toBe("```ts\ncode\n```");
- });
-
- it("clears the language with null", () => {
- const view = makeEditor("```js\ncode\n```", 0);
- view.dispatch({ changes: setFenceInfo(view.state, 0, null)! });
- expect(text(view)).toBe("```\ncode\n```");
- });
-
- it("returns null outside any fence", () => {
- const view = makeEditor("plain prose", 0);
- expect(setFenceInfo(view.state, 2, "js")).toBeNull();
- });
-});
-
-describe("fenceContent", () => {
- afterEach(destroyEditors);
-
- it("returns the block's code and empty for a bare pair", () => {
- const view = makeEditor("```js\na\nb\n```", 0);
- expect(fenceContent(view.state, 0)).toBe("a\nb");
- const bare = makeEditor("```\n```", 0);
- expect(fenceContent(bare.state, 0)).toBe("");
- });
-});
-
-describe("infoFor", () => {
- it("writes the shortest idiomatic alias", () => {
- expect(infoFor("TypeScript")).toBe("ts");
- expect(infoFor("JavaScript")).toBe("js");
- expect(infoFor("NoSuchLanguage")).toBeNull();
- });
-});
-
-describe("languageEntries", () => {
- it("offers renderer-backed tags (Mermaid) alongside the grammars", () => {
- const mermaid = languageEntries().find((entry) => entry.label === "Mermaid");
- expect(mermaid?.info).toBe("mermaid");
- });
-
- it("keeps Plain text first and the rest sorted A→Z (Mermaid merged in, not appended)", () => {
- const entries = languageEntries();
- expect(entries[0]).toMatchObject({ label: "Plain text", info: null });
- const labels = entries.slice(1).map((entry) => entry.label);
- expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
- });
-});
-
-describe("plain-pill affordance", () => {
- afterEach(destroyEditors);
-
- it("a languageless CLOSED fence gets the placeholder pill", () => {
- const view = makeEditor("```\ncode\n```", 0, [codeLanguageUI]);
- expect(view.dom.querySelectorAll(".cm-code-info--unset")).toHaveLength(1);
- });
-
- it("a fence WITH a language gets no placeholder", () => {
- const view = makeEditor("```js\ncode\n```", 0, [codeLanguageUI]);
- expect(view.dom.querySelectorAll(".cm-code-info--unset")).toHaveLength(0);
- });
-
- it("an UNCLOSED fence gets no placeholder (typing owns the flow)", () => {
- const view = makeEditor("```\ncode below", 0, [codeLanguageUI]);
- expect(view.dom.querySelectorAll(".cm-code-info--unset")).toHaveLength(0);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts b/packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts
deleted file mode 100644
index f852269..0000000
--- a/packages/rich-editor/src/codemirror/code/codeLanguageMenu.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-/**
- * Language + block menus for fenced code (ADR 0002).
- *
- * Typing the language right after ``` works while the block is still empty
- * (§12.4 keeps the caret on the opener) — these menus are the POINTER path,
- * and the only path once a block has content: the opener-row pill (real or
- * "plain" placeholder) opens a searchable chooser, and right-click anywhere
- * in a block offers "Set language…" and "Copy code". Menus mount on
- * document.body with inline styles (outside the editor's scoped theme), the
- * same pattern as the table menu.
- */
-
-import { EditorSelection, type ChangeSpec, type EditorState } from "@codemirror/state";
-import { type EditorView } from "@codemirror/view";
-import { languages } from "@codemirror/language-data";
-
-import { fenceAt } from "./fenceAutoClose";
-
-/** `fenceAt` resolves side -1 and misses the fence's own from-boundary — the
- * very position callers naturally pass (node.from). Probe one step in. */
-export function fenceAtLoose(state: EditorState, pos: number) {
- return fenceAt(state, pos) ?? fenceAt(state, Math.min(pos + 1, state.doc.length));
-}
-
-/** Replace the fence's info string (everything after the opening marks on the
- * opener line) with `info`; null clears it. Null result = no fence at pos. */
-export function setFenceInfo(
- state: EditorState,
- fencePos: number,
- info: string | null,
-): ChangeSpec | null {
- const node = fenceAtLoose(state, fencePos);
- if (!node) return null;
- const opener = node.getChildren("CodeMark")[0];
- if (!opener) return null;
- const openerLine = state.doc.lineAt(node.from);
- return { from: opener.to, to: openerLine.to, insert: info ?? "" };
-}
-
-/** The block's code content (between opener and closer lines). */
-export function fenceContent(state: EditorState, fencePos: number): string | null {
- const node = fenceAtLoose(state, fencePos);
- if (!node) return null;
- const marks = node.getChildren("CodeMark");
- if (marks.length < 2) return null;
- const openerLine = state.doc.lineAt(node.from);
- const closerLine = state.doc.lineAt(marks[marks.length - 1].from);
- if (closerLine.from - 1 <= openerLine.to) return "";
- return state.sliceDoc(openerLine.to + 1, closerLine.from - 1);
-}
-
-/** Write the block's content to the clipboard; returns it (for tests). */
-export function copyFenceCode(view: EditorView, fencePos: number): string | null {
- const content = fenceContent(view.state, fencePos);
- if (content === null) return null;
- void navigator.clipboard?.writeText(content).catch(() => {
- // Clipboard API denied (rare in the webview): execCommand fallback.
- const area = document.createElement("textarea");
- area.value = content;
- document.body.appendChild(area);
- area.select();
- document.execCommand("copy");
- area.remove();
- });
- return content;
-}
-
-interface MenuHandle {
- root: HTMLElement;
- destroy(): void;
-}
-
-function mountMenu(x: number, y: number): MenuHandle {
- const root = document.createElement("div");
- root.className = "cm-code-menu";
- root.setAttribute("role", "menu");
- root.style.cssText =
- "position:fixed;z-index:1000;min-width:12rem;padding:0.25rem;" +
- "background:var(--cds-layer-01,#ffffff);border:0.0625rem solid var(--cds-border-subtle-01,#e0e0e0);" +
- "border-radius:0.375rem;box-shadow:0 0.125rem 0.75rem rgba(0,0,0,0.18);font-size:0.875rem;";
- root.style.left = `${x}px`;
- root.style.top = `${y}px`;
- document.body.appendChild(root);
-
- const onOutside = (event: MouseEvent): void => {
- if (!root.contains(event.target as Node)) destroy();
- };
- const onEscape = (event: KeyboardEvent): void => {
- if (event.key === "Escape") destroy();
- };
- function destroy(): void {
- root.remove();
- document.removeEventListener("mousedown", onOutside, true);
- document.removeEventListener("keydown", onEscape, true);
- }
- // Defer registration past the event that opened the menu.
- setTimeout(() => {
- document.addEventListener("mousedown", onOutside, true);
- document.addEventListener("keydown", onEscape, true);
- }, 0);
-
- const clamp = () => {
- const rect = root.getBoundingClientRect();
- if (rect.right > window.innerWidth) root.style.left = `${x - rect.width}px`;
- if (rect.bottom > window.innerHeight) root.style.top = `${Math.max(0, y - rect.height)}px`;
- };
- queueMicrotask(clamp);
- return { root, destroy };
-}
-
-function itemButton(label: string, hint?: string): HTMLButtonElement {
- const button = document.createElement("button");
- button.type = "button";
- button.className = "cm-code-menu__item";
- button.style.cssText =
- "display:flex;justify-content:space-between;gap:1rem;width:100%;padding:0.3rem 0.6rem;" +
- "border:none;background:none;text-align:left;cursor:pointer;border-radius:0.25rem;color:inherit;";
- button.addEventListener("mouseenter", () => (button.style.background = "var(--cds-layer-hover-01,#e8e8e8)"));
- button.addEventListener("mouseleave", () => (button.style.background = "none"));
- const name = document.createElement("span");
- name.textContent = label;
- button.appendChild(name);
- if (hint) {
- const alias = document.createElement("span");
- alias.textContent = hint;
- alias.style.cssText = "color:var(--cds-text-secondary,#6f6f6f);font-size:0.75rem;";
- button.appendChild(alias);
- }
- return button;
-}
-
-/** The markdown info string we write for a chosen language: its shortest
- * alias (`ts`, `js`, `py`) — the idiomatic fence tag. */
-export function infoFor(name: string): string | null {
- const lang = languages.find((l) => l.name === name);
- if (!lang) return null;
- return [...lang.alias, lang.name.toLowerCase()].sort((a, b) => a.length - b.length)[0];
-}
-
-export interface LanguageEntry {
- label: string;
- hint?: string;
- info: string | null;
- haystack: string;
-}
-
-/** Fence tags Compose RENDERS rather than parses — no CodeMirror grammar
- * exists for them, so they'd otherwise be absent from the chooser, and
- * type-time auto-close (§12.4) makes this menu the only way to give a
- * from-scratch fence its language. */
-const RENDERED_FENCE_TAGS: LanguageEntry[] = [
- {
- label: "Mermaid",
- hint: "diagram",
- info: "mermaid",
- haystack: "mermaid diagram flowchart sequence graph chart",
- },
-];
-
-/** Every choosable entry: Plain text first, then grammars and rendered tags
- * merged A→Z. */
-export function languageEntries(): LanguageEntry[] {
- return [
- { label: "Plain text", info: null, haystack: "plain text none" },
- ...[
- ...languages.map((l) => ({
- label: l.name,
- hint: infoFor(l.name) ?? undefined,
- info: infoFor(l.name),
- haystack: `${l.name} ${l.alias.join(" ")}`.toLowerCase(),
- })),
- ...RENDERED_FENCE_TAGS,
- ].sort((a, b) => a.label.localeCompare(b.label)),
- ];
-}
-
-export interface LanguageMenuArgs {
- view: EditorView;
- x: number;
- y: number;
- /** Any position inside the target fence. */
- fencePos: number;
-}
-
-/** Searchable language chooser; picking dispatches the info-string change as
- * one undo step. */
-export function showLanguageMenu(args: LanguageMenuArgs): void {
- const menu = mountMenu(args.x, args.y);
-
- const search = document.createElement("input");
- search.type = "text";
- search.placeholder = "Search languages…";
- search.className = "cm-code-menu__search";
- search.style.cssText =
- "width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem 0.5rem;" +
- "border:0.0625rem solid var(--cds-border-subtle-01,#e0e0e0);border-radius:0.25rem;font:inherit;outline-color:var(--cds-focus,#0f62fe);";
- menu.root.appendChild(search);
-
- const list = document.createElement("div");
- list.style.cssText = "max-height:16rem;overflow-y:auto;";
- menu.root.appendChild(list);
-
- const apply = (info: string | null): void => {
- const change = setFenceInfo(args.view.state, args.fencePos, info) as {
- from: number;
- to: number;
- insert: string;
- } | null;
- menu.destroy();
- if (change) {
- // Language chosen → drop the caret at the start of the first content
- // line, ready to type the code (or diagram) itself.
- const newLength = args.view.state.doc.length - (change.to - change.from) + change.insert.length;
- const anchor = Math.min(change.from + change.insert.length + 1, newLength);
- args.view.dispatch({
- changes: change,
- selection: EditorSelection.cursor(anchor),
- userEvent: "input.code.language",
- });
- }
- args.view.focus();
- };
-
- const entries = languageEntries();
-
- const render = (filter: string): void => {
- list.textContent = "";
- const needle = filter.trim().toLowerCase();
- for (const entry of entries.filter((e) => !needle || e.haystack.includes(needle))) {
- const button = itemButton(entry.label, entry.hint);
- button.addEventListener("click", () => apply(entry.info));
- list.appendChild(button);
- }
- };
- render("");
- search.addEventListener("input", () => render(search.value));
- // Enter picks the top visible match.
- search.addEventListener("keydown", (event) => {
- if (event.key !== "Enter") return;
- event.preventDefault();
- list.querySelector("button")?.click();
- });
- search.focus();
-}
-
-/** Right-click menu for a code block: set language, copy content. */
-export function showCodeBlockMenu(args: LanguageMenuArgs): void {
- const menu = mountMenu(args.x, args.y);
- const language = itemButton("Set language…");
- language.addEventListener("click", () => {
- menu.destroy();
- showLanguageMenu(args);
- });
- const copy = itemButton("Copy code");
- copy.addEventListener("click", () => {
- copyFenceCode(args.view, args.fencePos);
- menu.destroy();
- args.view.focus();
- });
- menu.root.append(language, copy);
-}
diff --git a/packages/rich-editor/src/codemirror/code/codeLanguages.test.ts b/packages/rich-editor/src/codemirror/code/codeLanguages.test.ts
deleted file mode 100644
index e110e34..0000000
--- a/packages/rich-editor/src/codemirror/code/codeLanguages.test.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-// @vitest-environment jsdom
-/**
- * Fenced code gets a real NESTED parse from its info string (ADR 0002): the
- * `codeLanguages` wiring mounts the code grammar inside CodeText, which is
- * what the codeHighlight style colors. Probed via resolveInner — mounted
- * overlay trees are invisible to a plain tree iterate. The lazy-load path
- * (no preload) rides the view's idle work loop, which jsdom doesn't drive;
- * preloading reproduces the state the app reaches after the import lands.
- */
-import { forceParsing, syntaxTree } from "@codemirror/language";
-import { languages } from "@codemirror/language-data";
-import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
-import { EditorState } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
-import { afterEach, describe, expect, it } from "vitest";
-
-let view: EditorView | null = null;
-afterEach(() => {
- view?.destroy();
- view = null;
-});
-
-function makeView(doc: string): EditorView {
- view = new EditorView({
- state: EditorState.create({
- doc,
- extensions: [markdown({ base: markdownLanguage, codeLanguages: languages })],
- }),
- parent: document.body,
- });
- forceParsing(view, view.state.doc.length, 1000);
- return view;
-}
-
-/** Ancestor chain names at `pos` (side +1), innermost first. */
-function chainAt(v: EditorView, pos: number): string[] {
- const names: string[] = [];
- for (
- let node: { name: string; parent: unknown } | null = syntaxTree(v.state).resolveInner(pos, 1);
- node;
- node = node.parent as { name: string; parent: unknown } | null
- ) {
- names.push(node.name);
- }
- return names;
-}
-
-describe("fenced-code nested parsing", () => {
- it("a ```js fence parses its content with the JavaScript grammar", async () => {
- await languages.find((l) => l.name === "JavaScript")!.load();
- const doc = "```js\nconst x = f(1)\n```";
- const v = makeView(doc);
- const chain = chainAt(v, doc.indexOf("const") + 1);
- expect(chain).toContain("VariableDeclaration");
- expect(chain).toContain("Script");
- });
-
- it("an unknown language stays plain fenced code (no crash)", () => {
- const doc = "```nosuchlang\nwhatever\n```";
- const v = makeView(doc);
- const chain = chainAt(v, doc.indexOf("whatever") + 1);
- expect(chain).toContain("CodeText");
- expect(chain).not.toContain("Script");
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/codePalette.ts b/packages/rich-editor/src/codemirror/code/codePalette.ts
deleted file mode 100644
index 6b1aaac..0000000
--- a/packages/rich-editor/src/codemirror/code/codePalette.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * The one code-syntax palette (One Light values — ADR 0002), consumed by both
- * renderers so code looks the same everywhere it appears:
- *
- * - the EDITOR, via `codeHighlight.ts` (a CodeMirror `HighlightStyle` built
- * from these specs), and
- * - the CLIPBOARD, via `highlightFence.ts` (inline-styled spans — pasted HTML
- * carries no stylesheet, so classes would be dead weight there).
- */
-
-import { tags, type Tag } from "@lezer/highlight";
-
-export interface CodeStyleSpec {
- tag: Tag | readonly Tag[];
- color: string;
- fontStyle?: string;
-}
-
-export const CODE_PALETTE: readonly CodeStyleSpec[] = [
- { tag: [tags.keyword, tags.modifier, tags.operatorKeyword], color: "#a626a4" },
- { tag: [tags.string, tags.special(tags.string)], color: "#50a14f" },
- { tag: tags.comment, color: "#a0a1a7", fontStyle: "italic" },
- { tag: [tags.number, tags.bool, tags.null, tags.atom], color: "#986801" },
- { tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: "#4078f2" },
- { tag: [tags.typeName, tags.className, tags.namespace], color: "#c18401" },
- { tag: tags.definition(tags.variableName), color: "#e45649" },
- { tag: tags.propertyName, color: "#4078f2" },
- { tag: [tags.tagName, tags.self], color: "#e45649" },
- { tag: tags.attributeName, color: "#986801" },
- { tag: [tags.regexp, tags.escape], color: "#0184bc" },
- { tag: tags.invalid, color: "#ca1243" },
-];
diff --git a/packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts b/packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts
deleted file mode 100644
index 3982567..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceAutoClose.test.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it } from "vitest";
-
-import { destroyEditors, makeEditor, text } from "../core/editorTestHarness";
-import type { EditorView } from "@codemirror/view";
-
-import { fenceAutoClose, fenceExitBlock, fenceTypeAutoClose } from "./fenceAutoClose";
-
-describe("fenceAutoClose — Enter on a just-typed fence (#91)", () => {
- afterEach(destroyEditors);
-
- it("closes the fence and puts the caret inside the empty block", () => {
- const view = makeEditor("```", 3);
- expect(fenceAutoClose(view)).toBe(true);
- expect(text(view)).toBe("```\n\n```");
- expect(view.state.selection.main.head).toBe(4);
- });
-
- it("keeps the language info and matches the fence length", () => {
- const js = makeEditor("```js", 5);
- expect(fenceAutoClose(js)).toBe(true);
- expect(text(js)).toBe("```js\n\n```");
-
- const long = makeEditor("````", 4);
- expect(fenceAutoClose(long)).toBe(true);
- expect(text(long)).toBe("````\n\n````");
- });
-
- it("releases content below instead of swallowing it", () => {
- // The user's report: an unclosed fence runs to the end of the document,
- // so everything below turned into code. Closing right after the opening
- // line hands it back to prose.
- const view = makeEditor("```\nexisting text below", 3);
- expect(fenceAutoClose(view)).toBe(true);
- expect(text(view)).toBe("```\n\n```\nexisting text below");
- expect(view.state.selection.main.head).toBe(4);
- });
-
- it("declines on an already-closed fence's opening line", () => {
- const view = makeEditor("```\ncode\n```", 3);
- expect(fenceAutoClose(view)).toBe(false);
- expect(text(view)).toBe("```\ncode\n```");
- });
-
- it("declines mid-line and on non-fence lines", () => {
- const mid = makeEditor("```js", 3);
- expect(fenceAutoClose(mid)).toBe(false);
-
- const prose = makeEditor("plain", 5);
- expect(fenceAutoClose(prose)).toBe(false);
- });
-
- it("steps onto an empty first content line instead of inserting another (§12.5)", () => {
- const doc = "```\n\n```";
- const view = makeEditor(doc, 3, [fenceTypeAutoClose]);
- expect(fenceAutoClose(view)).toBe(true);
- expect(text(view)).toBe(doc); // no edit — just the caret move
- expect(view.state.selection.main.head).toBe("```\n".length);
- });
-
- it("declines inside the code content of an unclosed fence", () => {
- // Enter while typing code must stay a plain newline — only the opening
- // line auto-closes.
- const doc = "```\nlet x = 1";
- const view = makeEditor(doc, doc.length);
- expect(fenceAutoClose(view)).toBe(false);
- });
-});
-
-describe("fenceTypeAutoClose — the completing keystroke closes the fence (§9.5)", () => {
- afterEach(destroyEditors);
-
- function typeChar(view: EditorView, ch: string): void {
- const head = view.state.selection.main.head;
- view.dispatch({
- changes: { from: head, insert: ch },
- selection: { anchor: head + 1 },
- userEvent: "input.type",
- });
- }
-
- it("typing the third backtick closes the fence with the caret kept on the opener (§12.4)", () => {
- const view = makeEditor("``", 2, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("```\n\n```");
- expect(view.state.selection.main.head).toBe("```".length);
- });
-
- it("content below is never hijacked, not even transiently", () => {
- const doc = "``\nexisting text";
- const view = makeEditor(doc, 2, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("```\n\n```\nexisting text");
- });
-
- it("typing straight through gives the language: ```mermaid, then Enter steps in", () => {
- // The universal fence idiom — the language pill renders the tag live, so
- // typing here is never invisible.
- const view = makeEditor("``", 2, [fenceTypeAutoClose]);
- typeChar(view, "`");
- for (const ch of "mermaid") typeChar(view, ch);
- expect(text(view)).toBe("```mermaid\n\n```");
- expect(view.state.selection.main.head).toBe("```mermaid".length);
-
- expect(fenceAutoClose(view)).toBe(true); // Enter → §12.5 step-in
- expect(view.state.selection.main.head).toBe("```mermaid\n".length);
- for (const ch of "graph LR") typeChar(view, ch);
- expect(text(view)).toBe("```mermaid\ngraph LR\n```");
- });
-
- it("language typing on a fresh block re-sites once the block has content", () => {
- // The click-the-gray-row-means-code protection (§12.7) is only relaxed
- // while the block is EMPTY.
- const doc = "```js\ncode\n```";
- const view = makeEditor(doc, "```js".length, [fenceTypeAutoClose]);
- typeChar(view, "x");
- expect(text(view)).toBe("```js\nxcode\n```");
- });
-
- it("a language typed before the third backtick is kept on the opener", () => {
- // ``js| → caret before the js? No — the flow is ``` then edit; the
- // supported language flow is typing the info on the opener line later or
- // before completing the fence: `` + ` typed with js already present is
- // NOT a bare fence line, so no auto-close fires and typing continues.
- const view = makeEditor("``js", 2, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("```js");
- });
-
- it("tilde fences close the same way", () => {
- const view = makeEditor("~~", 2, [fenceTypeAutoClose]);
- typeChar(view, "~");
- expect(text(view)).toBe("~~~\n\n~~~");
- });
-
- it("indented openers keep their indent on the content line and closer", () => {
- const view = makeEditor(" ``", 4, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe(" ```\n \n ```");
- expect(view.state.selection.main.head).toBe(" ```".length);
- });
-
- it("a fence as a task item's direct content closes inside the item (§12.4)", () => {
- const doc = "- [ ] ``";
- const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("- [ ] ```\n \n ```");
- expect(view.state.selection.main.head).toBe("- [ ] ```".length);
- });
-
- it("a fence inside a blockquote carries the quote prefix onto both lines (§12.4)", () => {
- const doc = "> ``";
- const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("> ```\n> \n> ```");
- });
-
- it("backticks typed inside an existing code block stay literal", () => {
- const doc = "```\nco``\n```";
- const view = makeEditor(doc, doc.indexOf("co``") + 4, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("```\nco```\n```");
- });
-
- it("a 4th backtick on a closed opener becomes code content, never fence surgery", () => {
- const view = makeEditor("```\n\n```", 3, [fenceTypeAutoClose]);
- typeChar(view, "`");
- expect(text(view)).toBe("```\n`\n```");
- });
-
- it("pasting a fence does not trigger the close", () => {
- const view = makeEditor("", 0, [fenceTypeAutoClose]);
- view.dispatch({
- changes: { from: 0, insert: "```" },
- selection: { anchor: 3 },
- userEvent: "input.paste",
- });
- expect(text(view)).toBe("```");
- });
-});
-
-describe("fenceAutoClose — §12.5 step-in inside containers", () => {
- afterEach(destroyEditors);
-
- it("Enter on a quote-nested opener steps onto the existing '> ' content line", () => {
- const doc = "> ```js\n> \n> ```";
- const openerEnd = doc.indexOf("\n");
- const view = makeEditor(doc, openerEnd);
- expect(fenceAutoClose(view)).toBe(true);
- expect(view.state.doc.toString()).toBe(doc);
- const contentLineEnd = doc.indexOf("\n", openerEnd + 1);
- expect(view.state.selection.main.head).toBe(contentLineEnd);
- });
-});
-
-describe("fenceExitBlock — Enter on the empty last line leaves the block (§9.5)", () => {
- afterEach(destroyEditors);
-
- it("exits to the line after the closing fence", () => {
- const doc = "```\ncode\n\n```\nafter";
- const view = makeEditor(doc, doc.indexOf("\n\n```") + 1);
- expect(fenceExitBlock(view)).toBe(true);
- expect(text(view)).toBe("```\ncode\n```\nafter");
- expect(view.state.selection.main.head).toBe(text(view).indexOf("after"));
- });
-
- it("creates the line below when the block ends the document", () => {
- const doc = "```\ncode\n\n```";
- const view = makeEditor(doc, doc.indexOf("\n\n```") + 1);
- expect(fenceExitBlock(view)).toBe(true);
- expect(text(view)).toBe("```\ncode\n```\n");
- expect(view.state.selection.main.head).toBe(text(view).length);
- });
-
- it("declines on an empty line mid-block (Enter should add a code line)", () => {
- const doc = "```\n\ncode\n```";
- const view = makeEditor(doc, doc.indexOf("\n\ncode") + 1);
- expect(fenceExitBlock(view)).toBe(false);
- });
-
- it("declines in an unclosed block and on non-empty lines", () => {
- const unclosed = makeEditor("```\n\nswallowed", 4);
- expect(fenceExitBlock(unclosed)).toBe(false);
-
- const doc = "```\ncode\n```";
- const nonEmpty = makeEditor(doc, doc.indexOf("code") + 4);
- expect(fenceExitBlock(nonEmpty)).toBe(false);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/fenceAutoClose.ts b/packages/rich-editor/src/codemirror/code/fenceAutoClose.ts
deleted file mode 100644
index 65ecca8..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceAutoClose.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-/**
- * Fence lifecycle (#91, interaction-spec §9.5, §12.4–.6).
- *
- * An unclosed fence runs to the end of the document per CommonMark, so the
- * moment ``` is typed everything below renders as one giant code block —
- * grammar-correct, wrong WYSIWYG. The behaviors here keep blocks bounded,
- * enterable, and escapable:
- *
- * - TYPE-TIME close (§12.4): the keystroke completing a fence opener at a
- * line's CONTENT start — top level, inside a list item, inside a quote
- * (positions from `lineStructure`, not a line regex) — inserts an empty
- * content line plus the matching closer at the same content column. The
- * caret STAYS on the opener, so typing continues the language tag
- * (```mermaid — the universal fence idiom, rendered live as the visible
- * pill) and Enter steps onto the content line (§12.5). Quote prefixes are
- * carried onto the inserted lines; list prefixes become continuation
- * indent.
- * - ENTER close: Enter at the end of an unclosed opener line (e.g. pasted)
- * closes it with the caret inside.
- * - ENTER step-in (§12.5): Enter on a CLOSED block's opener whose first
- * content line is empty moves the caret onto that line instead of
- * inserting another.
- * - ENTER exit (§12.6): Enter on the block's empty last content line
- * removes that line and moves the caret below the closing fence,
- * creating the line when the block ends the document.
- *
- * Typing on a CLOSED fence's own rows re-sites into content (§12.7) — see
- * resiteFenceLineTyping — which also closes the old gap where lengthening
- * the opener in place re-opened the block.
- */
-
-import { EditorSelection, EditorState, Prec, type Transaction } from "@codemirror/state";
-import { keymap, type Command } from "@codemirror/view";
-
-import { lineStructure } from "../core/lineStructure";
-import { treeAt } from "../core/treeAt";
-
-/** The FencedCode syntax node, as a structural stand-in (`@lezer/common` stays
- * a transitive dep). Exported because `fenceAt` returns it. */
-export type FenceNode = {
- readonly name: string;
- readonly from: number;
- readonly to: number;
- readonly parent: FenceNode | null;
- getChildren(type: string): readonly FenceNode[];
-};
-
-/** Continuation prefix for a line inserted inside the same containers as
- * `line` up to `contentStart`: quote marks carry over, everything else
- * becomes plain indent. */
-function containerPrefix(text: string): string {
- return [...text].map((c) => (c === ">" ? ">" : " ")).join("");
-}
-
-/** §12.7 — typing on a CLOSED fence's own rows can never edit the fence.
- * On the closing line, trailing text stops it closing (CommonMark allows no
- * info string there) and the block re-opens, swallowing everything below.
- * On the opener, typing extends the language tag — which users hit when they
- * click the block's first gray row meaning to type CODE. Both re-site: the
- * closer row onto a fresh content line before the closer; the opener row to
- * the start of the first content line. (An UNCLOSED opener still types in
- * place — that's the language flow for a pasted fence.) Editing an existing
- * language tag moves to RAW mode, spec-noted. Returns the replacement spec,
- * or null when the insertion is elsewhere. */
-function resiteFenceLineTyping(
- tr: Transaction,
- from: number,
- text: string,
-): { changes: { from: number; insert: string }; selection: ReturnType; userEvent: string } | null {
- const state = tr.startState;
- let node = fenceAt(state, from);
- if (!node) {
- // Column 0 of the opener row sits ON the fence node's from-boundary,
- // where side -1 resolves to the sibling before the block. Probe from the
- // line's end instead; only accept a fence whose opener IS this line.
- const line = state.doc.lineAt(from);
- if (from !== line.from || line.to === line.from) return null;
- const probed = fenceAt(state, line.to);
- if (!probed || state.doc.lineAt(probed.from).from !== line.from) return null;
- node = probed;
- }
- const marks = node.getChildren("CodeMark");
- if (marks.length < 2) return null;
- const openerLine = state.doc.lineAt(node.from);
- const closer = marks[marks.length - 1];
- const closerLine = state.doc.lineAt(closer.from);
- if (from >= openerLine.from && from <= openerLine.to) {
- // Fresh-block language flow (§12.4): while the block holds no code yet,
- // typing after the opening marks extends the info string IN PLACE —
- // ```mermaid⏎ — with the visible pill as live feedback. A fence character
- // is excluded (a 4th backtick must not grow the marks), and a block WITH
- // content keeps the clicked-the-gray-row-means-code re-site below.
- const bodyIsEmpty =
- closerLine.number - openerLine.number < 2 ||
- state.doc
- .sliceString(openerLine.to, closerLine.from)
- .split("\n")
- .every((lineText) => /^[>\s]*$/.test(lineText));
- if (
- bodyIsEmpty &&
- from >= marks[0].to &&
- from <= openerLine.to &&
- text !== "`" &&
- text !== "~"
- ) {
- return null;
- }
- // No content line exists in a bare ```/``` pair post-§12.4, but a pasted
- // block may lack one — fall back to a fresh line after the opener.
- if (openerLine.to + 1 <= closerLine.from - 1) {
- const contentStart = openerLine.to + 1;
- return {
- changes: { from: contentStart, insert: text },
- selection: EditorSelection.cursor(contentStart + text.length),
- userEvent: "input.type",
- };
- }
- const prefix = containerPrefix(state.doc.sliceString(openerLine.from, node.from));
- const insert = `${state.lineBreak}${prefix}${text}`;
- return {
- changes: { from: openerLine.to, insert },
- selection: EditorSelection.cursor(openerLine.to + insert.length),
- userEvent: "input.type",
- };
- }
- if (from < closerLine.from || from > closerLine.to) return null;
- const prefix = containerPrefix(state.doc.sliceString(closerLine.from, closer.from));
- const insert = `${state.lineBreak}${prefix}${text}`;
- const at = closerLine.from - 1;
- return {
- changes: { from: at, insert },
- selection: EditorSelection.cursor(at + insert.length),
- userEvent: "input.type",
- };
-}
-
-/** The keystroke that completes a ```/~~~ opener at a line's content start
- * closes the fence below, before the unclosed state can swallow the rest of
- * the document; a keystroke on a closed fence's closing line re-sites onto a
- * fresh content line (§12.7). */
-export const fenceTypeAutoClose = EditorState.transactionFilter.of((tr: Transaction) => {
- if (!tr.docChanged || !tr.isUserEvent("input.type") || tr.isUserEvent("input.type.compose")) {
- return tr;
- }
- let single: { from: number; ch: string } | null = null;
- let eligible = true;
- tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
- const ch = inserted.toString();
- if (single || fromA !== toA || ch.includes("\n")) eligible = false;
- else single = { from: fromA, ch };
- });
- if (!eligible || !single) return tr;
-
- const resited = resiteFenceLineTyping(tr, (single as { from: number }).from, (single as { ch: string }).ch);
- if (resited) return [resited];
-
- const { ch: typed } = single as { from: number; ch: string };
- if (typed !== "`" && typed !== "~") return tr;
- const { from } = single as { from: number };
-
- // The line must have been prose before the keystroke — a backtick typed
- // inside an existing block is literal, and an opener line of a closed
- // fence (typing a 4th backtick) must not stack another closer.
- const oldLine = tr.startState.doc.lineAt(from);
- const info = lineStructure(tr.startState, oldLine);
- if (info.inCode) return tr;
-
- // The fence must occupy the line's whole CONTENT — everything after the
- // block markers the grammar sees (list/task marker, quote marks).
- const contentStart = info.list ? info.list.markTo : info.contentFrom;
- const newLine = tr.newDoc.lineAt(from + 1);
- const content = tr.newDoc.sliceString(contentStart, newLine.to);
- if (!/^(`{3,}|~{3,})$/.test(content) || from + 1 !== newLine.to) return tr;
-
- // Continuation prefix for the inserted lines: quote marks carry over,
- // everything else (list markers, indent) becomes plain indent so the new
- // lines stay inside the same container at the fence's column.
- const prefix = containerPrefix(tr.newDoc.sliceString(newLine.from, contentStart));
- const brk = tr.startState.lineBreak;
- return [
- tr,
- {
- changes: { from: newLine.to, insert: `${brk}${prefix}${brk}${prefix}${content}` },
- // The caret STAYS at the end of the opener: typing continues the
- // language tag (```mermaid), Enter steps into the body (§12.5).
- selection: EditorSelection.cursor(newLine.to),
- sequential: true,
- },
- ];
-});
-
-/** The FencedCode ancestor at `pos`, or null.
- *
- * Resolves from {@link treeAt}, not `syntaxTree`: a tree that stops short of
- * `pos` reports it as bare `Document`, and the delete guards then merge a code
- * block into the prose above it. */
-export function fenceAt(state: EditorState, pos: number): FenceNode | null {
- let node = treeAt(state, pos).resolveInner(pos, -1) as unknown as FenceNode | null;
- while (node && node.name !== "FencedCode") node = node.parent;
- return node;
-}
-
-export const fenceAutoClose: Command = (view) => {
- const { state } = view;
- const { main } = state.selection;
- if (!main.empty) return false;
- const line = state.doc.lineAt(main.head);
- if (main.head !== line.to) return false;
-
- const node = fenceAt(state, main.head);
- // Only on the fence's OPENING line.
- if (!node || node.from < line.from) return false;
- const marks = node.getChildren("CodeMark");
- const open = marks[0];
- if (!open) return false;
-
- if (marks.length >= 2) {
- // §12.5 — closed block: step onto an existing empty first content line.
- // "Empty" includes container prefixes — inside a blockquote that line is
- // "> ", the same shape bodyIsEmpty accepts above.
- if (line.to < state.doc.length) {
- const next = state.doc.lineAt(line.to + 1);
- const closerLine = state.doc.lineAt(marks[marks.length - 1].from);
- if (next.number < closerLine.number && /^[>\s]*$/.test(next.text)) {
- view.dispatch({
- selection: EditorSelection.cursor(next.to),
- scrollIntoView: true,
- userEvent: "select",
- });
- return true;
- }
- }
- return false;
- }
-
- const fence = state.sliceDoc(open.from, open.to);
- const indent = state.sliceDoc(line.from, open.from);
- view.dispatch({
- changes: { from: line.to, insert: `${state.lineBreak}${state.lineBreak}${indent}${fence}` },
- selection: EditorSelection.cursor(line.to + state.lineBreak.length),
- scrollIntoView: true,
- userEvent: "input",
- });
- return true;
-};
-
-/** Enter on the block's empty last content line exits below the fence. */
-export const fenceExitBlock: Command = (view) => {
- const { state } = view;
- const { main } = state.selection;
- if (!main.empty) return false;
- const line = state.doc.lineAt(main.head);
- if (line.from !== line.to) return false; // only a fully empty line exits
-
- const node = fenceAt(state, main.head);
- if (!node || node.from >= line.from) return false;
- const marks = node.getChildren("CodeMark");
- if (marks.length < 2) return false; // unclosed: Enter just adds code lines
- const closing = marks[marks.length - 1];
- // The empty line must sit directly above the closing fence line.
- if (state.doc.lineAt(closing.from).number !== line.number + 1) return false;
-
- const docLen = state.doc.length;
- const changes = [{ from: line.from - 1, to: line.to, insert: "" }] as {
- from: number;
- to?: number;
- insert: string;
- }[];
- if (node.to === docLen) changes.push({ from: docLen, insert: state.lineBreak });
- const removed = line.to - line.from + 1;
- view.dispatch({
- changes,
- // Just past the (shifted) closing fence's newline — the line below.
- selection: EditorSelection.cursor(node.to - removed + 1),
- scrollIntoView: true,
- userEvent: "input",
- });
- return true;
-};
-
-export const fenceAutoCloseKeymap = Prec.highest(
- keymap.of([
- { key: "Enter", run: fenceAutoClose },
- { key: "Enter", run: fenceExitBlock },
- ]),
-);
diff --git a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts b/packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts
deleted file mode 100644
index 4f4b7e8..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-// @vitest-environment jsdom
-/**
- * §12.9 — the caret never parks on a closed fence's marker rows.
- *
- * User report: the opener row accepted the caret, and the first keystroke
- * visibly jumped to the second line (the §12.7 re-site). Instead, clicks on
- * a marker row land on the nearest content edge and arrow motion crossing a
- * marker row exits the block, so typing always happens where the caret is.
- */
-import { afterEach, describe, expect, it } from "vitest";
-import { EditorSelection } from "@codemirror/state";
-import type { EditorView } from "@codemirror/view";
-
-import { destroyEditors, makeEditor } from "../core/editorTestHarness";
-import { fenceCaretGuard } from "./fenceCaretGuard";
-
-function place(view: EditorView, pos: number, userEvent = "select"): number {
- view.dispatch({ selection: EditorSelection.cursor(pos), userEvent });
- return view.state.selection.main.head;
-}
-
-describe("caret placement on fence marker rows (§12.9)", () => {
- afterEach(destroyEditors);
-
- const doc = "alpha\n```js\ncode\nmore\n```\nomega";
- const openerStart = doc.indexOf("```js");
- const contentStart = doc.indexOf("code");
- const lastContentEnd = doc.indexOf("more") + "more".length;
- const closerStart = doc.indexOf("\n```\n") + 1;
-
- it("a click on the opener row enters the first content line", () => {
- const view = makeEditor(doc, 0, [fenceCaretGuard]);
- expect(place(view, openerStart + 2, "select.pointer")).toBe(contentStart);
- });
-
- it("a click on the closer row lands at the last content line's end", () => {
- const view = makeEditor(doc, 0, [fenceCaretGuard]);
- expect(place(view, closerStart + 1, "select.pointer")).toBe(lastContentEnd);
- });
-
- it("forward motion onto the opener continues into content", () => {
- const view = makeEditor(doc, "alpha".length, [fenceCaretGuard]);
- expect(place(view, openerStart + 1)).toBe(contentStart);
- });
-
- it("backward motion onto the opener exits above the block", () => {
- const view = makeEditor(doc, contentStart, [fenceCaretGuard]);
- expect(place(view, openerStart + 2)).toBe("alpha".length);
- });
-
- it("forward motion onto the closer exits below the block", () => {
- const view = makeEditor(doc, lastContentEnd, [fenceCaretGuard]);
- expect(place(view, closerStart + 1)).toBe(doc.indexOf("omega"));
- });
-
- it("at a document-ending block, forward motion holds at the content edge", () => {
- const endDoc = "```\ncode\n```";
- const view = makeEditor(endDoc, endDoc.indexOf("code") + 4, [fenceCaretGuard]);
- expect(place(view, endDoc.length - 1)).toBe(endDoc.indexOf("code") + 4);
- });
-
- it("an unclosed opener keeps accepting the caret (language flow)", () => {
- const view = makeEditor("```j\ntext below", 0, [fenceCaretGuard]);
- expect(place(view, 4, "select.pointer")).toBe(4);
- });
-
- it("a block with no content line is left alone", () => {
- const bare = "```\n```";
- const view = makeEditor(bare, 0, [fenceCaretGuard]);
- expect(place(view, 2, "select.pointer")).toBe(2);
- });
-
- it("range selections across the block are untouched", () => {
- const view = makeEditor(doc, 0, [fenceCaretGuard]);
- view.dispatch({
- selection: EditorSelection.range(0, closerStart + 2),
- userEvent: "select.pointer",
- });
- expect(view.state.selection.main.to).toBe(closerStart + 2);
- });
-});
-
-describe("no-content blocks are exempt — the language-typing state (§12.9/§12.4)", () => {
- afterEach(destroyEditors);
-
- it("arrowing within the opener row of an empty-body fence keeps the caret", () => {
- const doc = "```mermiad\n\n```";
- const openerEnd = doc.indexOf("\n");
- const view = makeEditor(doc, openerEnd, [fenceCaretGuard]);
- // A selection-only backward step (ArrowLeft toward the typo) must not be
- // re-sited off the row.
- expect(place(view, openerEnd - 1)).toBe(openerEnd - 1);
- expect(place(view, openerEnd - 4)).toBe(openerEnd - 4);
- });
-
- it("the exemption covers quote-prefixed blank bodies too", () => {
- const doc = "> ```js\n> \n> ```";
- const openerEnd = doc.indexOf("\n");
- const view = makeEditor(doc, openerEnd, [fenceCaretGuard]);
- expect(place(view, openerEnd - 1)).toBe(openerEnd - 1);
- });
-
- it("a fence WITH content still evicts the caret from its opener row", () => {
- const doc = "```js\ncode\n```";
- const view = makeEditor(doc, 0, [fenceCaretGuard]);
- const contentStart = doc.indexOf("code");
- expect(place(view, 2, "select.pointer")).toBe(contentStart);
- });
-});
-
diff --git a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts b/packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts
deleted file mode 100644
index fd141cd..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceCaretGuard.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * §12.9 — a closed fence's marker rows never hold the caret.
- *
- * The opener/closer lines are block chrome: a caret parked there types
- * somewhere else (the §12.7 re-site), which reads as a jump. Instead the
- * caret is re-placed the moment a selection lands on a marker row:
- *
- * - pointer clicks: nearest content edge (opener → first content line's
- * start, closer → last content line's end)
- * - forward motion (Down/Right): through the row into the block, or out
- * below it; holds at the content edge when nothing follows the block
- * - backward motion (Up/Left): out above the block, or back to content
- *
- * A fence currently rendered as a BLOCK WIDGET (a mermaid diagram) inverts
- * the rule: moving the caret INTO its content would reveal the source — so a
- * click landing beside the widget flipped the diagram to code. Those fences
- * snap the caret to the boundary OUTSIDE the block instead; entering one is
- * a deliberate act (double-click / Edit chip / click-to-edit).
- *
- * Unclosed fences keep the caret — typing a language on a pasted opener
- * needs it — and blocks with no content line are left alone (§12.7 re-sites
- * any typing there safely). Range selections and multi-cursor pass through.
- */
-
-import { EditorSelection, EditorState, type Transaction } from "@codemirror/state";
-
-import { fenceAt } from "./fenceAutoClose";
-import { mermaidField } from "../mermaid/mermaidPlugin";
-
-/** Is this fence currently replaced by a rendered block widget? */
-function widgetCovered(state: EditorState, from: number, to: number): boolean {
- const field = state.field(mermaidField, false);
- if (!field) return false;
- let covered = false;
- field.decorations.between(from, to, () => {
- covered = true;
- return false;
- });
- return covered;
-}
-
-export const fenceCaretGuard = EditorState.transactionFilter.of((tr: Transaction) => {
- if (tr.docChanged || !tr.selection) return tr;
- if (tr.selection.ranges.length > 1) return tr;
- const sel = tr.selection.main;
- if (!sel.empty) return tr;
-
- const state = tr.startState;
- const line = state.doc.lineAt(sel.head);
- if (line.to === line.from) return tr;
- const node = fenceAt(state, line.to);
- if (!node) return tr;
- const marks = node.getChildren("CodeMark");
- if (marks.length < 2) return tr;
-
- const openerLine = state.doc.lineAt(node.from);
- const closerLine = state.doc.lineAt(marks[marks.length - 1].from);
- if (closerLine.number - openerLine.number < 2) return tr;
- const onOpener = line.number === openerLine.number;
- if (!onOpener && line.number !== closerLine.number) return tr;
-
- const covered = widgetCovered(state, node.from, node.to);
- // §12.9 exempts no-content blocks, and a body of only blank (or
- // quote-prefixed blank) lines is no content — the shape type-time
- // auto-close leaves while the language is still being typed on the
- // opener, where arrowing over a typo must not throw the caret out.
- // Widget-rendered fences keep the outside-snap: their opener is hidden
- // chrome regardless of body.
- if (!covered) {
- let bodyEmpty = true;
- for (let n = openerLine.number + 1; n < closerLine.number; n += 1) {
- if (!/^[>\s]*$/.test(state.doc.line(n).text)) {
- bodyEmpty = false;
- break;
- }
- }
- if (bodyEmpty) return tr;
- }
-
- const oldHead = state.selection.main.head;
- const pointer = tr.isUserEvent("select.pointer");
- const backward = !pointer && sel.head < oldHead;
-
- let target: number;
- if (covered) {
- target = onOpener ? openerLine.from : closerLine.to;
- } else {
- const contentStart = openerLine.to + 1;
- const lastContentEnd = closerLine.from - 1;
- if (onOpener) {
- target = backward && openerLine.from > 0 ? openerLine.from - 1 : contentStart;
- } else if (pointer || backward) {
- target = lastContentEnd;
- } else {
- target = closerLine.to < state.doc.length ? closerLine.to + 1 : lastContentEnd;
- }
- }
- if (target === sel.head) return tr;
- return [tr, { selection: EditorSelection.cursor(target), sequential: true }];
-});
diff --git a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts b/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts
deleted file mode 100644
index 024ba09..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-// @vitest-environment jsdom
-// interaction-spec §12.1–.3 — fence lines are structure, not text.
-import { afterEach, describe, expect, it } from "vitest";
-
-import { visibleBackspace, visibleDeleteForward } from "../interaction/deleteNormalizer";
-import { destroyEditors, makeEditor, text } from "../core/editorTestHarness";
-
-describe("fence delete walls (§12.1)", () => {
- afterEach(destroyEditors);
-
- it("backspace at the first content line start is a no-op", () => {
- const doc = "above\n```\ncode\n```";
- const view = makeEditor(doc, doc.indexOf("code"));
- expect(visibleBackspace(view)).toBe(true);
- expect(text(view)).toBe(doc);
- });
-
- it("forward-delete at the last content line end is a no-op", () => {
- const doc = "```\ncode\n```\nafter";
- const view = makeEditor(doc, doc.indexOf("code") + 4);
- expect(visibleDeleteForward(view)).toBe(true);
- expect(text(view)).toBe(doc);
- });
-
- it("interior code lines join plainly — no list-prefix eating", () => {
- // "- looks like a bullet" inside code is code; the join must remove just
- // the newline, never a pseudo list marker.
- const doc = "```\nfirst\n- second\n```";
- const view = makeEditor(doc, doc.indexOf("- second"));
- visibleBackspace(view);
- expect(text(view)).toBe("```\nfirst- second\n```");
- });
-
- it("backspacing into an effectively empty block deletes it whole", () => {
- const doc = "above\n```\n\n```";
- const view = makeEditor(doc, doc.indexOf("\n\n```") + 1); // caret on the empty content line
- visibleBackspace(view);
- // A trailing block takes its preceding newline with it — no dangling
- // empty line; the caret rests at the end of the text above.
- expect(text(view)).toBe("above");
- expect(view.state.selection.main.head).toBe("above".length);
- });
-});
-
-describe("fence two-step approach (§12.2)", () => {
- afterEach(destroyEditors);
-
- it("backspace after the block parks at the content end, no edit", () => {
- const doc = "```\ncode\n```\nafter";
- const view = makeEditor(doc, doc.indexOf("after"));
- expect(visibleBackspace(view)).toBe(true);
- expect(text(view)).toBe(doc);
- expect(view.state.selection.main.head).toBe(doc.indexOf("code") + 4);
- });
-
- it("a second backspace (now inside) deletes code, not the fence", () => {
- const doc = "```\ncode\n```\nafter";
- const view = makeEditor(doc, doc.indexOf("after"));
- visibleBackspace(view); // park
- visibleBackspace(view); // delete 'e'
- expect(text(view)).toBe("```\ncod\n```\nafter");
- });
-
- it("forward-delete before the block parks at the content start", () => {
- const doc = "before\n```\ncode\n```";
- const view = makeEditor(doc, "before".length);
- expect(visibleDeleteForward(view)).toBe(true);
- expect(text(view)).toBe(doc);
- expect(view.state.selection.main.head).toBe(doc.indexOf("code"));
- });
-
- it("an empty block approached from below is deleted whole", () => {
- const doc = "```\n\n```\nafter";
- const view = makeEditor(doc, doc.indexOf("after"));
- visibleBackspace(view);
- expect(text(view)).toBe("after");
- });
-});
-
-describe("fence line above (§12.3)", () => {
- afterEach(destroyEditors);
-
- it("backspace on an empty line above a block removes that line", () => {
- const doc = "above\n\n```\ncode\n```";
- const view = makeEditor(doc, doc.indexOf("\n```"));
- visibleBackspace(view);
- expect(text(view)).toBe("above\n```\ncode\n```");
- });
-});
-
-describe("fences below the fold (§12.1)", () => {
- afterEach(destroyEditors);
-
- /** A document whose code block sits past the parsed viewport. */
- function longDocWithFenceAtEnd() {
- const filler = Array.from({ length: 400 }, (_, i) => `paragraph line ${i}`).join("\n\n");
- return `${filler}\n\n\`\`\`\ncode\n\`\`\``;
- }
-
- it("walls the same at the end of a long document as at the top of a short one", () => {
- // `syntaxTree` only returns what the viewport has driven the parser
- // through — about 3,000 characters here, against a document of 8,000 — so
- // asking it about a fence further down answered "there is no fence" and
- // backspace merged the block into the prose above.
- //
- // The same gap is what made these tests flaky: jsdom reports a viewport of
- // a few hundred characters whatever the document holds, so whether the
- // parse reached the caret came down to timing under load.
- const doc = longDocWithFenceAtEnd();
- const view = makeEditor(doc, doc.lastIndexOf("code"));
-
- expect(visibleBackspace(view)).toBe(true);
- expect(text(view)).toBe(doc);
- });
-
- it("still deletes inside that block rather than walling everything", () => {
- // The wall must not become "backspace does nothing down here".
- const doc = longDocWithFenceAtEnd();
- const view = makeEditor(doc, doc.lastIndexOf("code") + 4);
-
- visibleBackspace(view);
- expect(text(view)).toBe(doc.replace(/code$/m, "cod"));
- });
-});
-
diff --git a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts b/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts
deleted file mode 100644
index bb1c250..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceDeleteGuards.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-/**
- * Fence walls for deletion (interaction-spec §12.1–.3).
- *
- * Fence lines are structure, not text: a character-level join that merges a
- * fence line with its neighbor corrupts the pair — content becomes the
- * opener's invisible info string, or the closer gains trailing text and
- * stops closing, re-pairing the opener with a later fence and swallowing
- * unrelated content. The delete normalizer consults these guards whenever a
- * Backspace/Delete would cross a line boundary; they answer with the §12
- * behavior (wall, park, plain code-line join, or whole-block deletion) and
- * report whether they handled the press.
- */
-
-import { EditorSelection, type EditorState } from "@codemirror/state";
-import type { EditorView } from "@codemirror/view";
-
-import { fenceAt } from "./fenceAutoClose";
-
-interface BlockInfo {
- openerLine: { number: number; to: number };
- closerLine: { number: number; from: number } | null;
- /** First/last content positions (line-granular). */
- contentFrom: number;
- contentTo: number;
- contentEmpty: boolean;
-}
-
-type FenceNode = NonNullable>;
-
-function blockInfo(state: EditorState, node: FenceNode): BlockInfo {
- const marks = node.getChildren("CodeMark");
- const openerLine = state.doc.lineAt(node.from);
- const closerMark = marks.length >= 2 ? marks[marks.length - 1] : null;
- const closerLine = closerMark ? state.doc.lineAt(closerMark.from) : null;
- const contentFrom = Math.min(openerLine.to + 1, node.to);
- const contentTo = closerLine ? Math.max(closerLine.from - 1, contentFrom) : node.to;
- const contentEmpty =
- !closerLine ||
- contentFrom >= closerLine.from ||
- state.doc.sliceString(contentFrom, contentTo).trim() === "";
- return { openerLine, closerLine, contentFrom, contentTo, contentEmpty };
-}
-
-function park(view: EditorView, pos: number): true {
- view.dispatch({
- selection: EditorSelection.cursor(pos),
- scrollIntoView: true,
- userEvent: "select",
- });
- return true;
-}
-
-function deleteWholeBlock(view: EditorView, node: FenceNode, userEvent: string): true {
- const docLen = view.state.doc.length;
- // Take one bounding newline with the block so no empty line is left behind.
- const to = node.to < docLen ? node.to + 1 : node.to;
- const from = node.to >= docLen && node.from > 0 ? node.from - 1 : node.from;
- view.dispatch({
- changes: { from, to, insert: "" },
- selection: EditorSelection.cursor(from),
- scrollIntoView: true,
- userEvent,
- });
- return true;
-}
-
-function joinNewline(view: EditorView, at: number, userEvent: string): true {
- view.dispatch({
- changes: { from: at, to: at + 1, insert: "" },
- selection: EditorSelection.cursor(at),
- scrollIntoView: true,
- userEvent,
- });
- return true;
-}
-
-/** Backspace whose deletion would cross upward out of `line`. True when the
- * press was handled (edit, park, or deliberate wall no-op). */
-export function fenceBackspaceGuard(view: EditorView, pos: number): boolean {
- const { state } = view;
- const line = state.doc.lineAt(pos);
- if (line.from === 0) return false;
- const prevLine = state.doc.lineAt(line.from - 1);
-
- const inside = fenceAt(state, pos);
- if (inside) {
- const info = blockInfo(state, inside);
- if (line.number === info.openerLine.number) {
- // Caret on the opener line: joining it onto the prose above dissolves
- // the fence. Move up instead.
- return park(view, prevLine.to);
- }
- if (info.closerLine && line.number === info.closerLine.number) {
- // Caret on the closer line: pulling it up corrupts the pair.
- return park(view, prevLine.to);
- }
- if (prevLine.number === info.openerLine.number) {
- // §12.1 — first content line: the wall is solid; an effectively empty
- // block collapses whole instead.
- if (info.contentEmpty) return deleteWholeBlock(view, inside, "delete.backward");
- return true;
- }
- // Interior code lines join plainly — never through blockPrefixLength,
- // which would eat code that merely looks like a list marker.
- return joinNewline(view, line.from - 1, "delete.backward");
- }
-
- const prevFence = fenceAt(state, prevLine.to);
- if (prevFence) {
- const info = blockInfo(state, prevFence);
- if (info.closerLine && prevLine.number === info.closerLine.number) {
- // §12.2 — approaching from below: park at the content end (or collapse
- // an empty block whole).
- if (info.contentEmpty) return deleteWholeBlock(view, prevFence, "delete.backward");
- return park(view, info.contentTo);
- }
- }
- return false;
-}
-
-/** Forward-delete whose deletion would cross downward out of `line`. */
-export function fenceDeleteGuard(view: EditorView, pos: number): boolean {
- const { state } = view;
- const line = state.doc.lineAt(pos);
- if (line.to >= state.doc.length) return false;
- const nextLine = state.doc.lineAt(line.to + 1);
-
- const inside = fenceAt(state, pos);
- if (inside) {
- const info = blockInfo(state, inside);
- if (line.number === info.openerLine.number) {
- // Caret on the opener: pulling content up makes it the invisible info
- // string. Step into the block instead.
- return park(view, nextLine.from);
- }
- if (info.closerLine && line.number === info.closerLine.number) {
- return park(view, nextLine.from);
- }
- if (info.closerLine && nextLine.number === info.closerLine.number) {
- // §12.1 mirror — last content line.
- if (info.contentEmpty) return deleteWholeBlock(view, inside, "delete.forward");
- return true;
- }
- return joinNewline(view, line.to, "delete.forward");
- }
-
- const nextFence = fenceAt(state, Math.min(nextLine.to, state.doc.length));
- if (nextFence && state.doc.lineAt(nextFence.from).number === nextLine.number) {
- const info = blockInfo(state, nextFence);
- // §12.2 mirror — approaching from above: park at the content start.
- if (info.contentEmpty) return deleteWholeBlock(view, nextFence, "delete.forward");
- return park(view, info.contentFrom);
- }
- return false;
-}
diff --git a/packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts b/packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts
deleted file mode 100644
index 8bfe5d5..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceLineTyping.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-// @vitest-environment jsdom
-/**
- * interaction-spec §12.7 (TDD, red-first) — typing on a fence's CLOSING line
- * can never edit the fence: a closing fence with trailing text stops closing
- * (CommonMark allows no info string there), the block re-opens and swallows
- * everything below. The intent of typing on the block's last gray row is
- * "code at the end of the block" — so the keystroke lands on a fresh content
- * line before the closer.
- */
-import { afterEach, describe, expect, it } from "vitest";
-import { syntaxTree } from "@codemirror/language";
-import type { EditorView } from "@codemirror/view";
-
-import { destroyEditors, makeEditor, text } from "../core/editorTestHarness";
-import { fenceTypeAutoClose } from "./fenceAutoClose";
-
-function typeChar(view: EditorView, ch: string): void {
- const head = view.state.selection.main.head;
- view.dispatch({
- changes: { from: head, insert: ch },
- selection: { anchor: head + ch.length },
- userEvent: "input.type",
- });
-}
-
-/** Number of FencedCode nodes and the end of the first one. */
-function fenceShape(view: EditorView): { count: number; firstEnd: number } {
- let count = 0;
- let firstEnd = -1;
- syntaxTree(view.state).iterate({
- enter: (n) => {
- if (n.name === "FencedCode") {
- count += 1;
- if (firstEnd < 0) firstEnd = n.to;
- }
- },
- });
- return { count, firstEnd };
-}
-
-describe("typing on the closing fence line (§12.7)", () => {
- afterEach(destroyEditors);
-
- it("a char typed at the closer's end lands on a new last content line", () => {
- const doc = "```\ncode\n```\n\nCalibrate";
- const view = makeEditor(doc, doc.indexOf("\n\nCalibrate"), [fenceTypeAutoClose]);
- typeChar(view, "l");
- expect(text(view)).toBe("```\ncode\nl\n```\n\nCalibrate");
- // The block still closes where it should — nothing below is swallowed.
- const shape = fenceShape(view);
- expect(shape.count).toBe(1);
- expect(view.state.sliceDoc(shape.firstEnd)).toBe("\n\nCalibrate");
- });
-
- it("a char typed at the closer's start lands the same way", () => {
- const doc = "```\ncode\n```";
- const v = makeEditor(doc, doc.lastIndexOf("```"), [fenceTypeAutoClose]);
- typeChar(v, "x");
- expect(text(v)).toBe("```\ncode\nx\n```");
- });
-
- it("caret follows onto the new content line", () => {
- const doc = "```\ncode\n```";
- const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]);
- typeChar(view, "z");
- const head = view.state.selection.main.head;
- expect(view.state.doc.lineAt(head).text).toBe("z");
- expect(head).toBe(view.state.doc.lineAt(head).to);
- });
-
- it("quote-nested closer keeps the quote prefix on the new line", () => {
- const doc = "> ```\n> code\n> ```";
- const view = makeEditor(doc, doc.length, [fenceTypeAutoClose]);
- typeChar(view, "q");
- expect(text(view)).toBe("> ```\n> code\n> q\n> ```");
- });
-
- it("typing on a closed opener row lands at the first content line (§12.7)", () => {
- const doc = "```\ncode\n```";
- const view = makeEditor(doc, 3, [fenceTypeAutoClose]);
- typeChar(view, "j");
- expect(text(view)).toBe("```\njcode\n```");
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts b/packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts
deleted file mode 100644
index a3d03f6..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceOpenerTyping.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-// @vitest-environment jsdom
-/**
- * §12.7 extension + §12.8 (TDD, red-first).
- *
- * Opener row: users keep clicking a block's first gray row to type CODE —
- * three separate reports. On a CLOSED fence, characters typed on the opener
- * line land on the first content line instead of silently extending the
- * (differently-styled) language tag. The pill still renders existing tags;
- * editing one moves to RAW mode.
- *
- * Tab: inside a code block, Tab must indent (and Shift-Tab dedent) — with no
- * binding, the browser's focus navigation stole the key and the caret
- * "jumped out of the editor to the rich/raw button".
- */
-import { afterEach, describe, expect, it } from "vitest";
-import type { EditorView } from "@codemirror/view";
-
-import { destroyEditors, makeEditor, text } from "../core/editorTestHarness";
-import { fenceTypeAutoClose } from "./fenceAutoClose";
-import { fenceTabIndent, fenceTabDedent } from "./fenceTabIndent";
-
-function typeChar(view: EditorView, ch: string): void {
- const head = view.state.selection.main.head;
- view.dispatch({
- changes: { from: head, insert: ch },
- selection: { anchor: head + ch.length },
- userEvent: "input.type",
- });
-}
-
-describe("typing on a closed fence's opener row (§12.7)", () => {
- afterEach(destroyEditors);
-
- it("a char at the opener's end lands at the start of the first content line", () => {
- const doc = "```js\ncode\n```";
- const view = makeEditor(doc, "```js".length, [fenceTypeAutoClose]);
- typeChar(view, "x");
- expect(text(view)).toBe("```js\nxcode\n```");
- const head = view.state.selection.main.head;
- expect(head).toBe("```js\nx".length);
- });
-
- it("a char at the opener line's very START re-sites too (node-boundary click)", () => {
- // Clicking the block's first gray row often lands the caret at column 0 —
- // exactly on the fence node's from-boundary. Reported live: "kfj" typed
- // there landed BEFORE the backticks and un-fenced the whole block.
- const doc = "```\nfjfjf\n```";
- const view = makeEditor(doc, 0, [fenceTypeAutoClose]);
- typeChar(view, "k");
- typeChar(view, "f");
- typeChar(view, "j");
- expect(text(view)).toBe("```\nkfjfjfjf\n```");
- });
-
- it("opener-start typing works when the fence follows other content", () => {
- const doc = "alpha\n```\ncode\n```";
- const view = makeEditor(doc, "alpha\n".length, [fenceTypeAutoClose]);
- typeChar(view, "x");
- expect(text(view)).toBe("alpha\n```\nxcode\n```");
- });
-
- it("an unclosed opener still accepts language typing (paste flow)", () => {
- const doc = "```j\ncode below";
- const view = makeEditor(doc, "```j".length, [fenceTypeAutoClose]);
- typeChar(view, "s");
- expect(text(view)).toBe("```js\ncode below");
- });
-});
-
-describe("Tab inside a code block indents (§12.8)", () => {
- afterEach(destroyEditors);
-
- it("Tab inserts an indent unit at the caret", () => {
- const doc = "```\ncode\n```";
- const view = makeEditor(doc, doc.indexOf("code"));
- expect(fenceTabIndent(view)).toBe(true);
- expect(text(view)).toBe("```\n code\n```");
- });
-
- it("Shift-Tab removes leading indent from the line", () => {
- const doc = "```\n code\n```";
- const view = makeEditor(doc, doc.indexOf("code") + 2);
- expect(fenceTabDedent(view)).toBe(true);
- expect(text(view)).toBe("```\ncode\n```");
- });
-
- it("outside code, both decline so lists and focus keep their behavior", () => {
- const doc = "plain text";
- const view = makeEditor(doc, 2);
- expect(fenceTabIndent(view)).toBe(false);
- expect(fenceTabDedent(view)).toBe(false);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/fenceTabIndent.ts b/packages/rich-editor/src/codemirror/code/fenceTabIndent.ts
deleted file mode 100644
index fd20d15..0000000
--- a/packages/rich-editor/src/codemirror/code/fenceTabIndent.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Tab inside a fenced code block indents; Shift-Tab dedents (§12.8).
- *
- * Without a binding, the browser's default Tab handling moves FOCUS — the
- * caret "jumped out of the editor to the rich/raw button" mid-code. Both
- * commands decline outside code so list indentation (`listIndent.ts`) and
- * the accessibility default keep their behavior everywhere else.
- */
-
-import { EditorSelection, Prec } from "@codemirror/state";
-import { type Command, keymap } from "@codemirror/view";
-
-import { fenceAt } from "./fenceAutoClose";
-
-const INDENT = " ";
-
-/** Caret strictly inside a fence (not on the opener/closer lines). */
-function inFenceContent(view: Parameters[0]): boolean {
- const { state } = view;
- const head = state.selection.main.head;
- const node = fenceAt(state, head);
- if (!node) return false;
- const line = state.doc.lineAt(head);
- const marks = node.getChildren("CodeMark");
- if (line.from <= node.from) return false;
- const closer = marks.length >= 2 ? marks[marks.length - 1] : null;
- return !closer || line.to < state.doc.lineAt(closer.from).from;
-}
-
-export const fenceTabIndent: Command = (view) => {
- if (!inFenceContent(view)) return false;
- const head = view.state.selection.main.head;
- view.dispatch({
- changes: { from: head, insert: INDENT },
- selection: EditorSelection.cursor(head + INDENT.length),
- userEvent: "input.type",
- });
- return true;
-};
-
-export const fenceTabDedent: Command = (view) => {
- if (!inFenceContent(view)) return false;
- const { state } = view;
- const line = state.doc.lineAt(state.selection.main.head);
- const leading = line.text.match(/^ {1,2}/)?.[0].length ?? 0;
- if (leading === 0) return true;
- view.dispatch({
- changes: { from: line.from, to: line.from + leading, insert: "" },
- userEvent: "delete.dedent",
- });
- return true;
-};
-
-export const fenceTabKeymap = Prec.high(
- keymap.of([
- { key: "Tab", run: fenceTabIndent },
- { key: "Shift-Tab", run: fenceTabDedent },
- ]),
-);
diff --git a/packages/rich-editor/src/codemirror/code/highlightFence.test.ts b/packages/rich-editor/src/codemirror/code/highlightFence.test.ts
deleted file mode 100644
index c8fa086..0000000
--- a/packages/rich-editor/src/codemirror/code/highlightFence.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// @vitest-environment jsdom
-import { describe, expect, it } from "vitest";
-
-import { languages } from "@codemirror/language-data";
-
-import { highlightFenceSpans } from "./highlightFence";
-
-describe("highlightFenceSpans", () => {
- it("returns inline-styled spans once the grammar is loaded (the sync path)", async () => {
- await languages.find((l) => l.name === "JavaScript")!.load();
-
- const spans = highlightFenceSpans("js", "const x = 'hi' // note");
-
- expect(spans).not.toBeNull();
- const keyword = spans!.find((span) => span.text === "const");
- expect(keyword?.style).toContain("color:#a626a4");
- const string = spans!.find((span) => span.text === "'hi'");
- expect(string?.style).toContain("color:#50a14f");
- const comment = spans!.find((span) => span.text === "// note");
- expect(comment?.style).toContain("font-style:italic");
- // Round-trip: concatenated spans reproduce the source exactly.
- expect(spans!.map((s) => s.text).join("")).toBe("const x = 'hi' // note");
- });
-
- it("returns null for an unknown language", () => {
- expect(highlightFenceSpans("nosuchlang", "x")).toBeNull();
- });
-
- it("returns null (and kicks the load) for a not-yet-loaded grammar", () => {
- // Erlang is obscure enough that nothing else in the suite loads it.
- const cold = languages.find((l) => l.name === "Erlang")!;
- expect(cold.support).toBeUndefined();
- expect(highlightFenceSpans("erlang", "x")).toBeNull();
- });
-});
diff --git a/packages/rich-editor/src/codemirror/code/highlightFence.ts b/packages/rich-editor/src/codemirror/code/highlightFence.ts
deleted file mode 100644
index f32ec79..0000000
--- a/packages/rich-editor/src/codemirror/code/highlightFence.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * Synchronous fence highlighting for the clipboard (#149).
- *
- * A copy event writes `text/html` synchronously, so highlighting must not
- * await anything. The trick: the grammars are the SAME `@codemirror/language-data`
- * singletons the editor's nested fence parse loads — any fence visible in the
- * editor has its grammar warm, so highlighting the copied code is a sync parse.
- * A cold grammar (copy from a doc whose fence never rendered) kicks the load
- * and returns null: THIS copy ships plain, the next one is highlighted.
- *
- * Output is inline-styled spans (the palette from
- * [codePalette](./codePalette.ts), same colors as the editor) — pasted HTML
- * carries no stylesheet, so classes would be dead weight in Docs/Word.
- */
-
-import { LanguageDescription } from "@codemirror/language";
-import { languages } from "@codemirror/language-data";
-import { highlightCode, tagHighlighter } from "@lezer/highlight";
-
-import { CODE_PALETTE } from "./codePalette";
-
-export interface HighlightedSpan {
- text: string;
- /** Inline CSS for the span; absent for unstyled text (incl. line breaks). */
- style?: string;
-}
-
-const styleByClass = new Map(
- CODE_PALETTE.map((spec, index) => [
- `tok${index}`,
- `color:${spec.color}${spec.fontStyle ? `;font-style:${spec.fontStyle}` : ""}`,
- ]),
-);
-
-// tagHighlighter implements lezer's tag-containment matching (a token tagged
-// `function(variableName)` matches a spec on either tag) — hand-rolling that
-// gets the precedence subtly wrong.
-const paletteHighlighter = tagHighlighter(
- CODE_PALETTE.map((spec, index) => ({ tag: spec.tag as never, class: `tok${index}` })),
-);
-
-function styleFor(classes: string): string | undefined {
- const styles = classes
- .split(" ")
- .map((cls) => styleByClass.get(cls))
- .filter(Boolean);
- return styles.length > 0 ? styles.join(";") : undefined;
-}
-
-/** Highlight `code` as `lang` into inline-styled spans, or null when no
- * grammar matches or the grammar isn't loaded yet (the load is kicked so a
- * later copy succeeds). Never throws — a parser hiccup falls back to null. */
-export function highlightFenceSpans(lang: string, code: string): HighlightedSpan[] | null {
- const description = LanguageDescription.matchLanguageName(languages, lang, true);
- if (!description) return null;
- if (!description.support) {
- void description.load().catch(() => {});
- return null;
- }
- try {
- const parser = description.support.language.parser;
- const spans: HighlightedSpan[] = [];
- highlightCode(
- code,
- parser.parse(code),
- paletteHighlighter,
- (text, classes) => spans.push(classes ? { text, style: styleFor(classes) } : { text }),
- () => spans.push({ text: "\n" }),
- );
- return spans;
- } catch {
- return null;
- }
-}
diff --git a/packages/rich-editor/src/codemirror/code/index.ts b/packages/rich-editor/src/codemirror/code/index.ts
deleted file mode 100644
index 1b8a2b1..0000000
--- a/packages/rich-editor/src/codemirror/code/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Fenced code blocks: syntax colors, the opener-row language pill + chooser,
- * fence typing/deletion/caret guards, Tab indentation, and the synchronous
- * highlighter the clipboard uses. Internal: `codeLanguageMenu`, `codePalette`.
- */
-export * from "./codeHighlight";
-export * from "./codeLangAffordance";
-export * from "./fenceAutoClose";
-export * from "./fenceCaretGuard";
-export * from "./fenceDeleteGuards";
-export * from "./fenceTabIndent";
-export * from "./highlightFence";
diff --git a/packages/rich-editor/src/codemirror/core/codeContext.test.ts b/packages/rich-editor/src/codemirror/core/codeContext.test.ts
deleted file mode 100644
index e5b4efc..0000000
--- a/packages/rich-editor/src/codemirror/core/codeContext.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it } from "vitest";
-
-import { inCode, viewportTree } from "./codeContext";
-import { destroyEditors, makeEditor } from "./editorTestHarness";
-
-describe("inCode — grammar-level code-context test", () => {
- afterEach(destroyEditors);
-
- it("reports fenced, indented, and inline-code positions as code; prose as not", () => {
- const doc = "prose start\n\n```js\nlet x = 1\n```\n\n indented\n\nmix `span` end";
- const view = makeEditor(doc);
- const tree = viewportTree(view);
- expect(inCode(tree, doc.indexOf("prose"))).toBe(false);
- expect(inCode(tree, doc.indexOf("let"))).toBe(true);
- expect(inCode(tree, doc.indexOf("indented"))).toBe(true);
- expect(inCode(tree, doc.indexOf("span"))).toBe(true);
- expect(inCode(tree, doc.indexOf("end"))).toBe(false);
- });
-
- it("sees a fence nested inside a container (list item)", () => {
- const doc = "- item\n ```\n code here\n ```";
- const view = makeEditor(doc);
- const tree = viewportTree(view);
- expect(inCode(tree, doc.indexOf("item"))).toBe(false);
- expect(inCode(tree, doc.indexOf("code here"))).toBe(true);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/codeContext.ts b/packages/rich-editor/src/codemirror/core/codeContext.ts
deleted file mode 100644
index 4a0a816..0000000
--- a/packages/rich-editor/src/codemirror/core/codeContext.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * Grammar-level code-context test for the regex-scanning inline plugins
- * (wikilink, highlight, footnote, math). Lezer emits no nodes for those
- * constructs, so the plugins scan raw text — but code is literal, and the
- * scans must skip it, exactly as the export pre-pass does (`protected_regions`
- * in `src-tauri/src/export/html.rs`, derived from comrak's parse).
- *
- * A plugin guards a match by its OPENER and CLOSER positions: either sitting
- * in code disqualifies the match, while a code span strictly inside the body
- * (`[[a `b` c]]`) does not — the same rule the export applies.
- */
-
-import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
-import type { EditorState } from "@codemirror/state";
-import type { EditorView } from "@codemirror/view";
-
-type Tree = ReturnType;
-
-/** The slice of Lezer's SyntaxNode the ancestor walk touches. */
-type NodeLike = { readonly name: string; readonly parent: NodeLike | null };
-
-const CODE_NODES = new Set(["FencedCode", "CodeBlock", "InlineCode"]);
-
-/** The tree parsed through the viewport — the painter's forced-parse bound
- * (see `plugin.ts`), so a just-typed fence is already in the tree when a
- * viewport-scanning plugin consults the guard. */
-export function viewportTree(view: EditorView): Tree {
- return ensureSyntaxTree(view.state, view.viewport.to, 100) ?? syntaxTree(view.state);
-}
-
-/** The tree parsed through the whole document, for doc-wide state-level scans
- * (math). Falls back to the partial tree when the parse budget runs out —
- * the guard then degrades to the unguarded behavior past the parse frontier. */
-export function docTree(state: EditorState): Tree {
- return ensureSyntaxTree(state, state.doc.length, 20) ?? syntaxTree(state);
-}
-
-/** Whether the character at `pos` is code — inside a fenced or indented code
- * block or an inline code span. `resolveInner` descends into a fence's nested
- * `codeLanguages` parse; the parent walk crosses back into the markdown tree,
- * so the enclosing FencedCode still answers. */
-export function inCode(tree: Tree, pos: number): boolean {
- let node = tree.resolveInner(pos, 1) as unknown as NodeLike | null;
- for (; node; node = node.parent) {
- if (CODE_NODES.has(node.name)) return true;
- }
- return false;
-}
diff --git a/packages/rich-editor/src/codemirror/core/editorTestHarness.ts b/packages/rich-editor/src/codemirror/core/editorTestHarness.ts
deleted file mode 100644
index 316a3c9..0000000
--- a/packages/rich-editor/src/codemirror/core/editorTestHarness.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * Test-only harness: a headless CodeMirror `EditorView` wired with the markdown
- * language + the live-preview decoration plugin, for keystroke-level tests of
- * the editor commands (cursor motion, delete, list continuation, formatting).
- *
- * CodeMirror runs under jsdom; each test FILE that uses this must declare
- * `// @vitest-environment jsdom` at its top. The Lezer tree is built eagerly so
- * the plugin's atomic/hidden ranges (which the commands consult) are present.
- *
- * Not shipped — nothing in the app imports it, so it tree-shakes out.
- */
-
-import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
-import { ensureSyntaxTree } from "@codemirror/language";
-import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
-
-import { markdownDecorationsPlugin } from "./plugin";
-import {
- composeExtensions,
- footnoteExtension,
- highlightExtension,
- mathExtension,
- mermaidExtension,
- tableExtension,
- wikilinkExtension,
-} from "../extensions";
-
-const live: EditorView[] = [];
-
-// The full rendering extension set the real editor composes — so a rendered-
-// output test sees what the user sees (wikilinks, highlight, footnotes, math,
-// tables), not just the base markdown decorations.
-const FULL_EXTENSIONS = composeExtensions([
- wikilinkExtension,
- highlightExtension,
- footnoteExtension,
- mathExtension,
- mermaidExtension,
- tableExtension(),
-]).extensions;
-
-/** A headless editor over `doc` with the caret at `caret`. Extra extensions
- * (e.g. a keymap under test) can be appended. Track-and-cleanup via
- * {@link destroyEditors} in an `afterEach`. */
-export function makeEditor(doc: string, caret = 0, extra: Extension[] = []): EditorView {
- const parent = document.createElement("div");
- document.body.appendChild(parent);
- const state = EditorState.create({
- doc,
- selection: EditorSelection.cursor(caret),
- extensions: [markdown({ base: markdownLanguage }), markdownDecorationsPlugin, ...extra],
- });
- // The commands under test read the plugin's atomic/hidden ranges, which are
- // derived from the Lezer tree — so a tree that is not finished makes a guard
- // silently not fire, and the test fails as a confusing content diff rather
- // than as "the parse did not finish". `ensureSyntaxTree` reports that by
- // returning null; ignoring it is how a parse timeout became a mystery.
- if (ensureSyntaxTree(state, doc.length, 5000) === null) {
- throw new Error(
- `the markdown parse did not finish within 5s for a ${doc.length}-char document; ` +
- "any assertion after this would be testing an unparsed editor",
- );
- }
- const view = new EditorView({ parent, state });
- // Again, on the view's own state. Creating the view starts CodeMirror's
- // viewport-driven parse, and jsdom has no layout — so the viewport can be
- // tiny and `syntaxTree(view.state)` returns a tree that stops short of the
- // caret. A command asking "am I inside a fence?" then gets `null` and takes
- // the plain-text path, which is a wrong answer rather than a slow one.
- if (ensureSyntaxTree(view.state, doc.length, 5000) === null) {
- throw new Error(
- `the markdown parse did not finish within 5s for a ${doc.length}-char document; ` +
- "any assertion after this would be testing an unparsed editor",
- );
- }
- live.push(view);
- return view;
-}
-
-/** Like {@link makeEditor} but with every rendering extension the real editor
- * loads — for tests that assert the user-visible rendered output, not just the
- * source. */
-export function makeFullEditor(doc: string, caret = 0, extra: Extension[] = []): EditorView {
- return makeEditor(doc, caret, [...FULL_EXTENSIONS, ...extra]);
-}
-
-/** Tear down every editor made since the last call. Call in `afterEach`. */
-export function destroyEditors(): void {
- for (const view of live.splice(0)) {
- view.destroy();
- view.dom.parentElement?.remove();
- }
-}
-
-/** The current caret offset (head of the main selection). */
-export function caret(view: EditorView): number {
- return view.state.selection.main.head;
-}
-
-/** The current document text. */
-export function text(view: EditorView): string {
- return view.state.doc.toString();
-}
diff --git a/packages/rich-editor/src/codemirror/core/editorTheme.test.ts b/packages/rich-editor/src/codemirror/core/editorTheme.test.ts
deleted file mode 100644
index 710e323..0000000
--- a/packages/rich-editor/src/codemirror/core/editorTheme.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it } from "vitest";
-
-import { destroyEditors, makeEditor } from "./editorTestHarness";
-import { editorBaseTheme } from "./editorTheme";
-
-describe("editorBaseTheme", () => {
- afterEach(destroyEditors);
-
- it("mounts as a valid theme extension and still renders the document", () => {
- const view = makeEditor("# Heading", 0, [editorBaseTheme]);
- expect(view.dom.querySelector(".cm-content")?.textContent).toContain("Heading");
- });
-
- it("scopes its rules by adding a theme class to the editor wrapper", () => {
- const plain = makeEditor("x", 0);
- const themed = makeEditor("x", 0, [editorBaseTheme]);
- // EditorView.theme injects a generated host class onto `.cm-editor`,
- // so the themed wrapper carries one more class than the plain one.
- expect(themed.dom.classList.length).toBeGreaterThan(plain.dom.classList.length);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/editorTheme.ts b/packages/rich-editor/src/codemirror/core/editorTheme.ts
deleted file mode 100644
index 0e83422..0000000
--- a/packages/rich-editor/src/codemirror/core/editorTheme.ts
+++ /dev/null
@@ -1,534 +0,0 @@
-/**
- * Editor theme.
- *
- * Lives in `EditorView.baseTheme` rather than in `global.scss` for
- * three load-bearing reasons:
- *
- * 1. **Line-metric integrity.** CM6 measures line heights from the
- * DOM through a `requestMeasure` cycle to compute click → byte
- * and scroll → viewport mappings. Styles injected via
- * `EditorView.theme` participate in that cycle at the right
- * moment; styles from an external sheet can land *after* the
- * first measurement, so clicks land on the wrong line until the
- * next measure fires. (This was the click-drift the spike
- * shipped — fixed by moving here.)
- * 2. **Specificity that beats the base theme without `!important`.**
- * CM6 ships its monospace-and-purple base theme via the same
- * mechanism; layering `EditorView.theme` on top is the only
- * collision-free pattern.
- * 3. **No margin/padding on heading lines.** Margins between
- * `.cm-line` siblings shift visual position without changing
- * `offsetTop`, so the metric cache and the eye disagree —
- * that's the canonical click-on-wrong-line bug. We change
- * `font:` (which doesn't touch line-height), nothing else.
- *
- * Zettlr's `markdown-editor/theme/editor.ts` is the canonical
- * reference for this pattern in the open-source ecosystem.
- */
-
-import { EditorView } from "@codemirror/view";
-
-/**
- * `EditorView.theme` (not `baseTheme`) — `baseTheme` is the
- * low-priority slot meant for theme packages and gets out-specifity'd
- * by any app-level CSS (Carbon's globals in our case). `theme` is the
- * app-priority slot; our intent is to override CM6's defaults, so
- * this is the right tier.
- */
-export const editorBaseTheme = EditorView.theme({
- // CM6 paints a focus outline by default. The caret IS the focus
- // indicator in Compose; the box would be visual noise.
- "&.cm-focused": {
- outline: "none",
- },
-
- // Body type. Override CM6's monospace default *here* (not in the
- // external stylesheet) so the measurement cycle sees it.
- ".cm-content": {
- fontFamily:
- "var(--cds-body-01-font-family, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif)",
- fontSize: "1rem",
- // Tight line-height: the caret height tracks the line box
- // (line-height × font-size), so anything above 1.4 starts to
- // feel disproportionate. 1.4 is the value Zettlr uses for the
- // same reason.
- lineHeight: "1.4",
- color: "var(--cds-text-primary, #161616)",
- maxWidth: "48rem",
- margin: "0 auto",
- },
-
- // Scroller padding — kept here so it's part of the same theme
- // bundle as the content metrics.
- ".cm-scroller": {
- padding: "1.5rem 3rem",
- },
-
- // Drawn selection (selectionLayer.ts) — ranges paint from logical state so
- // the highlight survives the virtualized viewport. The widget tint sits
- // ABOVE block widgets (their opaque backgrounds hide the below-layer band).
- ".cm-selectionBackground": {
- background: "var(--cds-highlight, #d0e2ff)",
- },
- "&:not(.cm-focused) .cm-selectionBackground": {
- background: "var(--cds-layer-accent-01, #e0e0e0)",
- },
- ".cm-selectionWidgetTint": {
- background: "rgba(15, 98, 254, 0.08)",
- },
-
- // Caret stroke. CM6 owns the height (it tracks the current line-box,
- // so headings get a heading-sized caret automatically per spec 6.2).
- // Width is spec's 1 px at the default root size, in rem so it follows the
- // user's text-size preference; "thick caret" mode is a Phase-N follow-up.
- ".cm-cursor, .cm-dropCursor": {
- borderLeftWidth: "0.0625rem",
- },
-
- // Headings — `font:` shorthand changes size + weight *without*
- // setting line-height (it inherits the content's 1.5). The vertical
- // breathing room is `padding` not `margin` for the click-metric
- // reason above — sibling margins drift, sibling padding doesn't.
- ".cm-heading--1": {
- fontWeight: "700",
- fontSize: "2.0rem",
- lineHeight: "1.3",
- paddingTop: "0.6em",
- paddingBottom: "0.2em",
- },
- ".cm-heading--2": {
- fontWeight: "700",
- fontSize: "1.6rem",
- lineHeight: "1.3",
- paddingTop: "0.5em",
- paddingBottom: "0.15em",
- },
- ".cm-heading--3": {
- fontWeight: "700",
- fontSize: "1.3rem",
- lineHeight: "1.3",
- paddingTop: "0.4em",
- paddingBottom: "0.1em",
- },
- ".cm-heading--4": { fontWeight: "700", fontSize: "1.15rem", paddingTop: "0.3em" },
- ".cm-heading--5": { fontWeight: "700", fontSize: "1.05rem", paddingTop: "0.25em" },
- ".cm-heading--6": {
- fontWeight: "700",
- fontSize: "1.0rem",
- color: "var(--cds-text-secondary, #525252)",
- paddingTop: "0.2em",
- },
-
- // Inline / block styling. All sized by font + color only — never
- // by margin/padding, never by line-height override.
- ".cm-strong": { fontWeight: "700" },
- ".cm-emphasis": { fontStyle: "italic" },
- ".cm-strikethrough": { textDecoration: "line-through" },
-
- // Code backgrounds are translucent, never opaque: the drawn selection
- // (selectionLayer.ts) paints BELOW the content, so an opaque background here
- // blanks the highlight on exactly these rows/chips while the prose around
- // them tints. The alphas composite to the intended resting colors on the
- // white canvas — #e0e0e0 for the inline chip, #f6f8fa (GitHub-style) for
- // fence lines.
- ".cm-inline-code": {
- fontFamily:
- "var(--cds-code-01-font-family, ui-monospace, \"SF Mono\", Menlo, monospace)",
- fontSize: "0.92em",
- padding: "0 0.25em",
- background: "rgba(141, 141, 141, 0.27)",
- borderRadius: "0.1875rem",
- },
-
- ".cm-fenced-code": {
- fontFamily:
- "var(--cds-code-01-font-family, ui-monospace, \"SF Mono\", Menlo, monospace)",
- fontSize: "0.92em",
- background: "rgba(30, 80, 130, 0.04)",
- paddingLeft: "0.75em",
- paddingBottom: "0",
- },
- // Language tag on a fence's opener row — visible (never invisible typing)
- // and rendered as a small pill so it reads as the block's chrome, not as a
- // wrong-font first code line (§12.4/§12.7).
- ".cm-code-info": {
- color: "var(--cds-text-secondary, #6f6f6f)",
- fontSize: "0.75em",
- background: "var(--cds-layer-accent-01, #e8ebee)",
- padding: "0.1em 0.5em",
- borderRadius: "999rem",
- verticalAlign: "0.1em",
- cursor: "pointer",
- },
- ".cm-code-info:hover": {
- background: "var(--cds-layer-accent-hover-01, #d1d7dc)",
- color: "var(--cds-text-primary, #161616)",
- },
- // The languageless placeholder: same chip, dimmed, so every closed block
- // has a discoverable language control.
- ".cm-code-info--unset": {
- opacity: "0.55",
- fontStyle: "italic",
- },
-
- ".cm-blockquote": {
- borderInlineStart: "0.1875rem solid var(--cds-border-subtle-02, #c6c6c6)",
- paddingInlineStart: "0.75em",
- color: "var(--cds-text-secondary, #525252)",
- fontStyle: "italic",
- },
-
- // List-item line: small left indent so the bullet widget sits in
- // its own column. Padding (not margin) keeps click metrics exact.
- ".cm-list-line": {
- paddingInlineStart: "0.5em",
- },
-
- // Bullet widget — styled here so the widget's HTML inherits the
- // right colour against body type.
- ".cm-bullet-widget": {
- display: "inline-block",
- width: "1em",
- color: "var(--cds-text-secondary, #525252)",
- fontWeight: "700",
- },
-
- // Ordered-list number (`1.`) — wider than a bullet's fixed 1em so multi-digit
- // markers fit, with a small gap before the item text. Normal weight: a bold
- // number reads as heavier than the body text it labels.
- ".cm-ordered-marker": {
- width: "auto",
- minWidth: "1.2em",
- marginRight: "0.3em",
- fontWeight: "normal",
- },
-
- // Task list checkbox — drawn as a Carbon checkbox, not the native control: a
- // 1rem square that fills with the icon token and shows a white tick when
- // checked. `appearance: none` is what replaces WebKit's small rounded default;
- // the box then matches the design system rather than approximating it with an
- // accent colour over the native shape.
- ".cm-task-checkbox": {
- appearance: "none",
- WebkitAppearance: "none",
- boxSizing: "border-box",
- position: "relative",
- width: "1rem",
- height: "1rem",
- margin: "0 0.4em 0 0",
- cursor: "pointer",
- verticalAlign: "-0.15em",
- border: "0.0625rem solid var(--cds-icon-primary, #161616)",
- borderRadius: "0.0625rem",
- background: "transparent",
- },
- ".cm-task-checkbox:checked": {
- background: "var(--cds-icon-primary, #161616)",
- borderColor: "var(--cds-icon-primary, #161616)",
- },
- // The tick: an L (right + bottom border) rotated 45° into a check.
- ".cm-task-checkbox:checked::after": {
- content: "''",
- position: "absolute",
- left: "0.3125rem",
- top: "0.0625rem",
- width: "0.25rem",
- height: "0.5rem",
- border: "solid var(--cds-icon-on-color, #ffffff)",
- borderWidth: "0 0.125rem 0.125rem 0",
- transform: "rotate(45deg)",
- },
- ".cm-task-checkbox:focus-visible": {
- outline: "0.125rem solid var(--cds-focus, #0f62fe)",
- outlineOffset: "0.0625rem",
- },
-
- // Inline image widget. Constrained max-width so a single large
- // image doesn't blow up the editor; lazy loading via `
`.
- ".cm-image-widget": {
- display: "inline-block",
- maxWidth: "min(100%, 32rem)",
- height: "auto",
- borderRadius: "0.25rem",
- margin: "0.25em 0",
- },
-
- // Horizontal-rule widget. Inline span styled as a full-width
- // border so the line stays inside CM6's line layout.
- ".cm-hr-widget": {
- display: "inline-block",
- width: "100%",
- height: "0",
- borderTop: "0.0625rem solid var(--cds-border-subtle-02, #c6c6c6)",
- verticalAlign: "middle",
- },
-
- ".cm-table-widget": {
- borderCollapse: "collapse",
- width: "100%",
- // `fixed` distributes width evenly across columns instead of letting the
- // auto algorithm shrink columns toward their content — with the inherited
- // `cm-lineWrapping` word-breaking, an auto-shrunk column wraps one
- // character per line ("T / o / p / i / c").
- tableLayout: "fixed",
- },
- ".cm-table-widget th, .cm-table-widget td": {
- border: "0.0625rem solid var(--cds-border-subtle-02, #c6c6c6)",
- padding: "0.4em 0.75em",
- verticalAlign: "top",
- // No native text-selection: a drag would otherwise zig-zag a ragged
- // selection across cells (uneven heights). `tablev2` tracks the
- // drag and tints whole cells uniformly instead; the cell editor re-enables
- // selection for its own content (below).
- userSelect: "none",
- WebkitUserSelect: "none",
- // Wrap at word boundaries like prose (overriding the `word-break: break-word`
- // cm-lineWrapping inherits onto `.cm-content`), but still break a single
- // over-long token (e.g. `complementarity/orchestration`) so it can't spill
- // past its fixed-width column into the neighbour.
- overflowWrap: "break-word",
- wordBreak: "normal",
- },
- ".cm-table-widget thead th": {
- background: "var(--cds-layer-accent-01, #e8e8e8)",
- fontWeight: "600",
- textAlign: "left",
- },
- // The cell editor's own content stays selectable so a mounted cell edits
- // normally (only the surrounding grid is locked, above).
- ".cm-table-widget .cm-content": {
- userSelect: "text",
- WebkitUserSelect: "text",
- },
- // Cell highlight: `--selected` is a drag-selection (tablev2);
- // `--hover` previews the row/column a "Comment on this row/column" menu item
- // targets; `--commenting` is held by the host while that comment composer is
- // open. All read as the same even tint.
- ".cm-table-cell--selected, .cm-table-cell--hover, .cm-table-cell--commenting": {
- backgroundColor: "var(--cds-highlight, #d0e2ff)",
- },
-
- // Hover inserters (tablev2/tableV2HoverControls.ts). The wrapper reserves a top + left
- // padding gutter; JS parks the two "+" circles in it — clear of the grid, so
- // they never clip at a corner or sit under the header.
- ".cm-table-wrap": {
- position: "relative",
- // Vertical spacing is PADDING, never margin — the same rule the headings and
- // lists above follow. CM6 measures a block widget's height from its border
- // box (margins excluded), so a margin here under-measures the table and
- // drifts every click below it down onto the next line. The top padding
- // doubles as the hover-"+" gutter.
- paddingTop: "2.5em",
- paddingBottom: "0.5em",
- paddingLeft: "2em",
- },
-
- // Two-step delete (tableArmed.ts). The first Backspace/Delete next to a table
- // parks the caret at its edge and arms it: the table gets a blue "selected"
- // outline, and a green line is drawn at the armed edge — Zettlr's "green line
- // cursor behind the table" cue, signalling the next press removes it. The
- // caret is hidden while arming (it renders on the blank line just past the
- // table, which reads as "the cursor never moved") by hiding the drawn cursor
- // layer.
- "&.cm-table-arming .cm-cursorLayer": {
- display: "none",
- },
- ".cm-table-armed .cm-table-widget": {
- outline: "0.125rem solid var(--cds-border-interactive, #0f62fe)",
- outlineOffset: "0.0625rem",
- position: "relative",
- },
- ".cm-table-wrap[data-armed-edge] .cm-table-widget::after": {
- content: "''",
- position: "absolute",
- left: "0",
- right: "0",
- height: "0.1875rem",
- background: "var(--cds-support-success, #24a148)",
- pointerEvents: "none",
- },
- ".cm-table-wrap[data-armed-edge=\"end\"] .cm-table-widget::after": {
- bottom: "-0.375rem",
- },
- ".cm-table-wrap[data-armed-edge=\"start\"] .cm-table-widget::after": {
- top: "-0.375rem",
- },
- ".cm-table-inserter": {
- position: "absolute",
- display: "none",
- alignItems: "center",
- justifyContent: "center",
- width: "1.25rem",
- height: "1.25rem",
- borderRadius: "50%",
- appearance: "none",
- border: "none",
- padding: "0",
- cursor: "pointer",
- zIndex: "3",
- // Subtle at rest (a neutral chip); the primary accent is the hover
- // affordance. The glyph is a centred SVG stroked with currentColor.
- background: "var(--cds-layer-accent-01, #e0e0e0)",
- color: "var(--cds-icon-secondary, #525252)",
- boxShadow: "0 0.0625rem 0.125rem rgba(0, 0, 0, 0.12)",
- transition: "background 80ms ease, color 80ms ease",
- },
- ".cm-table-inserter:hover": {
- background: "var(--cds-link-primary, #0f62fe)",
- color: "#ffffff",
- },
-
- ".cm-image-menu": {
- background: "var(--cds-layer-01, #ffffff)",
- border: "0.0625rem solid var(--cds-border-subtle-01, #e0e0e0)",
- borderRadius: "0.125rem",
- boxShadow: "0 0.25rem 0.75rem rgba(0, 0, 0, 0.15)",
- padding: "0.25rem 0",
- minWidth: "11rem",
- fontFamily:
- "var(--cds-body-01-font-family, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif)",
- fontSize: "0.875rem",
- },
- ".cm-image-menu__item": {
- appearance: "none",
- background: "transparent",
- border: "none",
- textAlign: "start",
- padding: "0.4rem 0.75rem",
- cursor: "pointer",
- width: "100%",
- color: "var(--cds-text-primary, #161616)",
- font: "inherit",
- },
- ".cm-image-menu__item:hover": {
- background: "var(--cds-layer-hover-01, #e8e8e8)",
- },
- ".cm-image-menu__item--danger": {
- color: "var(--cds-text-error, #da1e28)",
- },
-
- ".cm-math-inline": {
- fontFamily: "KaTeX_Main, serif",
- },
- ".cm-math-block": {
- display: "block",
- margin: "0.5em 0",
- textAlign: "center",
- fontFamily: "KaTeX_Main, serif",
- },
-
- // A diagram reads as CONTENT, not a control: no fill or pointer on hover —
- // just a hairline frame plus a corner "Edit" chip as the click-to-edit cue.
- ".cm-mermaid-block": {
- position: "relative",
- display: "flex",
- justifyContent: "center",
- margin: "0.5em 0",
- padding: "0.75em",
- borderRadius: "0.25rem",
- },
- ".cm-mermaid-block:hover": {
- outline: "0.0625rem solid var(--cds-border-subtle-01, #e0e0e0)",
- },
- // The click-to-select state — native selection paints nothing over a block
- // widget, so the widget carries its own "I'm selected, ⌘C copies me" look.
- ".cm-mermaid-block--selected, .cm-mermaid-block--selected:hover": {
- outline: "0.125rem solid var(--cds-focus, #0f62fe)",
- outlineOffset: "-0.125rem",
- background: "rgba(15, 98, 254, 0.06)",
- },
- ".cm-mermaid-edit": {
- position: "absolute",
- insetBlockStart: "0.375rem",
- insetInlineEnd: "0.375rem",
- padding: "0.1rem 0.5rem",
- fontSize: "0.75rem",
- color: "var(--cds-text-secondary, #525252)",
- background: "var(--cds-layer-01, #f4f4f4)",
- border: "0.0625rem solid var(--cds-border-subtle-01, #e0e0e0)",
- borderRadius: "999rem",
- cursor: "pointer",
- opacity: "0",
- transition: "opacity 80ms ease",
- },
- ".cm-mermaid-block:hover .cm-mermaid-edit": {
- opacity: "1",
- },
- ".cm-mermaid-block > svg": {
- maxWidth: "100%",
- height: "auto",
- // Uniform click handling: SVG sub-elements otherwise swallow pointer
- // events over parts of the diagram, making click-to-select land only on
- // the background.
- pointerEvents: "none",
- },
- ".cm-mermaid-block--pending": {
- color: "var(--cds-text-secondary, #525252)",
- fontStyle: "italic",
- },
- ".cm-mermaid-block--error": {
- display: "block",
- borderLeft: "0.1875rem solid var(--cds-support-error, #da1e28)",
- background: "var(--cds-layer-01, #f4f4f4)",
- },
- ".cm-mermaid-error__title": {
- color: "var(--cds-text-error, #da1e28)",
- fontSize: "0.875em",
- },
- ".cm-mermaid-error__message": {
- margin: "0.5em 0 0",
- fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
- fontSize: "0.75em",
- color: "var(--cds-text-secondary, #525252)",
- whiteSpace: "pre-wrap",
- overflowWrap: "anywhere",
- },
-
- ".cm-html-inline, .cm-html-block": {
- display: "inline",
- },
- ".cm-html-block": {
- display: "block",
- margin: "0.5em 0",
- },
-
- ".cm-link": {
- color: "var(--cds-link-primary, #0f62fe)",
- textDecoration: "underline",
- },
-
- // Wikilink label — distinguish from regular markdown links via a
- // dashed underline so a non-technical reader can tell at a glance
- // that this is a vault-internal target. Matches the Tiptap version
- // (`.wikilink`).
- ".cm-wikilink": {
- color: "var(--cds-link-primary, #0f62fe)",
- textDecoration: "underline dashed",
- cursor: "pointer",
- },
-
- // Explicit yellow, not var(--cds-highlight): the app maps that token to the
- // drawn-selection color, and a mark bound to it is indistinguishable from
- // selected text. Translucent so the below-content selection band reads
- // through; the alpha composites to the intended #fff8c5 resting yellow on
- // the white canvas.
- ".cm-highlight": {
- background: "rgba(255, 235, 89, 0.35)",
- padding: "0 0.1em",
- borderRadius: "0.125rem",
- },
-
- ".cm-footnote-ref": {
- fontSize: "0.75em",
- verticalAlign: "super",
- color: "var(--cds-link-primary, #0f62fe)",
- cursor: "pointer",
- },
-
- ".cm-footnote-def": {
- fontSize: "0.875em",
- color: "var(--cds-text-secondary, #525252)",
- paddingInlineStart: "0.5em",
- borderInlineStart: "0.125rem solid var(--cds-border-subtle-02, #c6c6c6)",
- },
-});
diff --git a/packages/rich-editor/src/codemirror/core/hostFacets.test.ts b/packages/rich-editor/src/codemirror/core/hostFacets.test.ts
deleted file mode 100644
index 9d9406a..0000000
--- a/packages/rich-editor/src/codemirror/core/hostFacets.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-// @vitest-environment jsdom
-import { EditorState } from "@codemirror/state";
-import { describe, expect, it, vi } from "vitest";
-
-import { defaultResolveImageSrc } from "../../imageSrcResolver";
-import { openExternalUrlFacet, resolveImageSrcFacet, saveImageBytesFacet } from "./hostFacets";
-
-describe("hostFacets — defaults and host overrides", () => {
- it("resolveImageSrcFacet defaults to the passthrough resolver", () => {
- const state = EditorState.create({ doc: "" });
- expect(state.facet(resolveImageSrcFacet)).toBe(defaultResolveImageSrc);
- });
-
- it("resolveImageSrcFacet uses the first registered override", () => {
- const state = EditorState.create({
- doc: "",
- extensions: [resolveImageSrcFacet.of((raw) => `c:${raw}`)],
- });
- expect(state.facet(resolveImageSrcFacet)("img.png", { fileDir: null })).toBe("c:img.png");
- });
-
- it("saveImageBytesFacet defaults to null (data-URL fallback)", () => {
- const state = EditorState.create({ doc: "" });
- expect(state.facet(saveImageBytesFacet)).toBeNull();
- });
-
- it("openExternalUrlFacet default opens a new browser tab", () => {
- const open = vi.spyOn(window, "open").mockImplementation(() => null);
- EditorState.create({ doc: "" }).facet(openExternalUrlFacet)("https://example.com");
- expect(open).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer");
- open.mockRestore();
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/hostFacets.ts b/packages/rich-editor/src/codemirror/core/hostFacets.ts
deleted file mode 100644
index d33db70..0000000
--- a/packages/rich-editor/src/codemirror/core/hostFacets.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * Host-environment injection seams.
- *
- * The editor surface is environment-agnostic: it knows how to render and edit
- * markdown, but NOT how to read/write files, resolve asset URLs, or open links —
- * those depend on where it's embedded (a Tauri desktop shell, a plain browser, a
- * server-rendered preview). Each capability is a CM6 facet with a sensible
- * browser default; the React host overrides it by setting the facet from a prop.
- *
- * Keeping these as facets (not React context) lets the non-React CM6 plugins and
- * widgets — image paste handlers, the inline `
` widget, the click model —
- * read them straight off `view.state`.
- */
-
-import { Facet } from "@codemirror/state";
-
-import { defaultResolveImageSrc, type ImageResolveContext } from "../../imageSrcResolver";
-import { type SourceRange } from "../../types";
-
-export type ResolveImageSrc = (rawSrc: string, ctx: ImageResolveContext) => string;
-export type SaveImageBytes = (relPath: string, bytes: Uint8Array) => Promise;
-export type OpenExternalUrl = (url: string) => void;
-/** Viewport point a comment composer should anchor to (the right-click point). */
-export type CommentAnchor = { x: number; y: number };
-export type CommentOnExcerpt = (
- excerpt: { text: string; range: SourceRange },
- anchor: CommentAnchor,
-) => void;
-
-/**
- * Turn a markdown image `src` into a URL the view can load. Default: pass the
- * reference through unchanged (data URLs and absolute URLs render directly; a
- * browser resolves relative refs against the page origin). A desktop host
- * overrides this to map workspace-relative paths onto its asset protocol.
- */
-export const resolveImageSrcFacet = Facet.define({
- combine: (values) => values[0] ?? defaultResolveImageSrc,
-});
-
-/**
- * Persist pasted/dropped image bytes at a workspace-relative path. Default:
- * `null` — the insert pipeline then inlines the image as a `data:` URL so it
- * still survives a reload. A desktop host provides a writer that saves to disk
- * and the markdown reference stays a portable relative path.
- */
-export const saveImageBytesFacet = Facet.define({
- combine: (values) => values[0] ?? null,
-});
-
-/**
- * Open a clicked external link. Default: a new browser tab. A desktop host
- * overrides this to leave the app's webview via its shell-open API.
- */
-export const openExternalUrlFacet = Facet.define({
- combine: (values) => values[0] ?? defaultOpenExternalUrl,
-});
-
-/**
- * Comment on a selected table row/column — the table context menu hands its
- * excerpt + anchor point here. Default: `null` — the menu then omits its
- * "Comment on this row/column" items. A desktop host opens its comment composer
- * (the same one a text selection uses) seeded with the excerpt.
- */
-export const commentOnExcerptFacet = Facet.define<
- CommentOnExcerpt | null,
- CommentOnExcerpt | null
->({
- combine: (values) => values[0] ?? null,
-});
-
-function defaultOpenExternalUrl(url: string): void {
- if (typeof window !== "undefined") {
- window.open(url, "_blank", "noopener,noreferrer");
- }
-}
diff --git a/packages/rich-editor/src/codemirror/core/hrWidget.test.ts b/packages/rich-editor/src/codemirror/core/hrWidget.test.ts
deleted file mode 100644
index a22f7cd..0000000
--- a/packages/rich-editor/src/codemirror/core/hrWidget.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it } from "vitest";
-
-import { destroyEditors, makeEditor } from "./editorTestHarness";
-import { HorizontalRuleWidget } from "./hrWidget";
-
-describe("HorizontalRuleWidget", () => {
- afterEach(destroyEditors);
-
- it("renders span.cm-hr-widget", () => {
- const dom = new HorizontalRuleWidget().toDOM(makeEditor("x", 0));
- expect(dom.tagName).toBe("SPAN");
- expect(dom.className).toBe("cm-hr-widget");
- });
-
- it("eq() is always true and ignoreEvent() is false", () => {
- expect(new HorizontalRuleWidget().eq(new HorizontalRuleWidget())).toBe(true);
- expect(new HorizontalRuleWidget().ignoreEvent()).toBe(false);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/hrWidget.ts b/packages/rich-editor/src/codemirror/core/hrWidget.ts
deleted file mode 100644
index f11df6c..0000000
--- a/packages/rich-editor/src/codemirror/core/hrWidget.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Horizontal-rule widget — replaces `---` / `***` / `___` source
- * with a styled inline `` that draws a horizontal line.
- *
- * Kept as an inline widget (not `block: true`) so the line still
- * participates in CM6's normal line-metric measurement; CSS draws
- * the rule via a full-width border.
- */
-
-import { Decoration, EditorView, WidgetType } from "@codemirror/view";
-
-import { type NodeRule } from "./paint";
-
-export class HorizontalRuleWidget extends WidgetType {
- override eq(_other: HorizontalRuleWidget): boolean {
- return true;
- }
-
- override toDOM(_view: EditorView): HTMLElement {
- const span = document.createElement("span");
- span.className = "cm-hr-widget";
- return span;
- }
-
- override ignoreEvent(): boolean {
- return false;
- }
-}
-
-/* ---------------- The HorizontalRule rule ---------------- */
-
-
-
-// Stateless → one shared decoration; allocating per node per viewport build
-// is pure GC pressure.
-const HR_REPLACE = Decoration.replace({ widget: new HorizontalRuleWidget() });
-
-/** `---` → a styled `
` widget. */
-export const horizontalRuleRule: NodeRule = () => ({ paint: "widget", deco: HR_REPLACE });
diff --git a/packages/rich-editor/src/codemirror/core/index.ts b/packages/rich-editor/src/codemirror/core/index.ts
deleted file mode 100644
index 90289d5..0000000
--- a/packages/rich-editor/src/codemirror/core/index.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * The paint engine: the NodeRule/Paint rendering contract, the canonical
- * rules table, the viewport painter, the base theme, and the host-environment
- * facets. Every other feature folder builds on these.
- *
- * Folder front doors (this file and its siblings) are for composition roots —
- * the package index, the editor shell, and `extensions/`. Feature-folder
- * modules import each other by concrete module path instead, so module
- * initialization order never depends on a barrel's export order.
- *
- * Engine-internal, deliberately not re-exported: `registry` (consumed by the
- * painter and the table cell renderer), `lineStructure`, `hrWidget`, and
- * `editorTestHarness` (test-only — it pulls the whole extension set).
- */
-export * from "./paint";
-export * from "./parseToEnd";
-export * from "./treeAt";
-export * from "./plugin";
-export * from "./editorTheme";
-export * from "./hostFacets";
diff --git a/packages/rich-editor/src/codemirror/core/lineStructure.ts b/packages/rich-editor/src/codemirror/core/lineStructure.ts
deleted file mode 100644
index a274c15..0000000
--- a/packages/rich-editor/src/codemirror/core/lineStructure.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * What the GRAMMAR says a line is — resolved from the Lezer syntax tree, not
- * from regexes over the line's text (#61). Text lies where the tree doesn't:
- * `- item` inside a code fence isn't a list item, a nested ` - item` is one
- * even though its marker isn't at column 0, and ` - item` under a list is
- * a nested item while the same text at top level is indented code. Commands
- * that reshape a line consult this and edit the marker range the PARSER
- * identified.
- */
-
-import type { EditorState, Line } from "@codemirror/state";
-import { treeAt } from "./treeAt";
-
-export interface LineListInfo {
- kind: "bullet" | "ordered";
- /** The item carries a task checkbox (`- [ ] …`). */
- task: boolean;
- /** Source range of the list marker (plus checkbox for tasks), INCLUDING the
- * single space that separates it from the content. */
- markFrom: number;
- markTo: number;
-}
-
-export interface LineStructure {
- /** Line belongs to a BLOCK code context (fenced or indented code, including
- * the fence lines themselves) — structure commands must not rewrite code.
- * Inline code inside a paragraph does not count: the line is still prose. */
- inCode: boolean;
- /** ATX heading when the line is one, with its mark range (hashes +
- * following space). */
- heading: { level: number; markFrom: number; markTo: number } | null;
- list: LineListInfo | null;
- /** Outermost blockquote marker starting this line (`> `), when present. */
- quote: { markFrom: number; markTo: number } | null;
- /** Where this line's own content starts — after indentation and any
- * blockquote markers. A fresh line-type marker belongs here, so indented
- * and quoted lines keep their prefix. */
- contentFrom: number;
-}
-
-const HEADING = /^ATXHeading(\d)$/;
-
-/** The slice of Lezer's SyntaxNode these lookups touch. */
-type NodeLike = {
- readonly name: string;
- readonly from: number;
- readonly to: number;
- readonly parent: NodeLike | null;
- getChild(type: string): NodeLike | null;
-};
-
-/** Extend a mark's end over the single following space, when present. */
-function withMarkerSpace(state: EditorState, to: number, lineTo: number): number {
- return to < lineTo && state.sliceDoc(to, to + 1) === " " ? to + 1 : to;
-}
-
-function firstNonWhitespace(state: EditorState, from: number, to: number): number {
- const text = state.sliceDoc(from, to);
- return from + (text.length - text.trimStart().length);
-}
-
-export function lineStructure(state: EditorState, line: Line): LineStructure {
- const result: LineStructure = {
- inCode: false,
- heading: null,
- list: null,
- quote: null,
- contentFrom: firstNonWhitespace(state, line.from, line.to),
- };
-
- const tree = treeAt(state, result.contentFrom);
- // Resolve at the first content character. Blockquote markers are container
- // prefixes, not content — step past each one (`> > - x` → resolve at `-`)
- // so the line's own structure is an ancestor of the resolve point.
- let node = tree.resolveInner(result.contentFrom, 1) as unknown as NodeLike;
- while (node.name === "QuoteMark" && node.from >= line.from) {
- result.quote ??= {
- markFrom: node.from,
- markTo: withMarkerSpace(state, node.to, line.to),
- };
- result.contentFrom = firstNonWhitespace(state, node.to, line.to);
- node = tree.resolveInner(result.contentFrom, 1) as unknown as NodeLike;
- }
-
- // Every structural fact about the resolve point is one of its ancestors.
- for (let cur: NodeLike | null = node; cur; cur = cur.parent) {
- const name = cur.name;
- if (name === "FencedCode" || name === "CodeBlock") {
- result.inCode = true;
- continue;
- }
- const heading = HEADING.exec(name);
- if (heading && cur.from >= line.from && cur.from <= result.contentFrom) {
- const mark = cur.getChild("HeaderMark");
- if (mark) {
- result.heading = {
- level: Number(heading[1]),
- markFrom: mark.from,
- markTo: withMarkerSpace(state, mark.to, line.to),
- };
- }
- continue;
- }
- // The innermost ListItem STARTING on this line owns the line's marker; an
- // item merely continuing here (wrapped paragraph) contributes nothing.
- if (name === "ListItem" && !result.list && cur.from >= line.from && cur.from <= line.to) {
- const mark = cur.getChild("ListMark");
- if (!mark || mark.from > line.to) continue;
- const taskMark = cur.getChild("Task")?.getChild("TaskMarker") ?? null;
- result.list = {
- kind: cur.parent?.name === "OrderedList" ? "ordered" : "bullet",
- task: Boolean(taskMark),
- markFrom: mark.from,
- markTo: withMarkerSpace(state, (taskMark ?? mark).to, line.to),
- };
- }
- }
- return result;
-}
diff --git a/packages/rich-editor/src/codemirror/core/paint.ts b/packages/rich-editor/src/codemirror/core/paint.ts
deleted file mode 100644
index f9d02ff..0000000
--- a/packages/rich-editor/src/codemirror/core/paint.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * The rendering contract: one polymorphic shape for "how does a Lezer node
- * render", replacing three parallel dispatch surfaces (a data-entry union, a
- * widget-name union + builders map, and a contextual-override switch).
- *
- * NodeRule = (ctx) => Paint — one function per node NAME
- * Paint = which CM6 mechanism — a CLOSED union
- *
- * The openness is split on the right axis: CONSTRUCTS grow (every new node
- * adds a rule — one entry, one place, usually one line via the combinators
- * below), while the ways to PAINT don't (line class / span mark / hide /
- * widget / nothing — CodeMirror's own vocabulary). The single switch over
- * `Paint` lives in the painter (plugin.ts) and never changes when a
- * construct is added.
- *
- * Context (the bare-URL lesson: a node name can mean different things in
- * different parents) is not a bolt-on — every rule IS a function of context;
- * simple rules just ignore it.
- *
- * Extensions contribute rules through {@link nodeRulesFacet}: a plugin that
- * introduces node names ships its rules alongside its grammar, touching no
- * core file.
- */
-
-import { Facet, type EditorState } from "@codemirror/state";
-import { type Decoration } from "@codemirror/view";
-
-// Structural stand-in for a Lezer node (`@lezer/common` is a transitive dep
-// this package deliberately doesn't import from).
-export interface NodeLike {
- readonly name: string;
- readonly from: number;
- readonly to: number;
- readonly parent: NodeLike | null;
- readonly firstChild: NodeLike | null;
- readonly nextSibling: NodeLike | null;
- readonly prevSibling: NodeLike | null;
- getChild(type: string): NodeLike | null;
-}
-
-/** Everything a rule may consult. */
-export interface NodeContext {
- readonly name: string;
- readonly from: number;
- readonly to: number;
- /** The parent node's name — the common contextual discriminator. */
- readonly parentName: string | undefined;
- /** Full structural node, for rules that need siblings/children. */
- readonly node: NodeLike;
- /** Rules are pure over state — no view dependency, so non-editor
- * renderers (table cells) can invoke the same rules. */
- readonly state: EditorState;
-}
-
-/** One painting instruction, in CodeMirror's own vocabulary. */
-export type Paint =
- /** Stamp a line class — on the node's first line, or every spanned line. */
- | { readonly paint: "lineClass"; readonly className: string; readonly span: "first" | "all" }
- /** Style the node's span. */
- | { readonly paint: "mark"; readonly className: string }
- /**
- * Hide a range (default: the node, plus its one separator space when
- * line-leading) and make it atomic to caret motion. `atomicTo` widens the
- * atom past the hidden range (marker + space move as one unit).
- */
- | {
- readonly paint: "hide";
- readonly range?: { readonly from: number; readonly to: number };
- readonly expandSpace?: boolean;
- readonly atomicTo?: number;
- }
- /** Replace the (hidden) span with a widget decoration. */
- | { readonly paint: "widget"; readonly deco: Decoration; readonly atomicTo?: number }
- /** Leave the node alone — visible raw source. */
- | { readonly paint: "none" };
-
-export interface RuleMeta {
- readonly intent: "render-raw" | "structural";
- readonly why: string;
-}
-
-/** How one node name renders. `meta` tags the deliberate do-nothing rules so
- * the coverage test can insist their reason is documented. */
-export type NodeRule = ((ctx: NodeContext) => Paint) & { readonly meta?: RuleMeta };
-
-export type NodeRules = Readonly>;
-
-/* ---------------- Combinators — the common rules as one-liners ------------ */
-
-const NONE: Paint = { paint: "none" };
-
-/** Style the node's span with a class. */
-export function mark(className: string): NodeRule {
- const paint: Paint = { paint: "mark", className };
- return () => paint;
-}
-
-/** Stamp a line class on every line the node spans. */
-export function line(className: string): NodeRule {
- const paint: Paint = { paint: "lineClass", className, span: "all" };
- return () => paint;
-}
-
-/** Stamp a line class on the node's first line only (headings). */
-export function headingLine(className: string): NodeRule {
- const paint: Paint = { paint: "lineClass", className, span: "first" };
- return () => paint;
-}
-
-/** Hide the node (marker chrome), atomically. */
-export function hideAlways(): NodeRule {
- const paint: Paint = { paint: "hide" };
- return () => paint;
-}
-
-/** Deliberately unstyled-for-now, visible raw — `why` documents the intent. */
-export function raw(why: string): NodeRule {
- return Object.assign(() => NONE, { meta: { intent: "render-raw", why } as RuleMeta });
-}
-
-/** A parser grouping construct, never directly visible — `why` says which. */
-export function structural(why: string): NodeRule {
- return Object.assign(() => NONE, { meta: { intent: "structural", why } as RuleMeta });
-}
-
-export const none: Paint = NONE;
-
-/* ---------------- Extension seam ----------------------------------------- */
-
-/**
- * Rules contributed by extensions, merged over the base table (an extension
- * may also deliberately override a base rule — last provider wins). The
- * painter reads THIS, never the base table directly.
- */
-export const nodeRulesFacet = Facet.define({
- combine(values) {
- return Object.assign({}, ...values);
- },
-});
diff --git a/packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts b/packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts
deleted file mode 100644
index 4e5fc6a..0000000
--- a/packages/rich-editor/src/codemirror/core/parseToEnd.browser.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * The editor invariant, proven in the engine we ship on: with {@link parseToEnd}
- * installed, the syntax tree covers the whole document shortly after opening —
- * including documents past the ~100k point where CodeMirror's own background
- * parse stops for good.
- */
-
-import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
-import { syntaxTree } from "@codemirror/language";
-import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
-import { afterEach, describe, expect, it } from "vitest";
-
-import { parseToEnd } from "./parseToEnd";
-
-const views: EditorView[] = [];
-
-afterEach(() => {
- for (const view of views.splice(0)) {
- view.destroy();
- view.dom.parentElement?.remove();
- }
-});
-
-function openEditor(doc: string, extra: Extension[]): EditorView {
- const parent = document.createElement("div");
- document.body.appendChild(parent);
- const view = new EditorView({
- parent,
- state: EditorState.create({
- doc,
- selection: EditorSelection.cursor(0),
- extensions: [markdown({ base: markdownLanguage }), ...extra],
- }),
- });
- views.push(view);
- return view;
-}
-
-/** Big enough that the tail sits past the background parse's permanent stop. */
-function hugeDoc(): string {
- const filler = Array.from({ length: 8000 }, (_, i) => `paragraph line ${i}`).join("\n\n");
- return `${filler}\n\n\`\`\`\ncode\n\`\`\``;
-}
-
-async function waitFor(check: () => boolean, ms: number): Promise {
- const start = performance.now();
- while (performance.now() - start < ms) {
- if (check()) return true;
- await new Promise((r) => setTimeout(r, 50));
- }
- return check();
-}
-
-describe("parseToEnd", () => {
- it("covers a document the background parse alone never finishes", async () => {
- const doc = hugeDoc();
- const view = openEditor(doc, [parseToEnd]);
- const covered = await waitFor(() => syntaxTree(view.state).length >= doc.length, 10_000);
- expect(covered).toBe(true);
- }, 30_000);
-
- it("re-covers after an edit", async () => {
- const doc = hugeDoc();
- const view = openEditor(doc, [parseToEnd]);
- await waitFor(() => syntaxTree(view.state).length >= doc.length, 10_000);
-
- view.dispatch({ changes: { from: 0, insert: "# heading\n\n" } });
- const covered = await waitFor(
- () => syntaxTree(view.state).length >= view.state.doc.length,
- 10_000,
- );
- expect(covered).toBe(true);
- }, 30_000);
-
- it("control: without it, the background parse stops short — the premise", async () => {
- // If this ever fails, CodeMirror now covers large documents on its own and
- // the extension is obsolete — remove it rather than keep a no-op.
- const doc = hugeDoc();
- const view = openEditor(doc, []);
- const covered = await waitFor(() => syntaxTree(view.state).length >= doc.length, 4_000);
- expect(covered).toBe(false);
- }, 30_000);
-});
diff --git a/packages/rich-editor/src/codemirror/core/parseToEnd.test.ts b/packages/rich-editor/src/codemirror/core/parseToEnd.test.ts
deleted file mode 100644
index 9f7711d..0000000
--- a/packages/rich-editor/src/codemirror/core/parseToEnd.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-// @vitest-environment jsdom
-import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
-import { syntaxTree } from "@codemirror/language";
-import { EditorState, type Extension } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
-import { afterEach, describe, expect, it } from "vitest";
-
-import { parseToEnd } from "./parseToEnd";
-
-const views: EditorView[] = [];
-
-afterEach(() => {
- for (const view of views.splice(0)) {
- view.destroy();
- view.dom.parentElement?.remove();
- }
-});
-
-/** A raw view, deliberately not the harness: the harness pre-parses to the end
- * of the document, which is the invariant this extension exists to establish. */
-function openEditor(doc: string, extra: Extension[]): EditorView {
- const parent = document.createElement("div");
- document.body.appendChild(parent);
- const view = new EditorView({
- parent,
- state: EditorState.create({
- doc,
- extensions: [markdown({ base: markdownLanguage }), ...extra],
- }),
- });
- views.push(view);
- return view;
-}
-
-/** Long enough that the opening frame's parse stops well short of the end. */
-function longDoc(): string {
- return Array.from({ length: 800 }, (_, i) => `paragraph line ${i}`).join("\n\n");
-}
-
-async function waitFor(check: () => boolean, ms: number): Promise {
- const start = Date.now();
- while (Date.now() - start < ms) {
- if (check()) return true;
- await new Promise((r) => setTimeout(r, 25));
- }
- return check();
-}
-
-describe("parseToEnd", () => {
- it("drives the installed tree to the end of the document", async () => {
- const doc = longDoc();
- const view = openEditor(doc, [parseToEnd]);
- expect(syntaxTree(view.state).length).toBeLessThan(doc.length); // the premise
- const covered = await waitFor(() => syntaxTree(view.state).length >= doc.length, 5_000);
- expect(covered).toBe(true);
- }, 15_000);
-
- it("re-arms on a document change", async () => {
- const view = openEditor(longDoc(), [parseToEnd]);
- await waitFor(() => syntaxTree(view.state).length >= view.state.doc.length, 5_000);
-
- view.dispatch({
- changes: { from: view.state.doc.length, insert: `\n\n${longDoc()}` },
- });
- const covered = await waitFor(
- () => syntaxTree(view.state).length >= view.state.doc.length,
- 5_000,
- );
- expect(covered).toBe(true);
- }, 15_000);
-
- it("does not poll a view that has no language", async () => {
- const view = openEditor(longDoc(), []);
- const bare = new EditorView({
- parent: view.dom.parentElement!,
- state: EditorState.create({ doc: "plain text", extensions: [parseToEnd] }),
- });
- views.push(bare);
- // One gap is enough for the first work() call to run and decline.
- await new Promise((r) => setTimeout(r, 200));
- expect(syntaxTree(bare.state).length).toBe(0);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/parseToEnd.ts b/packages/rich-editor/src/codemirror/core/parseToEnd.ts
deleted file mode 100644
index 898b099..0000000
--- a/packages/rich-editor/src/codemirror/core/parseToEnd.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { forceParsing, language, syntaxTree } from "@codemirror/language";
-import type { Extension } from "@codemirror/state";
-import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view";
-
-/** Work per slice. Small enough to never register as jank between frames. */
-const SLICE_MS = 10;
-
-/** Gap between slices, leaving the main thread mostly idle while the tail of a
- * large document parses. Markdown covers ~240k characters in ~43ms of work, so
- * even a very large note finishes within a few hundred ms of opening. */
-const GAP_MS = 25;
-
-/**
- * Drives the parse to the end of the document, establishing the invariant the
- * rest of the editor is written against: **the installed syntax tree covers
- * the whole document**, not just what the viewport happened to render.
- *
- * Without it, CodeMirror's own background work stops 100,000 characters past
- * the viewport (measured: the tree plateaus at 100,739 for a 167k-character
- * document and a 341k one alike, and never advances again). Everything
- * downstream of the tree is then silently wrong for the rest of the document:
- * table and mermaid widgets never materialize, code-language affordances never
- * appear, and structural commands are left to re-parse at the caret
- * ({@link treeAt} — still wanted, since a keystroke can land before this
- * finishes).
- *
- * Scheduled with `setTimeout`, not `requestIdleCallback`: WebKit stops
- * delivering idle callbacks to an unfocused window, and a note opened while
- * the app is in the background must still be parsed when the user comes back
- * to it.
- */
-export const parseToEnd: Extension = ViewPlugin.fromClass(
- class {
- private timer: number | null = null;
-
- constructor(private readonly view: EditorView) {
- this.schedule();
- }
-
- update(update: ViewUpdate) {
- if (update.docChanged) this.schedule();
- }
-
- destroy() {
- if (this.timer !== null) window.clearTimeout(this.timer);
- }
-
- private schedule() {
- if (this.timer !== null) return;
- this.timer = window.setTimeout(this.work, GAP_MS);
- }
-
- private readonly work = () => {
- this.timer = null;
- const { state } = this.view;
- // Without a language nothing will ever parse; rescheduling would poll
- // forever for a tree that never comes.
- if (!state.facet(language)) return;
- if (syntaxTree(state).length >= state.doc.length) return;
- forceParsing(this.view, state.doc.length, SLICE_MS);
- // Reschedule until covered, not until progress: a slice can spend its
- // whole budget without committing a longer tree (measured: the resume
- // from CodeMirror's own stop point commits nothing on the first slice,
- // then covers the rest of the document on the next).
- this.schedule();
- };
- },
-);
diff --git a/packages/rich-editor/src/codemirror/core/plugin.test.ts b/packages/rich-editor/src/codemirror/core/plugin.test.ts
deleted file mode 100644
index a2fb05f..0000000
--- a/packages/rich-editor/src/codemirror/core/plugin.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-// @vitest-environment jsdom
-import type { EditorView } from "@codemirror/view";
-import { afterEach, describe, expect, it } from "vitest";
-
-import { destroyEditors, makeEditor } from "./editorTestHarness";
-import { mark, nodeRulesFacet } from "./paint";
-import { markdownDecorationsPlugin } from "./plugin";
-
-/** The plugin's atomic (= hidden) source ranges as [from, to] pairs. */
-function atomicRanges(view: EditorView): Array<[number, number]> {
- const set = view.plugin(markdownDecorationsPlugin)?.atomic;
- const out: Array<[number, number]> = [];
- set?.between(0, view.state.doc.length, (from, to) => {
- out.push([from, to]);
- });
- return out;
-}
-
-describe("markdownDecorationsPlugin — hides + atomicizes syntax markers", () => {
- afterEach(destroyEditors);
-
- it("makes the bold ** markers atomic when the caret is outside the construct", () => {
- const doc = "x **b** y";
- const view = makeEditor(doc, 0); // caret far from the bold
- const ranges = atomicRanges(view);
- const open = doc.indexOf("**b**");
- expect(ranges).toContainEqual([open, open + 2]); // opening **
- expect(ranges).toContainEqual([doc.indexOf("b") + 1, doc.indexOf("b") + 3]); // closing **
- // The visible "b" itself is never atomic.
- const b = doc.indexOf("b");
- expect(ranges.some(([f, t]) => f <= b && b < t)).toBe(false);
- });
-
- it("hides the heading marker (and its space) when the caret is on another line", () => {
- const doc = "# Title\nbody";
- const view = makeEditor(doc, doc.indexOf("body"));
- expect(atomicRanges(view)).toContainEqual([0, 2]); // "# "
- });
-
- it("keeps the ** markers hidden/atomic even with the caret inside (hide-always)", () => {
- // EmphasisMark is `hide-always` in the registry: this editor never reveals
- // raw `**` for editing, so the cursor/delete normalizers can always treat
- // the markers as atomic regardless of caret position.
- const doc = "x **b** y";
- const view = makeEditor(doc, doc.indexOf("b")); // caret inside the bold
- const open = doc.indexOf("**b**");
- expect(atomicRanges(view)).toContainEqual([open, open + 2]);
- });
-
- it("renders an ordered-list mark as its number and a bullet mark as a •", () => {
- const ordered = makeEditor("1. first\n2. second", 0);
- const numbers = [...ordered.dom.querySelectorAll(".cm-ordered-marker")].map((e) => e.textContent);
- expect(numbers).toEqual(["1.", "2."]);
-
- const bullet = makeEditor("- item", 0);
- expect(bullet.dom.querySelector(".cm-bullet-widget")?.textContent).toBe("•");
- expect(bullet.dom.querySelector(".cm-ordered-marker")).toBeNull();
- });
-
- it("hides the leading backslash of an escape, keeping the escaped char (\\' → ')", () => {
- const doc = "year\\'s"; // a backslash-escaped apostrophe, as some models emit
- const view = makeEditor(doc, 0);
- const slash = doc.indexOf("\\"); // the "\" before the apostrophe
- expect(atomicRanges(view)).toContainEqual([slash, slash + 1]);
- // The escaped "'" itself stays visible (never atomic).
- const apos = doc.indexOf("'");
- expect(atomicRanges(view).some(([f, t]) => f <= apos && apos < t)).toBe(false);
- });
-});
-
-describe("nodeRulesFacet — extensions contribute rules without touching core", () => {
- afterEach(destroyEditors);
-
- it("an extension-provided rule overrides the base rule for that node", () => {
- // Emphasis is a base `mark("cm-emphasis")`; an extension re-rules it.
- const view = makeEditor("*i*", 0, [
- nodeRulesFacet.of({ Emphasis: mark("cm-custom-emphasis") }),
- ]);
- expect(view.contentDOM.querySelector(".cm-custom-emphasis")).not.toBeNull();
- expect(view.contentDOM.querySelector(".cm-emphasis")).toBeNull();
- });
-
- it("base rules keep painting when no extension contributes", () => {
- const view = makeEditor("*i*", 0);
- expect(view.contentDOM.querySelector(".cm-emphasis")).not.toBeNull();
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/plugin.ts b/packages/rich-editor/src/codemirror/core/plugin.ts
deleted file mode 100644
index 21805c6..0000000
--- a/packages/rich-editor/src/codemirror/core/plugin.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-/**
- * Markdown decoration plugin — the PAINTER. Walks the Lezer tree against the
- * visible viewport, asks each node's {@link NodeRule} how to render, and
- * applies the returned {@link Paint}.
- *
- * The painter contains *no* construct knowledge: rules live in the base
- * table (`NODE_RULES`) merged with any extension-contributed rules
- * (`nodeRulesFacet`). The one switch here is over `Paint` — CodeMirror's
- * closed set of mechanisms — so adding a construct, widget, contextual
- * override, or whole extension never edits this file.
- *
- * Perf shape (unchanged from the spike):
- * * Walks `view.visibleRanges` against `syntaxTree(view.state)` —
- * the work is proportional to the viewport, not the document.
- * * Rebuilt on doc change or viewport change.
- *
- * Decoration ordering (CM6 requires `from`-then-`startSide` ascending):
- * * Line decorations collected in one bucket, mark / replace in
- * another. The final `Decoration.set` lets CM6 sort defensively,
- * costing ~µs on the typical viewport — cheaper than risking the
- * "decorations out of order" runtime error.
- */
-
-import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
-import { RangeSetBuilder, type Range } from "@codemirror/state";
-import {
- Decoration,
- EditorView,
- type DecorationSet,
- ViewPlugin,
- type PluginValue,
- type ViewUpdate,
-} from "@codemirror/view";
-
-import { nodeRulesFacet, type NodeLike, type NodeRules } from "./paint";
-import { NODE_RULES } from "./registry";
-
-/* ---------------- Decoration instances ----------------- */
-//
-// Cached per `className` so we don't allocate a Decoration per node
-// per build. CM6 dedups internally, but skipping the call is cheaper.
-
-const lineDecoCache = new Map();
-const markDecoCache = new Map();
-const HIDE_MARKER = Decoration.replace({});
-
-function lineDeco(className: string): Decoration {
- let d = lineDecoCache.get(className);
- if (!d) {
- d = Decoration.line({ class: className });
- lineDecoCache.set(className, d);
- }
- return d;
-}
-
-function markDeco(className: string): Decoration {
- let d = markDecoCache.get(className);
- if (!d) {
- d = Decoration.mark({ class: className });
- markDecoCache.set(className, d);
- }
- return d;
-}
-
-/* ---------------- Effective rules ---------------- */
-
-// Base + extension rules, merged once per distinct facet value (the facet
-// result is referentially stable until providers change).
-const mergedRulesCache = new WeakMap();
-
-function effectiveRules(view: EditorView): NodeRules {
- const extra = view.state.facet(nodeRulesFacet);
- if (Object.keys(extra).length === 0) return NODE_RULES;
- let merged = mergedRulesCache.get(extra);
- if (!merged) {
- merged = Object.assign({}, NODE_RULES, extra);
- mergedRulesCache.set(extra, merged);
- }
- return merged;
-}
-
-/* ---------------- The painter ---------------- */
-
-interface BuildResult {
- decorations: DecorationSet;
- /**
- * Every hidden/replaced range, surfaced as `EditorView.atomicRanges` so
- * cursor motion (arrow keys, drag-select, double-click) treats hidden
- * markup as a single atom — the user moves "around" hidden `**` instead
- * of getting stranded between two invisible characters.
- */
- atomic: DecorationSet;
-}
-
-/**
- * Block-level markers (`# ` heading, `> ` quote, `- ` list item) are
- * followed by exactly one separator space; we hide that space along
- * with the marker so the rendered line starts at the first content
- * char. Inline markers (`*`, backticks) don't have this — only the
- * line-leading case applies.
- */
-function expandTrailingSpace(
- view: EditorView,
- range: { from: number; to: number },
-): number {
- const line = view.state.doc.lineAt(range.from);
- if (range.from !== line.from) return range.to;
- const charAfter = view.state.doc.sliceString(range.to, range.to + 1);
- return charAfter === " " ? range.to + 1 : range.to;
-}
-
-function buildDecorations(view: EditorView): BuildResult {
- const rules = effectiveRules(view);
- const lineDecs: Range[] = [];
- const markDecs: Range[] = [];
- const atomicBuilder = new RangeSetBuilder();
- // Force-parse through the viewport so a list/task marker stays rendered while
- // typing: the incremental tree can lag for the just-edited line on a large
- // doc, briefly dropping the widget back to raw source (#37). Bounded to the
- // viewport + a timeout, so a large note doesn't pay a full re-parse per key.
- const tree = ensureSyntaxTree(view.state, view.viewport.to, 100) ?? syntaxTree(view.state);
-
- // Stamp a Decoration.line on every line overlapping the given
- // range. CM6 requires `Decoration.line` to be anchored at a line
- // start; this helper does the line-iteration once for callers.
- const stampLineRange = (deco: Decoration, from: number, to: number) => {
- let pos = from;
- while (pos <= to) {
- const line = view.state.doc.lineAt(pos);
- lineDecs.push(deco.range(line.from));
- if (line.to >= to) break;
- pos = line.to + 1;
- }
- };
-
- for (const { from, to } of view.visibleRanges) {
- tree.iterate({
- from,
- to,
- enter: (node) => {
- const rule = rules[node.name];
- if (!rule) {
- // Unknown node — the coverage test prevents this for the base
- // grammar; an extension grammar's node without a contributed rule
- // lands here. Skip silently rather than throw: a missing rule
- // must not break editing.
- return;
- }
- // `node.node` materializes a SyntaxNode (lezer's documented-expensive
- // path); most rules are constant combinators that never read it, so
- // the context exposes it — and the parent walk — through lazy getters
- // that only pay when a contextual rule actually asks.
- const paint = rule({
- name: node.name,
- from: node.from,
- to: node.to,
- get parentName() {
- return node.node.parent?.name;
- },
- get node() {
- return node.node as unknown as NodeLike;
- },
- state: view.state,
- });
-
- switch (paint.paint) {
- case "lineClass": {
- if (paint.span === "first") {
- const line = view.state.doc.lineAt(node.from);
- lineDecs.push(lineDeco(paint.className).range(line.from));
- } else {
- stampLineRange(lineDeco(paint.className), node.from, node.to);
- }
- return;
- }
- case "mark": {
- markDecs.push(markDeco(paint.className).range(node.from, node.to));
- return;
- }
- case "hide": {
- const range = paint.range ?? node;
- const hideEnd =
- paint.expandSpace === false ? range.to : expandTrailingSpace(view, range);
- markDecs.push(HIDE_MARKER.range(range.from, hideEnd));
- atomicBuilder.add(
- range.from,
- Math.max(hideEnd, paint.atomicTo ?? hideEnd),
- HIDE_MARKER,
- );
- return;
- }
- case "widget": {
- const hideEnd = expandTrailingSpace(view, node);
- markDecs.push(paint.deco.range(node.from, hideEnd));
- atomicBuilder.add(
- node.from,
- Math.max(hideEnd, paint.atomicTo ?? hideEnd),
- paint.deco,
- );
- return;
- }
- case "none":
- return;
- }
- },
- });
- }
-
- // Line decorations before mark/replace at the same point — this is
- // the canonical CM6 ordering. Concat-then-sort is one O(n log n)
- // for the whole viewport, dwarfed by the parse cost.
- const all: Range[] = lineDecs.concat(markDecs);
- return {
- decorations: Decoration.set(all, /* sort */ true),
- atomic: atomicBuilder.finish(),
- };
-}
-
-export const markdownDecorationsPlugin = ViewPlugin.fromClass(
- class implements PluginValue {
- decorations: DecorationSet;
- atomic: DecorationSet;
- constructor(view: EditorView) {
- const built = buildDecorations(view);
- this.decorations = built.decorations;
- this.atomic = built.atomic;
- }
- update(update: ViewUpdate) {
- // No decoration depends on the cursor (markers never reveal on
- // proximity), so caret-only transactions skip the whole build path.
- if (update.docChanged || update.viewportChanged) {
- const built = buildDecorations(update.view);
- this.decorations = built.decorations;
- this.atomic = built.atomic;
- }
- }
- },
- {
- decorations: (v) => v.decorations,
- provide: (plugin) =>
- EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? Decoration.none),
- },
-);
diff --git a/packages/rich-editor/src/codemirror/core/registry.test.ts b/packages/rich-editor/src/codemirror/core/registry.test.ts
deleted file mode 100644
index abcf5a7..0000000
--- a/packages/rich-editor/src/codemirror/core/registry.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Coverage gate for the decoration registry.
- *
- * Asserts that every Lezer markdown node Compose's editor will
- * encounter at runtime has an explicit entry in
- * `NODE_RULES`. "Explicit" includes `render-raw`
- * and `structural` — the gate's job is not to demand styling for
- * everything, just to make every choice conscious.
- *
- * We read the live parser's node set rather than maintaining a
- * hand-typed canonical list, so the day someone bumps
- * `@lezer/markdown` and it introduces (say) `MathBlock` or
- * `Footnote`, this test trips before that node ships unstyled.
- *
- * Two languages because `markdownLanguage` extends `commonmarkLanguage`
- * with GFM types (Table / Strikethrough / etc) — the editor wires the
- * extended one, so the union is what the user will actually see.
- */
-
-import { commonmarkLanguage, markdownLanguage } from "@codemirror/lang-markdown";
-import { describe, expect, it } from "vitest";
-
-import { NODE_RULES } from "./registry";
-
-// `@codemirror/language`'s `Language.parser` is typed as the abstract
-// `Parser` from `@lezer/common`, which doesn't surface `nodeSet`. The
-// concrete `MarkdownParser` we get at runtime does. Narrow the public
-// type at the boundary rather than importing `MarkdownParser` (which
-// would pull `@lezer/markdown` into our direct deps just to satisfy
-// the type checker for one test).
-interface ParserWithNodeSet {
- readonly nodeSet: { readonly types: readonly { readonly name: string }[] };
-}
-
-function collectNodeNames(): Set {
- const names = new Set();
- for (const lang of [commonmarkLanguage, markdownLanguage]) {
- const parser = lang.parser as unknown as ParserWithNodeSet;
- for (const type of parser.nodeSet.types) {
- // Lezer reserves an empty-named type at index 0 for the
- // anonymous root; ignore it. Same for any other anonymous
- // helper types — they're never named in tree iteration.
- if (type.name) names.add(type.name);
- }
- }
- return names;
-}
-
-describe("markdown decoration registry", () => {
- it("covers every node Lezer's CommonMark + GFM parsers emit", () => {
- const live = collectNodeNames();
- const missing = [...live].filter((name) => !(name in NODE_RULES)).sort();
- expect(
- missing,
- // Custom message so the diff actually tells the reader what to do.
- `Add NODE_RULES entries (mark()/line()/hideAlways()/raw(why)/… ) for: ${missing.join(", ")}`,
- ).toEqual([]);
- });
-
- it("never carries dead entries the live parser no longer emits", () => {
- const live = collectNodeNames();
- const extra = Object.keys(NODE_RULES)
- .filter((name) => !live.has(name))
- .sort();
- expect(
- extra,
- `Registry has entries for nodes Lezer no longer emits (probably a stale handcoded name): ${extra.join(", ")}`,
- ).toEqual([]);
- });
-
- it("requires every raw()/structural() rule to carry a documented why", () => {
- const offenders: string[] = [];
- for (const [name, rule] of Object.entries(NODE_RULES)) {
- if (rule.meta && (!rule.meta.why || rule.meta.why.trim() === "")) offenders.push(name);
- }
- expect(
- offenders,
- `These do-nothing rules omit \`why\` — every "we don't decorate this" decision needs a documented reason: ${offenders.join(", ")}`,
- ).toEqual([]);
- });
-});
diff --git a/packages/rich-editor/src/codemirror/core/registry.ts b/packages/rich-editor/src/codemirror/core/registry.ts
deleted file mode 100644
index 2d1fba3..0000000
--- a/packages/rich-editor/src/codemirror/core/registry.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * Canonical table: every Lezer markdown node name → its {@link NodeRule} —
- * ONE place answering "how does this construct render". The Lezer parser
- * handles CommonMark + GFM spec compliance; this file is the visual contract.
- *
- * Why a table of rules (functions), not data:
- *
- * 1. **Completeness is auditable.** `registry.test.ts` reads the live
- * parser's node set and fails when any node Lezer emits has no entry
- * here — a lezer-markdown bump that adds a node trips the gate before
- * it ships unstyled. Only the KEYS matter for that, so entries are free
- * to be behavior.
- * 2. **Context is first-class.** A node name can mean different things in
- * different parents (a URL inside `[text](url)` is chrome; a bare
- * pasted URL is content — hiding it made an invisible dead zone).
- * Every rule receives context; contextual entries are ordinary inline
- * functions, readable at the point of definition.
- * 3. **Adding a construct touches one place.** A styled span is a
- * one-liner via the combinators; a widget construct exports its rule
- * from its own module and is listed here; an EXTENSION with its own
- * grammar ships rules via `nodeRulesFacet` and never touches this file.
- * 4. **Deliberate non-styling stays documented.** `raw(why)` /
- * `structural(why)` tag their rules; the coverage test insists the
- * `why` is present.
- *
- * The rules speak {@link Paint} — CodeMirror's own mechanisms (line class,
- * span mark, hide, widget, nothing). That union is CLOSED: constructs grow,
- * ways to paint don't, so the single switch over Paint lives in the painter
- * (plugin.ts) and no new construct ever edits it.
- */
-
-import { horizontalRuleRule } from "./hrWidget";
-import { htmlBlockRule, htmlInlineRule } from "../html/htmlWidget";
-import { imageRule } from "../image/imageWidget";
-import { listMarkRule } from "../list/bulletWidget";
-import {
- headingLine,
- hideAlways,
- line,
- mark,
- raw,
- structural,
- type NodeContext,
- type NodeRules,
-} from "./paint";
-import { taskMarkerRule } from "../list/taskCheckboxWidget";
-
-/** Does this URL's parent Link render any visible label text? The label is
- * everything between the opening `[` and closing `]` marks; whitespace-only
- * counts as invisible (a lone space is a dead zone in practice). */
-function linkHasVisibleLabel(ctx: NodeContext): boolean {
- const link = ctx.node.parent;
- if (!link) return false;
- let bracketOpen: { readonly to: number } | null = null;
- let bracketClose: { readonly from: number } | null = null;
- for (let child = link.firstChild; child; child = child.nextSibling) {
- if (child.name !== "LinkMark") continue;
- const mark = ctx.state.sliceDoc(child.from, child.to);
- if (mark === "[" && !bracketOpen) bracketOpen = child;
- else if (mark === "]" && !bracketClose) bracketClose = child;
- }
- if (!bracketOpen || !bracketClose || bracketClose.from <= bracketOpen.to) return false;
- return ctx.state.sliceDoc(bracketOpen.to, bracketClose.from).trim() !== "";
-}
-
-export const NODE_RULES: NodeRules = {
- // ----- Structural wrappers (never directly styled) -----
- Document: structural("top-level wrapper"),
- Paragraph: structural("body font is the default"),
-
- // ----- ATX headings (`# H1` … `###### H6`) -----
- ATXHeading1: headingLine("cm-heading cm-heading--1"),
- ATXHeading2: headingLine("cm-heading cm-heading--2"),
- ATXHeading3: headingLine("cm-heading cm-heading--3"),
- ATXHeading4: headingLine("cm-heading cm-heading--4"),
- ATXHeading5: headingLine("cm-heading cm-heading--5"),
- ATXHeading6: headingLine("cm-heading cm-heading--6"),
-
- // ----- Setext headings (`=====` / `-----` under text) -----
- SetextHeading1: raw("Phase 2: same line scaling as ATXHeading1"),
- SetextHeading2: raw("Phase 2: same line scaling as ATXHeading2"),
-
- // ----- Block-level constructs -----
- Blockquote: line("cm-blockquote"),
- BulletList: structural("ListItem children carry the styling"),
- OrderedList: structural("ListItem children carry the styling"),
- ListItem: line("cm-list-line"),
- FencedCode: line("cm-fenced-code"),
- CodeBlock: raw("Phase 2: indented code-block, same style as FencedCode"),
- HorizontalRule: horizontalRuleRule, // `---` → styled `
` widget
- HTMLBlock: htmlBlockRule,
- LinkReference: raw("Phase 2: reference-style links resolve in click handler"),
-
- // ----- Inline styling -----
- Emphasis: mark("cm-emphasis"),
- StrongEmphasis: mark("cm-strong"),
- InlineCode: mark("cm-inline-code"),
- Link: mark("cm-link"),
- Image: imageRule, // `` → inline `
` widget
-
- // ----- Inline literal sub-nodes (rendered inside their parent) -----
- // Inside a Link the URL is chrome ONLY when a visible label exists — for
- // `[](url)` / `[ ](url)` the URL is the link's entire visible content, and
- // hiding it (with the marks already hidden) left an invisible dead zone.
- // Bare GFM autolinks and autolinks emit the SAME node name, and
- // there the URL IS the content — same class of bug when hidden.
- URL: (ctx) => {
- if (ctx.parentName !== "Link") return { paint: "mark", className: "cm-link" };
- return linkHasVisibleLabel(ctx)
- ? { paint: "hide" }
- : { paint: "mark", className: "cm-link" };
- },
- LinkLabel: raw("visible inside Link; parent mark styles it"),
- LinkTitle: hideAlways(), // `[text](URL "title")` — title never visible
- CodeText: raw("interior of FencedCode/InlineCode; parent decoration covers it"),
- CodeInfo: mark("cm-code-info"), // language tag on the opener row — visible so typing there is never invisible (§12.4)
- HardBreak: structural("trailing two-spaces or backslash, no visible glyph"),
- Comment: raw("Phase 2: dim inline HTML comments"),
- CommentBlock: raw("Phase 2: dim block HTML comments"),
- ProcessingInstruction: raw("Phase 2: dim like HTML comments"),
- ProcessingInstructionBlock: raw("Phase 2: dim like HTML comments"),
- Entity: raw("HTML entities like & render as-is, by design"),
- // Backslash-escape: hide only the leading `\`, so `\'` renders as `'` per
- // CommonMark rather than showing a literal backslash.
- Escape: (ctx) => ({
- paint: "hide",
- range: { from: ctx.from, to: ctx.from + 1 },
- expandSpace: false,
- }),
- HTMLTag: htmlInlineRule,
-
- // ----- Syntax markers (hidden always — non-technical UX, distinct from Obsidian's Live Preview) -----
- // `#` marks on ATX headings hide; a SETEXT heading's underline is that
- // heading's only marker on its own line — hiding it left an invisible,
- // unclickable dead line. Visible until setext gets real styling.
- HeaderMark: (ctx) =>
- ctx.parentName?.startsWith("SetextHeading") ? { paint: "none" } : { paint: "hide" },
- EmphasisMark: hideAlways(), // `*` / `_`
- CodeMark: hideAlways(), // backticks for inline / fence pairs for blocks
- LinkMark: hideAlways(), // `[`/`]`/`(`/`)`
- QuoteMark: hideAlways(), // `>`
- ListMark: listMarkRule, // `-`/`1.` → bullet / number / nothing beside a checkbox
- TaskMarker: taskMarkerRule, // `[ ]` / `[x]` → real checkbox
-
- // ----- GFM extensions (enabled via `markdownLanguage`) -----
- Strikethrough: mark("cm-strikethrough"),
- // `~~` joins the hidden-marker system like EmphasisMark — the flanking
- // guard and delete normalizer already assume these semantics; leaving the
- // marks visible made deletion eat single tildes (interaction-spec §8.1).
- StrikethroughMark: hideAlways(),
- Subscript: raw("Phase 2: vertical-align: sub"),
- SubscriptMark: raw("Phase 2: hide `~` off-parent"),
- Superscript: raw("Phase 2: vertical-align: super"),
- SuperscriptMark: raw("Phase 2: hide `^` off-parent"),
- Emoji: raw("Phase 2: replace `:smile:` with the emoji glyph"),
- Autolink: raw("wrapper of a visible URL child; Phase 2 wires click-to-open"),
- Task: raw("Phase 2: render task list items with a real checkbox"),
-
- // GFM tables — handled by a dedicated StateField that emits the block
- // widget on Table nodes (CM6 forbids multi-line replace from a ViewPlugin,
- // so this rules-driven plugin can't do that shape; see tableField.ts).
- Table: structural("tableField StateField owns Table rendering"),
- TableHeader: structural("covered by Table widget"),
- TableRow: structural("covered by Table widget"),
- TableCell: structural("covered by Table widget"),
- TableDelimiter: structural("covered by Table widget"),
-};
diff --git a/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts b/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts
deleted file mode 100644
index f1452c1..0000000
--- a/packages/rich-editor/src/codemirror/core/renderedOutput.test.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, describe, expect, it } from "vitest";
-
-import { destroyEditors, makeFullEditor } from "./editorTestHarness";
-
-/**
- * What the USER SEES — the rendered output, not the markdown source.
- *
- * The block-command / source-level tests assert what's written to the document;
- * this suite asserts what's drawn for it, through the *full* editor extension
- * set (wikilinks, highlight, footnotes, math, tables — not just the base
- * decorations). That layer was previously untested, which is how a numbered
- * list rendering as bullets and a task item drawing both a bullet and a checkbox
- * shipped unnoticed.
- */
-
-/** The visible text of each rendered line (hidden markers gone, widgets in). */
-function seen(doc: string): string[] {
- const view = makeFullEditor(doc, 0);
- return [...view.contentDOM.querySelectorAll(".cm-line")].map((line) => line.textContent ?? "");
-}
-
-describe("rendered output — what the user sees", () => {
- afterEach(destroyEditors);
-
- it("hides heading / emphasis / code markers, leaving the content", () => {
- expect(seen("# Title")).toEqual(["Title"]);
- expect(seen("**b** *i* `c`")).toEqual(["b i c"]);
- });
-
- it("a bare pasted URL stays VISIBLE, styled as a link", () => {
- // Field report: pasted URLs vanished (the registry's URL hide-always was
- // written for [text](url), but bare autolinks emit the same node) —
- // leaving an invisible, unclickable dead zone the user pasted into
- // repeatedly because nothing appeared.
- expect(seen("open ai build day: https://openai.devpost.com/x")).toEqual([
- "open ai build day: https://openai.devpost.com/x",
- ]);
- const view = makeFullEditor("see https://a.b/c now", 0);
- const link = view.contentDOM.querySelector(".cm-link");
- expect(link?.textContent).toBe("https://a.b/c");
- });
-
- it("an autolink shows its URL (brackets hidden)", () => {
- expect(seen("go now")).toEqual(["go https://a.b/c now"]);
- });
-
- it("a [label](url) link still shows ONLY the label", () => {
- expect(seen("see [docs](https://a.b/c) now")).toEqual(["see docs now"]);
- });
-
- it("an EMPTY-label link shows its URL — never an invisible hole", () => {
- // Same dead-zone class as the bare-URL report, one sibling over: with the
- // label and marks hidden, the URL was the link's only visible content.
- expect(seen("a [](https://x.dev/page) b")).toEqual(["a https://x.dev/page b"]);
- expect(seen("a [ ](https://y.dev/q) b")).toEqual(["a https://y.dev/q b"]);
- });
-
- it("the caret can sit inside a bare URL (no atomic dead zone)", () => {
- const doc = "x https://a.b/c y";
- const view = makeFullEditor(doc, 0);
- const inside = doc.indexOf("a.b") + 1;
- view.dispatch({ selection: { anchor: inside } });
- expect(view.state.selection.main.head).toBe(inside);
- });
-
- // ── INVARIANT: nothing the user typed renders invisibly ────────────────────
- // The registry hides construct CHROME; it must never hide CONTENT. Each row
- // plants a sentinel in a position that has bitten (or could): the sentinel
- // must appear in the rendered text. Bare URLs (#157), setext underlines, and
- // angle-bracket placeholders all failed this before their fixes — new
- // constructs add a row here.
- describe("no user text becomes invisible", () => {
- const CASES: Array<[name: string, doc: string, sentinel: string]> = [
- ["bare pasted URL", "day: https://x.dev/SENTINEL9 end", "https://x.dev/SENTINEL9"],
- ["angle autolink", "go now", "https://x.dev/SENTINEL9"],
- ["email autolink", "mail now", "sentinel9@x.dev"],
- ["setext H1 underline", "Title\n===", "==="],
- ["setext H2 underline", "Title\n---", "---"],
- ["angle-bracket placeholder", "Dear , hi", ""],
- ["stray closing tag", "a