Skip to the content

Capsid menu

components/markdown-editor/markdown-editor.view.ts

244 lines and 33 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. It is in an import cycle. This file on GitHub.

Functions

The functions of markdown-editor.view.ts
FunctionLinesComplexityCoveredCRAPMutation
syncScroller23 to 285No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
constructor33 to 382No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:34>34 to 341No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:37>37 to 371No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
update39 to 414No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:40>40 to 401No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
destroy42 to 441No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
createSurface148 to 2572No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
attrExt153 to 1541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
editableExt155 to 1582No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:177>177 to 1804No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
paste182 to 1884No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:183>183 to 1831No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
drop189 to 1944No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:190>190 to 1901No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run198 to 1981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run199 to 1991No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run200 to 2001No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run201 to 2011No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run202 to 2021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run203 to 2031No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run204 to 2041No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
getValue213 to 2131No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
setValue214 to 2182No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
focus219 to 2191No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
hasFocus220 to 2201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
selection221 to 2241No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
caret225 to 2291No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
coords230 to 2332No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
select234 to 2361No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
apply237 to 2452No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
configure246 to 2541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
destroy255 to 2551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/markdown-editor/markdown-editor.ts. Imported by components/markdown-editor/markdown-editor.ts.

Source

// The CodeMirror half of the markdown editor. Only this file imports CodeMirror, and only
// markdown-editor.ts's dynamic import() reaches it, so the editor stays its own chunk and a
// bundle that never mounts one never carries it. It exposes a small `Surface` and nothing of
// CodeMirror's own types, so the chrome (toolbar, link palette, slash menu, image prompt)
// stays plain DOM in markdown-editor.ts.
//
// CodeMirror writes its styles into the page unlayered, and an unlayered rule beats a
// `@layer cap.components` one. So the theme for CodeMirror's own parts is written through its
// theme API below, from Capsomer's tokens only; everything else is in markdown-editor.css.
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, lineNumbers, placeholder as cmPlaceholder } from "@codemirror/view";
import { tags } from "@lezer/highlight";

import type { Edit } from "./markdown-editor.ts";

// A scroller that overflows must be reachable by keyboard (WCAG 2.1.1, axe's
// scrollable-region-focusable): it takes tabindex="0" while it overflows and gives CodeMirror's
// own -1 back when it does not, as the dialog body does. The text inside is focusable too, so a
// keyboard user meets the scroller first (arrow keys scroll it), then the text.
function syncScroller(view: EditorView): void {
  const sc = view.scrollDOM;
  const over = sc.scrollHeight > sc.clientHeight + 1;
  if (over && sc.getAttribute("tabindex") !== "0") sc.tabIndex = 0;
  else if (!over && sc.getAttribute("tabindex") === "0") sc.tabIndex = -1;
}

const scrollerStop = ViewPlugin.fromClass(
  class {
    private readonly observer: ResizeObserver | null;
    constructor(readonly view: EditorView) {
      this.observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => syncScroller(view));
      this.observer?.observe(view.scrollDOM);
      this.observer?.observe(view.contentDOM);
      queueMicrotask(() => syncScroller(view));
    }
    update(u: { docChanged: boolean; geometryChanged: boolean; viewportChanged: boolean }) {
      if (u.docChanged || u.geometryChanged || u.viewportChanged) queueMicrotask(() => syncScroller(this.view));
    }
    destroy() {
      this.observer?.disconnect();
    }
  },
);

export type KeyName = "bold" | "italic" | "link" | "code" | "save" | "escape" | "down";

export interface SurfaceConfig {
  parent: HTMLElement;
  doc: string;
  placeholder: string;
  nonce: string;
  lineNumbers: boolean;
  readOnly: boolean;
  disabled: boolean;
  // Attributes on the editable element: its name, aria-invalid, aria-describedby, and so on.
  attrs: Record<string, string>;
  onDocChange(value: string): void;
  onCaret(): void;
  // Return true when the key was used, so CodeMirror does nothing more with it.
  onKey(name: KeyName): boolean;
  // Return true to take a dropped or pasted image file.
  onFile(file: File): boolean;
}

