Skip to the content

Capsid menu

app/components/editor/rich-editor.tsx

575 lines and 67 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of rich-editor.tsx
FunctionLinesComplexityCoveredCRAPMutation
defaultImage46 to 46150%OkCRAP 1.1No data: mutation testing has not run on this repo yet
RichEditor51 to 596230%CriticalCRAP 552No data: mutation testing has not run on this repo yet
<anonymous:61>61 to 6110%OkCRAP 2No data: mutation testing has not run on this repo yet
markdown76 to 7610%OkCRAP 2No data: mutation testing has not run on this repo yet
load78 to 8510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:88>88 to 17020%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:93>93 to 9320%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:93>93 to 931100%OkCRAP 1No data: mutation testing has not run on this repo yet
handleKeyDown107 to 123100%CriticalCRAP 110No data: mutation testing has not run on this repo yet
handlePaste124 to 140100%CriticalCRAP 110No data: mutation testing has not run on this repo yet
<anonymous:125>125 to 12510%OkCRAP 2No data: mutation testing has not run on this repo yet
handleDrop141 to 14940%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:142>142 to 14210%OkCRAP 2No data: mutation testing has not run on this repo yet
onUpdate151 to 15710%OkCRAP 2No data: mutation testing has not run on this repo yet
onSelectionUpdate158 to 15810%OkCRAP 2No data: mutation testing has not run on this repo yet
focus163 to 16310%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:164>164 to 16710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:173>173 to 17730%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:179>179 to 18110%OkCRAP 2No data: mutation testing has not run on this repo yet
syncSlash186 to 19080%CriticalCRAP 72No data: mutation testing has not run on this repo yet
<anonymous:191>191 to 20640%WatchCRAP 20No data: mutation testing has not run on this repo yet
getBoundingClientRect197 to 19710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:199>199 to 20520%WatchCRAP 6No data: mutation testing has not run on this repo yet
insertScaffold208 to 21740%WatchCRAP 20No data: mutation testing has not run on this repo yet
focusSourceBlock220 to 2291100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:221>221 to 22850%WatchCRAP 30No data: mutation testing has not run on this repo yet
<anonymous:223>223 to 22320%WatchCRAP 6No data: mutation testing has not run on this repo yet
insertMarkdown231 to 23520%WatchCRAP 6No data: mutation testing has not run on this repo yet
openPalette238 to 24430%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:245>245 to 26240%WatchCRAP 20No data: mutation testing has not run on this repo yet
getBoundingClientRect251 to 25110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:253>253 to 25920%WatchCRAP 6No data: mutation testing has not run on this repo yet
closePalette265 to 27040%WatchCRAP 20No data: mutation testing has not run on this repo yet
applyLink271 to 28140%WatchCRAP 20No data: mutation testing has not run on this repo yet
startUpload284 to 30140%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:295>295 to 29510%OkCRAP 2No data: mutation testing has not run on this repo yet
insertImage302 to 30840%WatchCRAP 20No data: mutation testing has not run on this repo yet
run311 to 31410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:311>311 to 3142100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:316>316 to 31610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:317>317 to 31710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:324>324 to 32850%WatchCRAP 30No data: mutation testing has not run on this repo yet
<anonymous:330>330 to 33010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:336>336 to 34310%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:338>338 to 33810%OkCRAP 2No data: mutation testing has not run on this repo yet
run345 to 34510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:347>347 to 34720%WatchCRAP 6No data: mutation testing has not run on this repo yet
run347 to 3471100%OkCRAP 1No data: mutation testing has not run on this repo yet
onToolbarKey349 to 35680%CriticalCRAP 72No data: mutation testing has not run on this repo yet
<anonymous:351>351 to 35110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:363>363 to 38760%CriticalCRAP 42No data: mutation testing has not run on this repo yet
<anonymous:374>374 to 37410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:375>375 to 37510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:420>420 to 42420%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:454>454 to 45410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:455>455 to 46430%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:477>477 to 48010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:500>500 to 50230%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:520>520 to 52010%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:521>521 to 53490%CriticalCRAP 90No data: mutation testing has not run on this repo yet
<anonymous:537>537 to 55240%WatchCRAP 20No data: mutation testing has not run on this repo yet
<anonymous:545>545 to 54510%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:546>546 to 54610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:569>569 to 58290%CriticalCRAP 90No data: mutation testing has not run on this repo yet
<anonymous:571>571 to 57110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:584>584 to 59110%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:586>586 to 58610%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/components/editor/source-views.ts, app/components/editor/toolbar-icons.tsx, app/lib/editor/markdown.mjs, app/lib/editor/schema.mjs. Imported by app/components/editor/writing-surface.tsx.