export interface Caret {
  head: number;
  lineFrom: number;
  lineTo: number;
  lineText: string;
}

export interface Surface {
  getValue(): string;
  setValue(value: string): void;
  focus(): void;
  hasFocus(): boolean;
  selection(): { from: number; to: number };
  caret(): Caret;
  coords(pos: number): { top: number; bottom: number; left: number } | null;
  select(from: number, to: number): void;
  apply(edit: Edit): void;
  configure(c: { placeholder: string; readOnly: boolean; disabled: boolean; attrs: Record<string, string> }): void;
  destroy(): void;
}

// Every colour, font and size is a Capsomer token, so both themes and every density follow
// without a second copy. The focus ring sits inside the surface, which clips overflow: an
// outside ring would be cut off (WCAG 2.4.7).
const capTheme = EditorView.theme({
  "&": {
    // Colour and ground come from the frame around it (markdown-editor.css), so read-only and
    // disabled change them there, in one place.
    color: "inherit",
    backgroundColor: "transparent",
    fontSize: "var(--text-size)",
    maxHeight: "var(--cap-md-max, min(70vh, 48rem))",
  },
  "&.cm-focused": {
    outline: "var(--ring-width) solid var(--ring)",
    outlineOffset: "calc(-1 * var(--ring-width))",
  },
  ".cm-scroller": {
    overflow: "auto",
    fontFamily: "var(--mono)",
    lineHeight: "1.65",
  },
  ".cm-content": {
    caretColor: "var(--accent)",
    padding: "var(--pad-y) 0",
    minHeight: "var(--cap-md-min, 16rem)",
  },
  ".cm-line": { padding: "0 var(--pad-x)" },
  ".cm-gutters": {
    backgroundColor: "var(--sunken)",
    color: "var(--muted)",
    border: "none",
    borderRight: "1px solid var(--line)",
  },
  // The selection is the accent mixed into the surface: the pale --sel token is nearly the
  // surface colour and would not show where the selection is.
  "& .cm-content ::selection, & .cm-content::selection": {
    backgroundColor: "color-mix(in srgb, var(--accent) 36%, var(--surface))",
    color: "var(--text)",
  },
  ".cm-placeholder": { color: "var(--dim)" },
});

// Meaning is never colour alone: headings and strong are bold, emphasis is italic, links are
// underlined, quotes are italic. Each colour is a token chosen to reach 4.5:1 on --surface.
const capHighlight = HighlightStyle.define([
  { tag: tags.heading, color: "var(--text)", fontWeight: "700" },
  { tag: tags.strong, color: "var(--text)", fontWeight: "700" },
  { tag: tags.emphasis, color: "var(--text)", fontStyle: "italic" },
  { tag: tags.link, color: "var(--accent-text)", textDecoration: "underline" },
  { tag: tags.url, color: "var(--accent-text)" },
  { tag: tags.monospace, color: "var(--info)" },
  { tag: tags.quote, color: "var(--muted)", fontStyle: "italic" },
  { tag: tags.list, color: "var(--muted)" },
  { tag: tags.meta, color: "var(--muted)" },
  { tag: tags.processingInstruction, color: "var(--muted)" },
  { tag: tags.contentSeparator, color: "var(--muted)" },
  { tag: tags.strikethrough, color: "var(--muted)", textDecoration: "line-through" },
]);

export function createSurface(cfg: SurfaceConfig): Surface {
  const attrsSlot = new Compartment();
  const editableSlot = new Compartment();
  const placeholderSlot = new Compartment();

  const attrExt = (attrs: Record<string, string>) =>
    EditorView.contentAttributes.of({ role: "textbox", "aria-multiline": "true", spellcheck: "true", ...attrs });
  const editableExt = (readOnly: boolean, disabled: boolean) => [
    EditorView.editable.of(!disabled),
    EditorState.readOnly.of(readOnly || disabled),
  ];

  const view: EditorView = new EditorView({
    parent: cfg.parent,
    state: EditorState.create({
      doc: cfg.doc,
      extensions: [
        // Without the nonce a strict style-src drops every style CodeMirror injects.
        EditorView.cspNonce.of(cfg.nonce),
        history(),
        markdown({ base: markdownLanguage }),
        syntaxHighlighting(capHighlight),
        capTheme,
        scrollerStop,
        EditorView.lineWrapping,
        cfg.lineNumbers ? lineNumbers() : [],
        attrsSlot.of(attrExt(cfg.attrs)),
        editableSlot.of(editableExt(cfg.readOnly, cfg.disabled)),
        placeholderSlot.of(cmPlaceholder(cfg.placeholder)),
        EditorView.updateListener.of((u) => {
          if (u.docChanged) cfg.onDocChange(u.state.doc.toString());
          if (u.docChanged || u.selectionSet) cfg.onCaret();
        }),
        EditorView.domEventHandlers({
          paste(event) {
            const item = [...(event.clipboardData?.items ?? [])].find((i) => i.type.startsWith("image/"));
            const file = item?.getAsFile();
            if (!file || !cfg.onFile(file)) return false;
            event.preventDefault();
            return true;
          },
          drop(event) {
            const file = [...(event.dataTransfer?.files ?? [])].find((f) => f.type.startsWith("image/"));
            if (!file || !cfg.onFile(file)) return false;
            event.preventDefault();
            return true;
          },
        }),
        // Tab is not bound: it leaves the editor, so the surface is no keyboard trap (WCAG 2.1.2).
        keymap.of([
          { key: "Mod-b", run: () => cfg.onKey("bold"), preventDefault: true },
          { key: "Mod-i", run: () => cfg.onKey("italic"), preventDefault: true },
          { key: "Mod-k", run: () => cfg.onKey("link"), preventDefault: true },
          { key: "Mod-e", run: () => cfg.onKey("code"), preventDefault: true },
          { key: "Mod-s", run: () => cfg.onKey("save"), preventDefault: true },
          { key: "Escape", run: () => cfg.onKey("escape") },
          { key: "ArrowDown", run: () => cfg.onKey("down") },
          ...historyKeymap,
          ...defaultKeymap,
        ]),
      ],
    }),
  });

  return {
    getValue: () => view.state.doc.toString(),
    setValue(value) {
      const current = view.state.doc.toString();
      if (current === value) return;
      view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
    },
    focus: () => view.focus(),
    hasFocus: () => view.hasFocus,
    selection() {
      const { from, to } = view.state.selection.main;
      return { from, to };
    },
    caret() {
      const head = view.state.selection.main.head;
      const line = view.state.doc.lineAt(head);
      return { head, lineFrom: line.from, lineTo: line.to, lineText: line.text };
    },
    coords(pos) {
      const c = view.coordsAtPos(pos);
      return c ? { top: c.top, bottom: c.bottom, left: c.left } : null;
    },
    select(from, to) {
      view.dispatch({ selection: { anchor: from, head: to } });
    },
    apply(edit) {
      view.dispatch({
        changes: edit.changes,
        selection: { anchor: edit.anchor, head: edit.head ?? edit.anchor },
        scrollIntoView: true,
        userEvent: "input",
      });
      view.focus();
    },
    configure(c) {
      view.dispatch({
        effects: [
          attrsSlot.reconfigure(attrExt(c.attrs)),
          editableSlot.reconfigure(editableExt(c.readOnly, c.disabled)),
          placeholderSlot.reconfigure(cmPlaceholder(c.placeholder)),
        ],
      });
    },
    destroy: () => view.destroy(),
  };
}