Source

// The formatted editor (ruling point 3): Tiptap's MIT core over ProseMirror, showing
// formatted text with the Markdown underneath kept byte for byte (lib/editor/markdown.mjs). It keeps
// what the Markdown editor did: the APG toolbar (bold, italic, link, heading, code, footnote, the app's
// blocks, an image), Ctrl or Cmd B, I, E, K and S, the link palette over the app's pages, the block
// menu on a lone "/", an image that is not in the text until it has alt text, the word count and
// reading time, read-only, and Tab leaving the text. Markdown the editor does not format (front
// matter, code, tables, directives, math) sits in source blocks, edited as Markdown in place.
//
// The server renders a labelled textarea holding the Markdown, so the page reads the same before the
// script runs; the editor replaces it once it loads.

import { Editor, type JSONContent } from "@tiptap/core";
import { Placeholder } from "@tiptap/extensions";
import { useEffect, useRef, useState } from "react";
import { countWords, filterLinkTargets, looksLikeUrl, minutesForWords, type LinkTarget, type Scaffold } from "capsomer/behaviour/markdown-editor";
import { place } from "capsomer/behaviour/popover";

import { fingerprints, fromDoc, pastedBlocks, toDoc, type Dialect, type JsonNode } from "~/lib/editor/markdown.mjs";
import { baseExtensions } from "~/lib/editor/schema.mjs";

import { sourceBlockView, SourceInlineView } from "./source-views";
import { GLYPH, Icon } from "./toolbar-icons";

export type RichEditorHandle = {
  /** Puts Markdown (a figure, an image) in as blocks at the cursor, as the editor's own blocks are put. */
  insertMarkdown: (markdown: string) => void;
  focus: () => void;
};

export type RichEditorProps = {
  /** What the editing surface is called to a screen reader, for example "Post, formatted". */
  ariaLabel: string;
  value: string;
  onChange: (next: string) => void;
  readOnly: boolean;
  dialect: Dialect;
  placeholder?: string;
  scaffolds?: Scaffold[];
  linkTargets?: LinkTarget[];
  onUpload?: (file: File) => Promise<{ url: string }>;
  imageMarkdown?: (image: { url: string; alt: string; name: string }) => string;
  accept?: string;
  onReady?: (handle: RichEditorHandle) => void;
};

const defaultImage = ({ url, alt }: { url: string; alt: string }) => `![${alt.replace(/[[\]]/g, "")}](${url})`;

type Palette = { from: number; to: number; query: string; active: number };
type Upload = { name: string; state: "sending" } | { name: string; state: "alt"; url: string } | null;

export function RichEditor(props: RichEditorProps) {
  const { ariaLabel, value, readOnly, dialect, placeholder = "Write here.", scaffolds = [], linkTargets = [], onUpload, accept } = props;
  const host = useRef<HTMLDivElement>(null);
  const editorRef = useRef<Editor | null>(null);
  const origin = useRef<{ tail: string; prints: Map<number, string>; last: string }>({ tail: "", prints: new Map(), last: value });
  const propsRef = useRef(props);
  propsRef.current = props;
  const readOnlyRef = useRef(readOnly);
  readOnlyRef.current = readOnly;
  const [ready, setReady] = useState(false);
  const [words, setWords] = useState(() => countWords(value));
  const [palette, setPalette] = useState<Palette | null>(null);
  const [slash, setSlash] = useState<number | null>(null);
  const [upload, setUpload] = useState<Upload>(null);
  const [uploadError, setUploadError] = useState<string | null>(null);
  const [alt, setAlt] = useState("");
  const [said, say] = useState("");
  const [activeTool, setActiveTool] = useState(0);
  const toolbar = useRef<HTMLDivElement>(null);
  const paletteRef = useRef<HTMLDivElement>(null);
  const slashRef = useRef<HTMLUListElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);
  const altRef = useRef<HTMLInputElement>(null);

  /** The Markdown the document stands for: unchanged blocks as their bytes, the rest spelled again. */
  const markdown = (editor: Editor) => fromDoc(editor.getJSON() as JsonNode, origin.current.tail, origin.current.prints, dialect);

  const load = (editor: Editor, source: string) => {
    const loaded = toDoc(source, dialect);
    const doc = editor.schema.nodeFromJSON(loaded.doc);
    // Not an edit: no undo step, and no change reported.
    editor.view.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, doc.content).setMeta("addToHistory", false).setMeta("preventUpdate", true));
    origin.current = { tail: loaded.tail, prints: fingerprints(editor.getJSON() as JsonNode), last: source };
    setWords(countWords(source));
  };

  // The editor itself: made once, after the page is in the browser.
  useEffect(() => {
    if (!host.current) return;
    const loaded = toDoc(value, dialect);
    const editor = new Editor({
      element: host.current,
      extensions: [...baseExtensions().filter((e) => e.name !== "sourceBlock" && e.name !== "sourceInline"), sourceBlockView(() => readOnlyRef.current), SourceInlineView, Placeholder.configure({ placeholder })],
      content: loaded.doc as JSONContent,
      editable: !readOnly,
      // Carrel's CSP takes no inline styles: Tiptap's base rules are in app.css instead.
      injectCSS: false,
      editorProps: {
        attributes: {
          class: "app-rich-surface",
          role: "textbox",
          "aria-multiline": "true",
          "aria-label": ariaLabel,
          "aria-placeholder": placeholder,
          spellcheck: "true",
        },
        handleKeyDown: (_view, event) => {
          const mod = event.ctrlKey || event.metaKey;
          if (mod && event.key.toLowerCase() === "s") return true; // The page's own Ctrl+S saves; the browser's never opens.
          if (mod && event.key.toLowerCase() === "k") {
            openPalette();
            return true;
          }
          if (event.key === "ArrowDown" && slashOpen.current) {
            slashRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
            return true;
          }
          if (event.key === "Escape" && slashOpen.current) {
            setSlash(null);
            return true;
          }
          return false;
        },
        handlePaste: (_view, event) => {
          const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
          if (files[0] && propsRef.current.onUpload) {
            void startUpload(files[0]);
            return true;
          }
          // Markdown pasted as text becomes formatted blocks, as typing it in Markdown did.
          const html = event.clipboardData?.getData("text/html");
          const text = event.clipboardData?.getData("text/plain");
          if (!html && text && /\n|^#{1,6} |^[-*+] |^\d+\. |^> |[*_`[]/.test(text)) {
            const blocks = pastedBlocks(text, dialect);
            const single = blocks.length === 1 && blocks[0]!.type === "paragraph";
            editorRef.current?.commands.insertContent(single ? (blocks[0]!.content ?? []) : blocks);
            return true;
          }
          return false;
        },
        handleDrop: (_view, event) => {
          const files = [...((event as DragEvent).dataTransfer?.files ?? [])].filter((f) => f.type.startsWith("image/"));
          if (files[0] && propsRef.current.onUpload) {
            event.preventDefault();
            void startUpload(files[0]);
            return true;
          }
          return false;
        },
      },
      onUpdate: ({ editor: e }) => {
        const md = markdown(e);
        origin.current.last = md;
        setWords(countWords(md));
        propsRef.current.onChange(md);
        syncSlash(e);
      },
      onSelectionUpdate: ({ editor: e }) => syncSlash(e),
    });
    origin.current = { tail: loaded.tail, prints: fingerprints(editor.getJSON() as JsonNode), last: value };
    editorRef.current = editor;
    setReady(true);
    propsRef.current.onReady?.({ insertMarkdown, focus: () => editor.commands.focus() });
    return () => {
      editor.destroy();
      editorRef.current = null;
    };
    // Made once; later values come in through the effects below.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // A new value from outside (a discarded draft, an AI draft taken as the working copy) is loaded.
  useEffect(() => {
    const editor = editorRef.current;
    if (editor && value !== origin.current.last) load(editor, value);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value]);

  useEffect(() => {
    editorRef.current?.setEditable(!readOnly, false);
  }, [readOnly]);

  // ---- the block menu on a lone "/"
  const slashOpen = useRef(false);
  slashOpen.current = slash !== null;
  function syncSlash(editor: Editor) {
    const { $from, empty: collapsed } = editor.state.selection;
    const lone = collapsed && !readOnlyRef.current && scaffolds.length > 0 && $from.depth === 1 && $from.parent.type.name === "paragraph" && $from.parent.textContent === "/" && $from.parentOffset === 1;
    setSlash(lone ? $from.before() : null);
  }
  useEffect(() => {
    const menu = slashRef.current;
    const editor = editorRef.current;
    if (!menu || !editor || slash === null) return;
    menu.showPopover?.();
    const at = editor.view.coordsAtPos(slash + 1);
    place({ getBoundingClientRect: () => new DOMRect(at.left, at.top, 0, at.bottom - at.top) } as Element, menu, { side: "bottom", align: "start" });
    say("Block menu open. Press Down arrow to choose a block, Escape to close.");
    return () => {
      try {
        menu.hidePopover?.();
      } catch {
        // Already closed.
      }
    };
  }, [slash]);

  function insertScaffold(s: Scaffold, fromSlash: boolean) {
    const editor = editorRef.current;
    if (!editor) return;
    const block = { type: "sourceBlock", attrs: { text: s.text, kind: s.label } };
    const chain = editor.chain().focus();
    if (fromSlash && slash !== null) chain.insertContentAt({ from: slash, to: slash + editor.state.doc.nodeAt(slash)!.nodeSize }, block).run();
    else chain.insertContent(block).run();
    setSlash(null);
    focusSourceBlock(editor, s);
  }

  /** Focus lands inside the new block's Markdown, where the block says (for example inside alt=""). */
  function focusSourceBlock(editor: Editor, s: Scaffold) {
    requestAnimationFrame(() => {
      const areas = [...editor.view.dom.querySelectorAll<HTMLTextAreaElement>(".app-src-text")];
      const area = areas.find((a) => a.value === s.text && document.activeElement !== a) ?? areas.at(-1);
      if (!area) return;
      area.focus();
      const at = s.cursorAfter ? s.text.indexOf(s.cursorAfter) + s.cursorAfter.length : (s.cursor ?? s.text.length);
      area.setSelectionRange(at, at);
    });
  }

  function insertMarkdown(md: string) {
    const editor = editorRef.current;
    if (!editor) return;
    editor.chain().focus().insertContent(pastedBlocks(md, dialect) as JSONContent[]).run();
  }

  // ---- the link palette
  function openPalette() {
    const editor = editorRef.current;
    if (!editor || readOnlyRef.current) return;
    const { from, to } = editor.state.selection;
    setSlash(null);
    setPalette({ from, to, query: editor.state.doc.textBetween(from, to, " "), active: 0 });
  }
  useEffect(() => {
    const panel = paletteRef.current;
    const editor = editorRef.current;
    if (!panel || !editor || !palette) return;
    panel.showPopover?.();
    const at = editor.view.coordsAtPos(palette.from);
    place({ getBoundingClientRect: () => new DOMRect(at.left, at.top, 0, at.bottom - at.top) } as Element, panel, { side: "bottom", align: "start" });
    panel.querySelector<HTMLInputElement>("input")?.focus();
    return () => {
      try {
        panel.hidePopover?.();
      } catch {
        // Already closed.
      }
    };
    // Opened once per palette; typing in it does not move it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [palette !== null]);
  const matches = palette ? filterLinkTargets(linkTargets, palette.query) : [];
  const typedUrl = palette && looksLikeUrl(palette.query) ? palette.query.trim() : null;
  function closePalette(restore: boolean) {
    const editor = editorRef.current;
    const p = palette;
    setPalette(null);
    if (editor && p && restore) editor.chain().focus().setTextSelection({ from: p.from, to: p.to }).run();
  }
  function applyLink(href: string, title: string) {
    const editor = editorRef.current;
    const p = palette;
    setPalette(null);
    if (!editor || !p) return;
    const chain = editor.chain().focus().setTextSelection({ from: p.from, to: p.to });
    if (p.from === p.to) chain.insertContent({ type: "text", text: title, marks: [{ type: "link", attrs: { href } }] }).run();
    else chain.setLink({ href }).run();
    // The cursor lands after the link, outside it.
    editor.commands.unsetMark("link", { extendEmptyMarkRange: false });
  }

  // ---- images: uploaded, then not in the text until they have alt text
  async function startUpload(file: File) {
    const send = propsRef.current.onUpload;
    if (!send) return;
    setUploadError(null);
    setUpload({ name: file.name, state: "sending" });
    say(`Uploading ${file.name}`);
    try {
      const { url } = await send(file);
      setAlt("");
      setUpload({ name: file.name, state: "alt", url });
      say("Uploaded. Describe the image to insert it.");
      requestAnimationFrame(() => altRef.current?.focus());
    } catch (error) {
      setUpload(null);
      setUploadError(error instanceof Error ? error.message : "The upload failed.");
      say("");
    }
  }
  function insertImage() {
    if (upload?.state !== "alt" || !alt.trim()) return;
    const md = (propsRef.current.imageMarkdown ?? defaultImage)({ url: upload.url, alt: alt.trim(), name: upload.name });
    setUpload(null);
    insertMarkdown(md);
    say("Image inserted.");
  }

  // ---- the toolbar
  const run = (fn: (e: Editor) => void) => () => {
    const editor = editorRef.current;
    if (editor) fn(editor);
  };
  const tools = [
    { id: "bold", label: "Bold", hint: "Ctrl or Cmd + B", keys: "Control+B Meta+B", paths: GLYPH.bold, run: run((e) => e.chain().focus().toggleBold().run()) },
    { id: "italic", label: "Italic", hint: "Ctrl or Cmd + I", keys: "Control+I Meta+I", paths: GLYPH.italic, run: run((e) => e.chain().focus().toggleItalic().run()) },
    { id: "link", label: "Link", hint: "Ctrl or Cmd + K, searches pages", keys: "Control+K Meta+K", paths: GLYPH.link, run: openPalette },
    {
      id: "heading",
      label: "Heading level",
      hint: "Cycles h2, h3, h4, none",
      paths: GLYPH.heading,
      run: run((e) => {
        const level = e.isActive("heading", { level: 2 }) ? 3 : e.isActive("heading", { level: 3 }) ? 4 : e.isActive("heading", { level: 4 }) ? 0 : 2;
        if (level === 0) e.chain().focus().setParagraph().run();
        else e.chain().focus().setHeading({ level: level as 2 | 3 | 4 }).run();
      }),
    },
    { id: "code", label: "Code", hint: "Ctrl or Cmd + E", keys: "Control+E Meta+E", paths: GLYPH.code, run: run((e) => e.chain().focus().toggleCode().run()) },
    {
      id: "footnote",
      label: "Footnote",
      hint: "A reference and its definition",
      paths: GLYPH.footnote,
      run: run((e) => {
        const md = markdown(e);
        const n = Math.max(0, ...[...md.matchAll(/\[\^(\d+)\]/g)].map((m) => Number(m[1]))) + 1;
        e.chain().focus().insertContent({ type: "sourceInline", attrs: { text: `[^${n}]` } }).run();
        const end = e.state.doc.content.size;
        e.chain().insertContentAt(end, { type: "sourceBlock", attrs: { text: `[^${n}]: `, kind: "Footnote" } }).run();
        focusSourceBlock(e, { id: "footnote", label: "Footnote", hint: "", text: `[^${n}]: ` });
      }),
    },
    ...(onUpload ? [{ id: "image", label: "Insert image", hint: "Uploads a file, then asks for alt text", paths: GLYPH.image, run: () => fileRef.current?.click() }] : []),
  ];
  const blocks = scaffolds.map((s) => ({ id: `scaffold-${s.id}`, label: s.label, hint: s.hint, keys: undefined, paths: s.icon ? [s.icon] : [], glyph: s.label.slice(0, 1), run: () => insertScaffold(s, false) }));
  const all = [...tools, ...blocks];
  const onToolbarKey = (event: React.KeyboardEvent) => {
    const buttons = [...(toolbar.current?.querySelectorAll<HTMLButtonElement>(".cap-md-tool:not(:disabled)") ?? [])];
    const at = buttons.findIndex((b) => b === document.activeElement);
    const next = event.key === "ArrowRight" ? (at + 1) % buttons.length : event.key === "ArrowLeft" ? (at - 1 + buttons.length) % buttons.length : event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : null;
    if (next === null || at < 0) return;
    event.preventDefault();
    buttons[next]?.focus();
  };
  const off = readOnly || !ready;

  return (
    <div className="cap-md app-rich" data-ready={ready ? "" : undefined}>
      <div className="cap-md-frame">
        <div className="cap-md-toolbar" role="toolbar" aria-label="Formatting" ref={toolbar} onKeyDown={onToolbarKey}>
          {all.map((t, i) => (
            <span key={t.id} className="app-tool-slot">
              {i === tools.length && blocks.length > 0 ? <span className="cap-md-sep" aria-hidden="true" /> : null}
              <button
                type="button"
                className="cap-md-tool"
                aria-label={t.label}
                title={`${t.label}. ${t.hint}`}
                aria-keyshortcuts={t.keys}
                tabIndex={i === activeTool ? 0 : -1}
                disabled={off}
                onFocus={() => setActiveTool(i)}
                onMouseDown={(e) => e.preventDefault()}
                onClick={t.run}
              >
                {t.paths.length > 0 ? (
                  <Icon paths={t.paths} />
                ) : (
                  <span className="cap-md-tool-glyph" aria-hidden="true">
                    {"glyph" in t ? String(t.glyph) : ""}
                  </span>
                )}
              </button>
            </span>
          ))}
          {blocks.length > 0 ? <span className="cap-md-toolbar-hint">Type / on an empty line</span> : null}
        </div>
        {!ready ? (
          // Before the script: the Markdown itself, readable and selectable.
          <textarea className="cap-input cap-md-source" aria-label={ariaLabel} value={value} readOnly rows={12} />
        ) : null}
        <div ref={host} className="cap-md-surface app-rich-host" hidden={!ready} />
        <div className="cap-md-foot">
          {onUpload ? (
            <p className="cap-md-hint" aria-hidden="true">
              <Icon paths={GLYPH.image} />
              Drop or paste an image to upload it
            </p>
          ) : null}
          <p className="cap-md-stats">
            <span className="cap-md-count">
              {words.toLocaleString("en")} word{words === 1 ? "" : "s"}
              {words > 0 ? ` ยท ${minutesForWords(words)} min read` : ""}
            </span>
          </p>
        </div>
      </div>
      <p className="cap-sr-only" role="status">
        {said}
      </p>
      {onUpload ? (
        <input
          ref={fileRef}
          type="file"
          hidden
          tabIndex={-1}
          accept={accept}
          onChange={(e) => {
            const f = e.target.files?.[0];
            e.target.value = "";
            if (f) void startUpload(f);
          }}
        />
      ) : null}
      {uploadError ? (
        <p className="cap-field-error cap-md-upload-error" role="alert">
          {uploadError}
        </p>
      ) : null}
      {upload ? (
        <div className="cap-md-upload" role="group" aria-label="Describe the image" aria-busy={upload.state === "sending" ? "true" : undefined}>
          {upload.state === "sending" ? (
            <p className="cap-md-upload-wait">Uploading {upload.name}</p>
          ) : (
            <>
              <img className="cap-md-upload-thumb" src={upload.url} alt="" />
              <div className="cap-md-upload-fields">
                <label className="cap-field-label" htmlFor="rich-alt">
                  Alt text{" "}
                  <span className="cap-field-required" aria-hidden="true">
                    Required
                  </span>
                </label>
                <input
                  ref={altRef}
                  id="rich-alt"
                  className="cap-input"
                  autoComplete="off"
                  placeholder="What the image shows"
                  aria-describedby="rich-alt-help"
                  value={alt}
                  onChange={(e) => setAlt(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === "Enter") {
                      e.preventDefault();
                      insertImage();
                    } else if (e.key === "Escape") {
                      e.preventDefault();
                      setUpload(null);
                      editorRef.current?.commands.focus();
                    }
                  }}
                />
                <p className="cap-field-help" id="rich-alt-help">
                  The image is uploaded. It is not in the text until it has alt text.
                </p>
                <div className="cap-md-upload-actions">
                  <button type="button" className="cap-btn" data-variant="primary" disabled={!alt.trim()} onClick={insertImage}>
                    Insert image
                  </button>
                  <button
                    type="button"
                    className="cap-btn"
                    data-variant="quiet"
                    onClick={() => {
                      setUpload(null);
                      editorRef.current?.commands.focus();
                    }}
                  >
                    Cancel
                  </button>
                </div>
              </div>
            </>
          )}
        </div>
      ) : null}
      {palette ? (
        <div
          ref={paletteRef}
          className="cap-popover cap-md-palette"
          popover="manual"
          role="group"
          aria-label="Insert a link"
          data-size="lg"
          data-side="bottom"
          data-align="start"
          onBlur={(e) => {
            if (!(e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget))) closePalette(false);
          }}
        >
          <label className="cap-md-palette-label" htmlFor="rich-link">
            Link to
          </label>
          <input
            id="rich-link"
            type="text"
            className="cap-input"
            role="combobox"
            aria-autocomplete="list"
            aria-expanded={matches.length > 0}
            aria-controls="rich-link-list"
            aria-activedescendant={matches.length > 0 ? `rich-link-${palette.active}` : undefined}
            autoComplete="off"
            spellCheck={false}
            placeholder="Search pages, or type a URL"
            value={palette.query}
            onChange={(e) => setPalette({ ...palette, query: e.target.value, active: 0 })}
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                e.preventDefault();
                e.stopPropagation();
                closePalette(true);
              } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
                e.preventDefault();
                if (matches.length) setPalette({ ...palette, active: (palette.active + (e.key === "ArrowDown" ? 1 : matches.length - 1)) % matches.length });
              } else if (e.key === "Enter") {
                e.preventDefault();
                if (typedUrl) applyLink(typedUrl, typedUrl);
                else if (matches[palette.active]) applyLink(matches[palette.active]!.href, matches[palette.active]!.title);
              }
            }}
          />
          <div className="cap-listbox" role="listbox" id="rich-link-list" aria-label="Pages" data-wrap="">
            {matches.map((m, i) => (
              <div
                key={m.href}
                id={`rich-link-${i}`}
                className="cap-option"
                role="option"
                aria-selected={i === palette.active}
                data-active={i === palette.active ? "" : undefined}
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => applyLink(m.href, m.title)}
              >
                <span className="cap-option-label">{m.title}</span>
                {m.note ? <span className="cap-md-option-note"> ({m.note})</span> : null}
                {m.hint ? <span className="cap-md-option-hint">{m.hint}</span> : null}
              </div>
            ))}
          </div>
          <p className="cap-md-palette-note" role="status">
            {typedUrl ? `Enter links to ${typedUrl}.` : matches.length === 0 ? "No page matches. Type a full address to link to it." : `${matches.length} page${matches.length === 1 ? "" : "s"}.`}
          </p>
        </div>
      ) : null}
      {slash !== null ? (
        <ul
          ref={slashRef}
          className="cap-popover cap-md-slash"
          popover="manual"
          aria-label="Insert a block"
          data-flush=""
          data-size="auto"
          data-side="bottom"
          data-align="start"
          onKeyDown={(e) => {
            const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>(".cap-md-slash-item")];
            const at = items.findIndex((b) => b === document.activeElement);
            if (e.key === "Escape") {
              e.preventDefault();
              e.stopPropagation();
              setSlash(null);
              editorRef.current?.commands.focus();
            } else if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") {
              e.preventDefault();
              const next = e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : (at + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length;
              items[next]?.focus();
            }
          }}
        >
          {scaffolds.map((s) => (
            <li key={s.id}>
              <button type="button" className="cap-md-slash-item" tabIndex={-1} onClick={() => insertScaffold(s, true)}>
                <strong>{s.label}</strong>
                <span className="cap-md-slash-hint">{s.hint}</span>
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}