← ui

editor

ui

a rich text editor whose document reads as markdown, as a page or a form field

npx shadcn add @ag/editor
Already installed? Preview an update
npx shadcn add @ag/editor --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • Fixed: the plain field editor's tools no longer show through a layer laid over the page (a full view, a dialog). Wordgard ranks its own panels at z-index 300, and nothing in the plain field kept that number inside the editor; the well is now the stacking context for everything Wordgard draws, in every face.
  • Changed (breaking): the editor looks like markdown. variant="page", the default, has no box: the document at the size of where it is placed (no pinned 15 px), set as markdown sets it, its first block flush with the top, its measure yours (className, as markdown's is); the tools are a quiet row in the registry's voice (the alpha ladder's quiet tier, a wash under the hand, a wash when on, never a solid fill, no grey bar, a hairline under it) that stays at the top while a long document scrolls (sticky in its scroll container, the caret scrolled clear of it); focus is the tokens' keyboard ring round the document. Migration: an editor in a form (a comment, a message, a proposal's body) takes variant="field" to keep a bounded box.
  • New: variant="field", a field in a form: the textarea's well (its surface and hairline, brightening under the hand and with the keyboard's focus) round the tools and the document, at the textarea's size (13 px, its padding), in both materials (material, scheme, from a MaterialProvider round it): in glass a well of the one glass material, its rim lit by the hand and the keyboard. invalid stains it red and sets aria-invalid on the document.
  • New: rows, the lines it is tall when empty (5 on a page, 3 in a field). Migration: --editor-min-height is gone; set rows.
  • Changed: Wordgard's menus (a block's kinds, a table's verbs) are menu's plain popup with its rows, its dialogs and the link's address on the registry's washes and hairlines; the / menu is menu's popup and rows, glass in a glass editor. A quote is markdown's (a wash, the quote voice, the mark in the gutter), where Wordgard's grey bar and indent showed through.
  • Changed: the uploads say what they are by name. The list is pictures going up (it was Image uploads), each bar <file> going up, its retry try <file> again and its cancel cancel <file> (they were retry and cancel, the same for every file). Migration: a test or a script that finds them by name uses the new names; a bar is a progressbar role, not a <progress> element.
Updated 2026-10-11 · release notes

also called wysiwyg

shelf text & code

uses markdown, material, menu, upload

the demo above, verbatim

editor.demo.tsx
"use client"
import "@/registry/base-nova/ui/edge-fade"
import { useState } from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Editor } from "./editor"

// #region essay
// Long enough to scroll past its tools: they stay at the top of the page.
const ESSAY = `# The slow road

We left before the sun was up, and the road ran along the water the whole way. **One lens, no gimbal**, and a rule: *stop when the light is good*, never when the map says so.

## What we carried

- a 35mm prime, and nothing wider
- two batteries, both charged the night before
- the wall, the roof of the car, a friend's shoulder

> The best camera is the one that's with you.

## The bay

The first stop was the bay, an hour after we set out. The tide was out and the boats sat on the sand at angles, the light coming low across them from the east. We stayed longer than we meant to.

### The pass

By noon the road climbed away from the water into the pass. The light went flat and hard, and we drove through it without stopping, which was the rule working.

## Coming back

The light came back on the way down, in the last hour. The pass at dusk was the picture of the day, and it was the one we nearly did not stop for.
`
// #endregion

// #region proposal
// A form: a title, the body in a field, a send. The body is wrong past 280
// characters, and says so in the field's red.
function Proposal() {
  const [body, setBody] = useState("")
  return (
    <form
      className="flex flex-col gap-3"
      onSubmit={(event) => event.preventDefault()}
    >
      <Input placeholder="what to change" aria-label="title" />
      <Editor
        variant="field"
        format="markdown"
        label="why"
        placeholder="why, and what it would take"
        images={false}
        onChange={setBody}
        invalid={body.length > 280}
      />
      <Button type="submit" className="self-end">
        propose
      </Button>
    </form>
  )
}
// #endregion

// #region note
// A markdown file as it comes off a disk: the editor holds what it can as itself and
// keeps the rest (the front matter, the task list, the aligned table) as its source.
const NOTE = `---
title: trip notes
---

# trip notes

Leave at **7**, coffee on the way, the *beach* at golden hour.

- one lens, no gimbal
- ask about the boat
  - Saturday, if the wind drops

\`\`\`sh
ffmpeg -i bay.mov -vf fps=3 frames/%03d.jpg
\`\`\`

| shot | lens |
|------|------|
| the bay | 24mm |
| the pass | 35mm |

- [ ] charge the batteries

| right | aligned |
|------:|--------:|
| 1 | 2 |
`
// #endregion

function MarkdownFile() {
  const [text, setText] = useState(NOTE)
  return (
    <div className="grid w-full gap-4 md:grid-cols-[minmax(0,1fr)_18rem]">
      <Editor
        label="trip notes.md"
        format="markdown"
        defaultValue={NOTE}
        onChange={setText}
      />
      {/* The surface a frame round the scroller, whose cut edges fade. */}
      <div className="flex max-h-[32rem] rounded-lg bg-foreground/4">
        <pre className="ag-edge-fade-xy min-h-0 flex-1 p-3 font-mono text-[11px] leading-5 text-foreground/70">
          {text}
        </pre>
      </div>
    </div>
  )
}

export default function Demo() {
  const [html, setHtml] = useState("")
  return (
    <div className="space-y-8">
      <Sample
        name="page"
        title="a page to write in"
        description="the default: no box, the document at the size of where it is placed, set as markdown sets it; the measure is yours (className), as markdown's is."
        with="essay"
      >
        <Editor
          label="the slow road"
          format="markdown"
          defaultValue={ESSAY}
          className="mx-auto max-w-[42rem]"
        />
      </Sample>
      <Sample
        name="field"
        title="a field in a form"
        description="variant field: the textarea's well and size round the tools and the document. MaterialProvider turns it to glass. Red past 280 characters (invalid)."
        with="proposal"
      >
        <div className="grid gap-4 md:grid-cols-2">
          <Proposal />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
              <Proposal />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="editor"
        glance="large"
        title="HTML in/out"
        description="supply upload for image paste, drop and attachments"
      >
        <Editor
          label="Example document"
          defaultValue="<h2>A place to think</h2><p>Write, select text, or make a list.</p>"
          onChange={setHtml}
        />
        <details className="mt-4">
          <summary>document HTML</summary>
          <pre className="ag-edge-fade-xy text-xs">{html}</pre>
        </details>
      </Sample>
      <Sample
        name="markdown"
        title="a markdown file"
        description="format markdown reads and writes the file itself: code blocks, rules, struck text and tables join the editor, and what it cannot hold (front matter, a task list, an aligned table) stays as its source, written back untouched. Type markdown (- , # , **bold**) and it becomes what it means; / in an empty line offers the blocks; a web address links itself at the space after it; Tab and ⇧Tab nest list items; ⌘⌥1 to 6 make headings, ⌘⇧7 and ⌘⇧8 lists, ⌘E code, ⌘⇧X struck; paste a link over chosen words to link them. The text on the right is what would be saved; it changes only once you edit."
        with="note"
      >
        <MarkdownFile />
      </Sample>
    </div>
  )
}

the source, every file an install writes

editor.tsx
"use client"

import { Image as ImageIcon, RotateCw, X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import type { SlashState } from "@/registry/base-nova/ui/editor-slash"
import { MARKDOWN, MARKDOWN_VARS } from "@/registry/base-nova/ui/markdown"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
  type WornScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Hints } from "@/registry/base-nova/ui/menu"
import type { MediaUploader } from "@/registry/base-nova/ui/upload"
import type { EditorFormat, EditorJob } from "./editor-engine"
import "./editor.css"

// A RICH TEXT EDITOR whose document wears markdown's look, so writing reads as reading.
// Two variants. `page` (the default, for writing): no box, the document at the size
// of where it is placed, a quiet row of tools above it that stays at the top while a
// long document scrolls. `field` (for a form: a comment, a proposal's body): the
// textarea's well round the tools and the document, at the textarea's size, in either
// material. Wordgard draws the tools and the document; the editor draws the row the
// tools sit in, the well, and the ring (editor.css restyles Wordgard's own nodes).

/** `page`: the document as it will be read. `field`: a bounded field in a form. */
export type EditorVariant = "page" | "field"

/** THE FACES, with SLASH below the one place the material is read: each variant's
 *  well round the tools and the document, the row the tools sit in and the document's
 *  own node, in each material. Glass is the material's (`glassVariants`) with the
 *  textarea's states; plain is the registry's surfaces at the alpha ladder. A glass
 *  page has no well either: its tools are a bar of glass over what scrolls. Focus
 *  answers the keyboard only (@ag/tokens): Wordgard's editable node (`wg-content`)
 *  takes it, and the editor draws it, the page's document as the ring, the field's
 *  well as its own state. Wrong (`invalid`), the well is stained red. */
const FACES = {
  page: {
    plain: {
      well: "",
      tools:
        "sticky top-0 z-10 mb-4 border-foreground/8 border-b bg-background/90 py-1 text-foreground backdrop-blur-md",
      document:
        "rounded-md has-[wg-content:focus-visible:not([data-focus-pointer])]:focus-ring",
    },
    glass: {
      well: "",
      // Glass sets `relative`: the row's own placement comes after it.
      tools: cn(
        glassVariants({ shape: "surface" }),
        "sticky top-2 z-10 mb-4 w-fit max-w-full p-1 [--glass-radius:--theme(--radius-2xl)]",
      ),
      document:
        "rounded-md has-[wg-content:focus-visible:not([data-focus-pointer])]:focus-ring",
    },
  },
  // A menu Wordgard opens hangs below the well: while one is open the well stands
  // above whatever follows it.
  field: {
    plain: {
      well: "relative rounded-md has-[wg-menu-list:not([style*=none])]:z-20 bg-foreground/[0.04] ring-1 ring-foreground/12 ring-inset transition-[box-shadow,background-color] hover:ring-foreground/22 has-[wg-content:focus-visible:not([data-focus-pointer])]:bg-foreground/[0.06] has-[wg-content:focus-visible:not([data-focus-pointer])]:ring-foreground/40 data-invalid:ring-tone-bad/70",
      tools: "border-foreground/8 border-b p-1 text-foreground",
      document: "",
    },
    glass: {
      well: cn(
        glassVariants({ shape: "surface" }),
        // textarea's states, `!` as the material's state classes are, so they win
        // over a light given inline; its transition is the glass's own.
        "[--glass-radius:--theme(--radius-xl)] has-[wg-menu-list:not([style*=none])]:z-20 hover:[--glass-rim:1.3]! has-[wg-content:focus-visible:not([data-focus-pointer])]:[--glass-rim:1.7]! data-invalid:[--glass-stain:var(--tone-bad)]! data-invalid:[--glass-rim-color:var(--tone-bad)]! data-invalid:[--glass-rim:2.2]!",
      ),
      tools: "border-current/10 border-b p-1",
      document: "",
    },
  },
} as const

/** The / menu's popup and its rows in each material: menu's own faces. */
const SLASH = {
  plain: {
    popup:
      "rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
    row: "rounded-md aria-selected:bg-foreground/8",
  },
  glass: {
    popup: cn(
      glassVariants({ shape: "surface" }),
      "[--glass-radius:14px] [--glass-blur:14px]",
    ),
    row: "rounded-[10px] aria-selected:bg-current/12",
  },
} as const

/** The type round the document and its padding (px) on Wordgard's editable node, and
 *  how many lines tall it is when empty: the page inherits the size of where it is
 *  placed and sits flush with the page's text; the field is the textarea's (its size,
 *  its padding, three rows). */
const DOCUMENT = {
  page: { size: "", padX: 0, padY: 0, rows: 5 },
  field: { size: "text-[13px]", padX: 10, padY: 6, rows: 3 },
} as const

export type EditorProps = {
  /** The initial document, in `format`. The editor is uncontrolled; use key to load
   *  another document. */
  defaultValue?: string
  /** "html" (the default) for a body a site renders; "markdown" for a markdown
   *  file, read and written as markdown, with code blocks, rules, struck text and
   *  tables, and anything it cannot hold kept as its source (editor-markdown). */
  format?: EditorFormat
  /** The document in `format`, after every change. */
  onChange?: (value: string) => void
  placeholder?: string
  label: string
  className?: string
  /** Disable images for formats that cannot represent them, e.g. Telegram messages. */
  images?: boolean
  upload?: MediaUploader
  /** "page" (the default): no box, the document at the size of where it is placed,
   *  its tools in a quiet row that stays at the top while it scrolls; its measure is
   *  the consumer's (`className`), as markdown's is. "field": a field in a form, the
   *  textarea's well and size. */
  variant?: EditorVariant
  /** Lines tall at least, empty: 5 on a page, 3 in a field (the textarea's). */
  rows?: number
  /** What is written is wrong: the field's well stained red, and `aria-invalid` on
   *  the document. */
  invalid?: boolean
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
}

export function Editor({
  format = "html",
  defaultValue = format === "html" ? "<p></p>" : "",
  onChange,
  placeholder,
  label,
  className,
  images = true,
  upload,
  variant = "page",
  rows,
  invalid = false,
  material,
  scheme,
}: EditorProps) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the editor.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
  const face = FACES[variant][m]
  const doc = DOCUMENT[variant]
  const host = useRef<HTMLDivElement>(null)
  const toolbar = useRef<HTMLDivElement>(null)
  const wrong = useRef(invalid)
  wrong.current = invalid
  const callbacks = useRef({ onChange, upload })
  callbacks.current = { onChange, upload }
  const [jobs, setJobs] = useState<EditorJob[]>([])
  const pendingJobs = useRef<EditorJob[]>([])
  const [error, setError] = useState("")
  const [submitBlocked, setSubmitBlocked] = useState(false)
  const [slash, setSlash] = useState<SlashState | null>(null)
  // Initial document/schema are intentionally read once. Changing the key mounts another document.
  // biome-ignore lint/correctness/useExhaustiveDependencies: uncontrolled editor
  useEffect(() => {
    let cancelled = false
    let teardown: (() => void) | undefined
    import("./editor-engine")
      .then(({ mountEditor }) => {
        if (cancelled || !host.current || !toolbar.current) return
        teardown = mountEditor(host.current, {
          toolbar: toolbar.current,
          documentClass: MARKDOWN,
          defaultValue,
          format,
          placeholder,
          label,
          images,
          // Read once, like the document and the schema: whether the editor offers
          // images at all is a mount-time decision.
          upload: callbacks.current.upload,
          onChange: (value) => callbacks.current.onChange?.(value),
          onJobs: (next) => {
            pendingJobs.current = next
            setJobs(next)
          },
          onSlash: setSlash,
        })
        markInvalid(host.current, wrong.current)
      })
      .catch((e) => {
        if (!cancelled) setError(String(e))
      })
    return () => {
      cancelled = true
      teardown?.()
    }
  }, [])
  useEffect(() => markInvalid(host.current, invalid), [invalid])
  useEffect(() => {
    const form = host.current?.closest("form")
    const prevent = (event: Event) => {
      if (pendingJobs.current.length) {
        event.preventDefault()
        event.stopPropagation()
        setSubmitBlocked(true)
      }
    }
    form?.addEventListener("submit", prevent, true)
    return () => form?.removeEventListener("submit", prevent, true)
  }, [])
  const lines = rows ?? doc.rows
  return (
    <div
      ref={here}
      data-slot="editor"
      data-variant={variant}
      data-material={m}
      className={cn("not-prose space-y-2", schemeOf(s), className)}
    >
      {/* Wordgard ranks its own layers (its panels at z-index 300): the well is
          their stacking context, so none of them outranks a layer of the page (a
          full view, a dialog) the editor sits under. */}
      <div
        data-slot="editor-well"
        data-invalid={invalid || undefined}
        className={cn("isolate", doc.size, face.well)}
      >
        <div
          data-slot="editor-toolbar"
          className={cn("text-[13px]", face.tools)}
        >
          {/* Wordgard places its toolbar here and adds its own classes to this node:
              React gives it none, so it never takes them back. */}
          <div ref={toolbar} data-slot="editor-panels" />
        </div>
        <div
          ref={host}
          data-slot="editor-document"
          className={face.document}
          style={
            {
              ...MARKDOWN_VARS,
              "--ag-editor-pad": `${doc.padY}px ${doc.padX}px`,
              "--ag-editor-min": `calc(${lines}lh + ${2 * doc.padY}px)`,
            } as React.CSSProperties
          }
        />
      </div>
      {slash && <SlashMenu state={slash} material={m} scheme={s} />}
      {error && (
        <p role="alert" className="text-sm text-destructive">
          {error}
        </p>
      )}
      {submitBlocked && jobs.length > 0 && (
        <p role="alert" className="text-[13px] text-tone-bad">
          the pictures still going up have to finish, or be cancelled, before
          this is published
        </p>
      )}
      {jobs.length > 0 && <Uploads jobs={jobs} />}
    </div>
  )
}

/** `aria-invalid` on the document itself (Wordgard's editable node, the textbox a
 *  screen reader is on), once it is mounted. */
function markInvalid(host: HTMLElement | null, invalid: boolean) {
  const textbox = host?.querySelector("[role=textbox]")
  if (invalid) textbox?.setAttribute("aria-invalid", "true")
  else textbox?.removeAttribute("aria-invalid")
}

/** The pictures going up: each one small as it was dropped, a hairline that fills as its
 *  bytes go, then "preparing" while the server makes its renditions, the reason when it
 *  failed, retry and cancel at hand. */
function Uploads({ jobs }: { jobs: readonly EditorJob[] }) {
  return (
    <ul
      aria-label="pictures going up"
      data-slot="editor-uploads"
      className="flex flex-col gap-1.5"
    >
      {jobs.map((job) => {
        const percent = Math.round(job.progress * 100)
        const state = job.error
          ? job.error
          : job.progress >= 1
            ? "preparing"
            : `${percent}%`
        return (
          <li
            key={job.id}
            data-failed={job.error ? "" : undefined}
            className="group/up flex items-center gap-3 rounded-lg bg-foreground/4 p-1.5 pr-1 text-[13px]"
          >
            {job.preview ? (
              // biome-ignore lint/performance/noImgElement: the dropped file itself, an object URL no loader can resize
              <img
                src={job.preview}
                alt=""
                className="size-9 shrink-0 rounded-md object-cover group-data-failed/up:opacity-50"
              />
            ) : (
              <span className="grid size-9 shrink-0 place-items-center rounded-md bg-foreground/6 text-foreground/45">
                <ImageIcon className="size-4" />
              </span>
            )}
            <span className="flex min-w-0 flex-1 flex-col gap-1">
              <span className="flex items-baseline gap-2">
                <span className="truncate text-foreground/85">{job.name}</span>
                <span
                  role="status"
                  className="shrink-0 text-[12px] text-foreground/45 tabular-nums group-data-failed/up:text-tone-bad"
                >
                  {state}
                </span>
              </span>
              {!job.error && (
                <span
                  role="progressbar"
                  aria-label={`${job.name} going up`}
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuenow={percent}
                  className="h-0.5 overflow-hidden rounded-full bg-foreground/10"
                >
                  <span
                    className="block h-full w-(--ag-upload-done) rounded-full bg-foreground/60 transition-[width] duration-200"
                    style={
                      {
                        "--ag-upload-done": `${percent}%`,
                      } as React.CSSProperties
                    }
                  />
                </span>
              )}
            </span>
            {job.error && (
              <button
                type="button"
                onClick={job.retry}
                aria-label={`try ${job.name} again`}
                className={UPLOAD_VERB}
              >
                <RotateCw className="size-3.5" />
              </button>
            )}
            <button
              type="button"
              onClick={job.cancel}
              aria-label={`cancel ${job.name}`}
              className={UPLOAD_VERB}
            >
              <X className="size-3.5" />
            </button>
          </li>
        )
      })}
    </ul>
  )
}

const UPLOAD_VERB =
  "grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground"

/** The / menu, hung under the cursor: the blocks the paragraph can become, the one
 *  Enter takes marked, each with the keys that make it without the menu. The keys
 *  stay in the document (editor-slash); a press here chooses. Drawn as menu's popup
 *  and rows, in the editor's material and scheme. */
function SlashMenu({
  state,
  material,
  scheme,
}: {
  state: SlashState
  material: Material
  scheme: WornScheme
}) {
  return createPortal(
    <div
      role="listbox"
      aria-label="turn into"
      data-slot="editor-slash"
      data-material={material}
      className={cn(
        schemeOf(scheme),
        SLASH[material].popup,
        "fixed top-(--ag-slash-top) left-(--ag-slash-left) z-50 min-w-56 p-1",
      )}
      style={
        {
          "--ag-slash-top": `${state.at.bottom + 6}px`,
          "--ag-slash-left": `${state.at.left - 8}px`,
        } as React.CSSProperties
      }
    >
      {state.choices.map((c, i) => (
        <div
          key={c.id}
          role="option"
          aria-selected={i === state.marked}
          tabIndex={-1}
          // A press chooses without taking focus from the document first.
          onPointerDown={(e) => {
            e.preventDefault()
            state.choose(i)
          }}
          className={cn(
            "flex cursor-pointer select-none items-center gap-6 px-2 py-1.5 text-[13px] text-current/85 aria-selected:text-current",
            SLASH[material].row,
          )}
        >
          <span className="flex-1">{c.label}</span>
          {c.keys && <Hints hint={c.keys} keysOnly className="opacity-50" />}
        </div>
      ))}
    </div>,
    document.body,
  )
}
editor-engine.ts
import { serialize } from "wordgard/doc"
import { menuBar, Panel, placeholder, Wordgard } from "wordgard/editor"
import { history } from "wordgard/history"
import {
  blockDoc,
  blockquote,
  bulletList,
  code,
  codeBlock,
  codeBlockLanguage,
  emphasis,
  heading,
  horizontalRule,
  image,
  lineBreak,
  link,
  orderedList,
  paragraph,
  strikethrough,
  strong,
} from "wordgard/schema"
import { GardState } from "wordgard/state"
import { tables } from "wordgard/table"
import { Blockquote, Image as ImageNode } from "wordgard/types"
import { structureKeys } from "@/registry/base-nova/ui/editor-keys"
import {
  htmlToMarkdown,
  markdownInput,
  markdownToHtml,
} from "@/registry/base-nova/ui/editor-markdown"
import {
  type SlashState,
  slashMenu,
} from "@/registry/base-nova/ui/editor-slash"
import type { MediaUploader } from "@/registry/base-nova/ui/upload"

/** What the document is read from and written as: HTML (a body a site renders), or
 *  markdown (a file; editor-markdown). */
export type EditorFormat = "html" | "markdown"

export type EditorJob = {
  id: string
  name: string
  /** The first picture being sent, as an object URL, released when the job ends. */
  preview?: string
  progress: number
  error?: string
  retry: () => void
  cancel: () => void
}
type Job = EditorJob & {
  from: number
  to: number
  controller: AbortController
  files: File[]
  /** A toolbar upload is Wordgard's own promise: it resolves with the URL for the
   *  dialog's field, or rejects when the job is cancelled or the editor goes away, so
   *  the dialog never receives an empty string as a picture. Paste and drop have no
   *  promise; they insert mapped nodes. */
  settle?: { resolve: (src: string) => void; reject: (error: Error) => void }
  onProgress?: (percent: number) => void
}

export function mountEditor(
  host: HTMLElement,
  opts: {
    /** Where Wordgard's toolbar and its dialogs (an image's, a link's) are placed: an
     *  element the editor draws itself, so the toolbar's row (sticky on a page, inside
     *  a field's well, glass) is the editor's own. */
    toolbar: HTMLElement
    /** The document's look, on the editable node itself (markdown's MARKDOWN), so a
     *  rule on its first and last block reaches them and the toolbar wears none of it. */
    documentClass: string
    /** In `format`: HTML, or a markdown file's text. */
    defaultValue: string
    format: EditorFormat
    label: string
    placeholder?: string
    images: boolean
    upload?: MediaUploader
    /** The document in `format`, after every change. */
    onChange: (value: string) => void
    onJobs: (jobs: EditorJob[]) => void
    /** The / menu: what it shows, null when closed (editor-slash). */
    onSlash: (state: SlashState | null) => void
  },
) {
  const jobs = new Map<string, Job>()
  let alive = true
  const notify = () => {
    if (alive) opts.onJobs([...jobs.values()])
  }
  const run = async (job: Job, wg: Wordgard) => {
    const handler = opts.upload
    if (!handler) return
    job.controller.abort()
    const controller = new AbortController()
    job.controller = controller
    job.error = undefined
    job.progress = 0
    // The gate is the file, before a byte moves: a video dropped into an image slot is
    // refused here, not after it has been uploaded and turned away.
    const wrong = job.files.find((file) => !file.type.startsWith("image/"))
    if (wrong) {
      job.error = `${wrong.name} is not an image`
      notify()
      return
    }
    notify()
    try {
      const progress = job.files.map(() => 0)
      const assets = await Promise.all(
        job.files.map((file, index) =>
          handler(file, {
            signal: controller.signal,
            onProgress: (value) => {
              if (
                alive &&
                !controller.signal.aborted &&
                job.controller === controller
              ) {
                progress[index] = value
                job.progress =
                  progress.reduce((a, b) => a + b, 0) / progress.length
                // Wordgard creates its progress element after invoking the uploader.
                const percent = job.progress * 100
                queueMicrotask(() => {
                  if (alive && !controller.signal.aborted)
                    job.onProgress?.(percent)
                })
                notify()
              }
            },
          }),
        ),
      )
      if (!alive || controller.signal.aborted || !jobs.has(job.id)) return
      // The server's contract, not the file's: an image went up, an image comes back.
      if (assets.some((asset) => asset.kind === "video"))
        throw new Error("the server sent back a video for an image")
      const first = assets[0]
      if (!first) throw new Error("the server sent back nothing")
      end(job)
      if (job.settle) job.settle.resolve(first.src)
      else
        wg.dispatch({
          changes: {
            from: job.from,
            to: job.to,
            insert: assets.map((asset) => ImageNode.of(asset.src)),
            fit: true,
          },
          userEvent: "input.image",
        })
      notify()
    } catch (error) {
      if (!alive || controller.signal.aborted) return
      controller.abort()
      job.error = error instanceof Error ? error.message : "it did not go up"
      notify()
    }
  }
  /** A job done or given up: gone from the list, its preview let go. */
  const end = (job: Job) => {
    jobs.delete(job.id)
    if (job.preview) URL.revokeObjectURL(job.preview)
  }
  const enqueue = (
    files: File[],
    wg: Wordgard,
    from: number,
    to = from,
    toolbar?: {
      resolve: (src: string) => void
      reject: (error: Error) => void
      progress: (percent: number) => void
    },
  ) => {
    const id = crypto.randomUUID()
    const job: Job = {
      id,
      files,
      settle: toolbar && { resolve: toolbar.resolve, reject: toolbar.reject },
      onProgress: toolbar?.progress,
      name: files.map((file) => file.name).join(", "),
      preview: files[0]?.type.startsWith("image/")
        ? URL.createObjectURL(files[0])
        : undefined,
      from,
      to,
      progress: 0,
      controller: new AbortController(),
      retry: () => void run(job, wg),
      cancel: () => {
        job.controller.abort()
        end(job)
        job.settle?.reject(new Error("upload cancelled"))
        notify()
      },
    }
    jobs.set(id, job)
    void run(job, wg)
  }
  const paste = Wordgard.domEventHandler("paste", (event, wg) => {
    if (!opts.upload || !opts.images || !event.clipboardData) return false
    const files = [...event.clipboardData.files].filter((f) =>
      f.type.startsWith("image/"),
    )
    if (!files.length) return false
    event.preventDefault()
    const { anchor, head } = wg.state.selection
    enqueue(files, wg, Math.min(anchor, head), Math.max(anchor, head))
    return true
  })
  // Capture the drop position now, then map through edits. Upstream resolves screen coordinates
  // only after upload, which can point at different text after scrolling or typing.
  const drop = Wordgard.domEventHandler("drop", (event, wg) => {
    if (!opts.upload || !opts.images || !event.dataTransfer) return false
    const files = [...event.dataTransfer.files].filter((f) =>
      f.type.startsWith("image/"),
    )
    if (!files.length) return false
    event.preventDefault()
    enqueue(
      files,
      wg,
      wg.posAtCoords({ x: event.clientX, y: event.clientY }).pos,
    )
    return true
  })
  const markdown = opts.format === "markdown"
  const editor = Wordgard.create({
    doc: markdown ? markdownToHtml(opts.defaultValue) : opts.defaultValue,
    parent: host,
    config: [
      blockDoc(),
      paragraph(),
      heading(),
      lineBreak(),
      strong(),
      emphasis(),
      code(),
      link(),
      // List items hold blocks in either format: a paragraph typed `- ` or `1. ` is
      // wrapped as one, and a list can nest. A markdown file also holds code, rules,
      // struck text and tables; an HTML body keeps the narrower set its consumers
      // render. Markdown typed or pasted becomes what it means in both.
      bulletList({ blockItems: true }),
      orderedList({ blockItems: true }),
      // blockquote() without its theme: Wordgard's grey bar and indent are unlayered
      // and beat markdown's quote (a wash, the quote voice, the mark in the gutter).
      GardState.schemaElement.of(Blockquote),
      blockquote.button,
      blockquote.createOnGT,
      markdownInput(markdown),
      structureKeys(markdown),
      ...slashMenu(markdown, opts.onSlash),
      ...(markdown
        ? [
            codeBlock(),
            codeBlockLanguage(),
            horizontalRule(),
            strikethrough(),
            tables({ cellSpanning: false, cellContent: "inline" }),
          ]
        : []),
      history(),
      menuBar(),
      Panel.configure({ topContainer: opts.toolbar }),
      // Panels placed outside the editor are not counted as covering it; a sticky
      // toolbar does cover the document's top, so the caret is scrolled clear of it.
      Wordgard.coveredMargins.of((wg) => ({
        top: Math.max(
          0,
          opts.toolbar.getBoundingClientRect().bottom -
            Math.max(0, wg.scrollDOM.getBoundingClientRect().top),
        ),
      })),
      Wordgard.contentAttributes.of({
        class: opts.documentClass,
        "aria-label": opts.label,
      }),
      Wordgard.colorScheme.of(host.closest(".dark") ? "dark" : "light"),
      ...(opts.placeholder ? [placeholder(opts.placeholder)] : []),
      ...(opts.images ? [image()] : []),
      ...(opts.images && opts.upload
        ? [
            GardState.prec.highest([paste, drop]),
            image.uploader.of(
              (file, wg, progress) =>
                new Promise<string>((resolve, reject) => {
                  enqueue([file], wg, 0, 0, { resolve, reject, progress })
                }),
            ),
          ]
        : []),
      Wordgard.updateListener.of((update) => {
        for (const job of jobs.values()) {
          if (job.settle) continue
          const collapsed = job.from === job.to
          job.from = update.changes.mapPos(job.from, collapsed ? -1 : 1)
          job.to = collapsed
            ? job.from
            : Math.max(job.from, update.changes.mapPos(job.to, -1))
        }
        if (update.docChanged) {
          const html = serialize(update.state.doc).toHTML()
          opts.onChange(markdown ? htmlToMarkdown(html) : html)
        }
      }),
    ],
  })
  return () => {
    alive = false
    for (const job of [...jobs.values()]) {
      job.controller.abort()
      job.settle?.reject(new Error("the editor closed"))
      end(job)
    }
    editor.dom.remove()
    // The toolbar lives outside the editor's own node; it goes with it.
    opts.toolbar.replaceChildren()
  }
}
editor-markdown.ts
// MARKDOWN IN AND OUT OF THE EDITOR, for a document whose file is markdown (a README, a
// note, a post body): the text is parsed by markdown's one parse (lib/markdown-core: mdast,
// GitHub's extensions, front matter), every block the editor can hold becomes its own
// node, and the
// document is written back as markdown on every change.
//
// NOTHING IS LOST. A block the editor cannot hold as itself (raw HTML, front matter,
// footnotes, link reference definitions, a reference link, a task list, an aligned
// table, a link or image with a title, a fence with meta) keeps its exact source: it
// shows as a code block marked `verbatim` and is written back as it came, byte for
// byte. What the editor holds is written in one style (`-` bullets, `*` emphasis,
// fenced code), so a file that never changes is never rewritten, and a file that
// does reads as one hand wrote it. Soft line breaks inside a paragraph become spaces.
//
// TYPED AND PASTED TOO (`markdownInput`, at the end): what a hand writes as markdown
// becomes what it means as it is typed, in either format.
//
// Browser-only: HTML is read with DOMParser.

import type * as M from "mdast"
import { frontmatterToMarkdown } from "mdast-util-frontmatter"
import { gfmToMarkdown } from "mdast-util-gfm"
import { toMarkdown } from "mdast-util-to-markdown"
import { toggleMark } from "wordgard/command"
import { Leaf, Mark, parse } from "wordgard/doc"
import { InputRule, Wordgard } from "wordgard/editor"
import { history } from "wordgard/history"
import { Code, Emphasis, Link, Strikethrough, Strong } from "wordgard/types"
import {
  FRONTMATTER,
  parseMarkdown,
} from "@/registry/base-nova/lib/markdown-core"

/** The code-block language that marks a block kept as its source. */
export const VERBATIM = "verbatim"

const escapeHtml = (s: string) =>
  s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")

const PHRASING = new Set([
  "text",
  "emphasis",
  "strong",
  "delete",
  "inlineCode",
  "link",
  "image",
  "break",
])
const BLOCKS = new Set([
  "paragraph",
  "heading",
  "blockquote",
  "list",
  "listItem",
  "code",
  "thematicBreak",
  "table",
  "tableRow",
  "tableCell",
])

/** Whether the editor holds this node and everything in it as itself. */
function held(node: M.Nodes): boolean {
  if (!PHRASING.has(node.type) && !BLOCKS.has(node.type)) return false
  if ((node.type === "link" || node.type === "image") && node.title)
    return false
  if (node.type === "listItem" && node.checked != null) return false
  if (node.type === "code" && node.meta) return false
  if (node.type === "table" && node.align?.some((a) => a !== null)) return false
  return "children" in node ? (node.children as M.Nodes[]).every(held) : true
}

const lines = (text: string) => escapeHtml(text).replace(/\n/g, "<br>")

function inlineHtml(nodes: readonly M.PhrasingContent[]): string {
  return nodes
    .map((n) => {
      switch (n.type) {
        case "text":
          return escapeHtml(n.value.replace(/\n/g, " "))
        case "emphasis":
          return `<em>${inlineHtml(n.children)}</em>`
        case "strong":
          return `<strong>${inlineHtml(n.children)}</strong>`
        case "delete":
          return `<s>${inlineHtml(n.children)}</s>`
        case "inlineCode":
          return `<code>${escapeHtml(n.value)}</code>`
        case "link":
          return `<a href="${escapeHtml(n.url)}">${inlineHtml(n.children)}</a>`
        case "image":
          return `<img src="${escapeHtml(n.url)}" alt="${escapeHtml(n.alt ?? "")}">`
        case "break":
          return "<br>"
        default:
          throw new Error(`editor-markdown: ${n.type} is not held inline`)
      }
    })
    .join("")
}

function blockHtml(node: M.RootContent, source: string): string {
  if (!held(node)) {
    const { start, end } = node.position ?? {}
    if (start?.offset === undefined || end?.offset === undefined)
      throw new Error(`editor-markdown: ${node.type} has no source position`)
    return `<pre data-language="${VERBATIM}">${lines(source.slice(start.offset, end.offset))}</pre>`
  }
  switch (node.type) {
    case "paragraph":
      return `<p>${inlineHtml(node.children)}</p>`
    case "heading":
      return `<h${node.depth}>${inlineHtml(node.children)}</h${node.depth}>`
    case "blockquote":
      return `<blockquote>${node.children.map((c) => blockHtml(c, source)).join("")}</blockquote>`
    case "list": {
      const items = node.children
        .map((item) => {
          const [only, ...rest] = item.children
          return only?.type === "paragraph" && rest.length === 0
            ? `<li>${inlineHtml(only.children)}</li>`
            : `<li>${item.children.map((c) => blockHtml(c, source)).join("")}</li>`
        })
        .join("")
      return node.ordered
        ? `<ol${node.start && node.start !== 1 ? ` start="${node.start}"` : ""}>${items}</ol>`
        : `<ul>${items}</ul>`
    }
    case "code":
      return `<pre${node.lang ? ` data-language="${escapeHtml(node.lang)}"` : ""}>${lines(node.value)}</pre>`
    case "thematicBreak":
      return "<hr>"
    case "table": {
      const [head, ...body] = node.children
      const row = (r: M.TableRow, cell: "th" | "td") =>
        `<tr>${r.children.map((c) => `<${cell}>${inlineHtml(c.children)}</${cell}>`).join("")}</tr>`
      return `<table>${head ? row(head, "th") : ""}${body.map((r) => row(r, "td")).join("")}</table>`
    }
    default:
      throw new Error(`editor-markdown: ${node.type} is not held as a block`)
  }
}

/** A markdown file as the editor's HTML. */
export function markdownToHtml(markdown: string): string {
  const tree = parseMarkdown(markdown)
  return (
    tree.children.map((node) => blockHtml(node, markdown)).join("") || "<p></p>"
  )
}

// ── back out: the editor's HTML as markdown.

/** A code block's text: its line breaks are `<br>`s. */
function preText(el: Element): string {
  return [...el.childNodes]
    .map((n) =>
      n.nodeName === "BR"
        ? "\n"
        : n instanceof Element
          ? preText(n)
          : (n.textContent ?? ""),
    )
    .join("")
}

function phrasing(el: Element): M.PhrasingContent[] {
  return [...el.childNodes].flatMap((n): M.PhrasingContent[] => {
    if (!(n instanceof Element))
      return n.textContent ? [{ type: "text", value: n.textContent }] : []
    switch (n.tagName) {
      case "STRONG":
      case "B":
        return [{ type: "strong", children: phrasing(n) }]
      case "EM":
      case "I":
        return [{ type: "emphasis", children: phrasing(n) }]
      case "S":
      case "DEL":
      case "STRIKE":
        return [{ type: "delete", children: phrasing(n) }]
      case "CODE":
        return [{ type: "inlineCode", value: n.textContent ?? "" }]
      case "A":
        return [
          {
            type: "link",
            url: n.getAttribute("href") ?? "",
            children: phrasing(n),
          },
        ]
      case "IMG":
        return [
          {
            type: "image",
            url: n.getAttribute("src") ?? "",
            alt: n.getAttribute("alt") ?? "",
          },
        ]
      case "BR":
        return [{ type: "break" }]
      default:
        return phrasing(n)
    }
  })
}

const BLOCK_TAGS = new Set([
  "P",
  "H1",
  "H2",
  "H3",
  "H4",
  "H5",
  "H6",
  "BLOCKQUOTE",
  "UL",
  "OL",
  "PRE",
  "HR",
  "TABLE",
])

function blocks(el: Element): M.RootContent[] {
  const out: M.RootContent[] = []
  // Inline content loose at block level (a list item's own text) is one paragraph.
  let loose: Node[] = []
  const flush = () => {
    if (loose.some((n) => n.textContent?.trim() || n.nodeName === "IMG")) {
      const holder = el.ownerDocument.createElement("p")
      for (const n of loose) holder.append(n.cloneNode(true))
      out.push({ type: "paragraph", children: phrasing(holder) })
    }
    loose = []
  }
  for (const n of [...el.childNodes]) {
    if (!(n instanceof Element) || !BLOCK_TAGS.has(n.tagName)) {
      loose.push(n)
      continue
    }
    flush()
    out.push(block(n))
  }
  flush()
  return out
}

function block(el: Element): M.RootContent {
  switch (el.tagName) {
    case "P":
      return { type: "paragraph", children: phrasing(el) }
    case "H1":
    case "H2":
    case "H3":
    case "H4":
    case "H5":
    case "H6":
      return {
        type: "heading",
        depth: Number(el.tagName[1]) as M.Heading["depth"],
        children: phrasing(el),
      }
    case "BLOCKQUOTE":
      return {
        type: "blockquote",
        children: blocks(el) as M.BlockContent[],
      }
    case "UL":
    case "OL":
      return {
        type: "list",
        ordered: el.tagName === "OL",
        start:
          el.tagName === "OL" ? Number(el.getAttribute("start") ?? "1") : null,
        spread: false,
        children: [...el.children]
          .filter((li) => li.tagName === "LI")
          .map(
            (li): M.ListItem => ({
              type: "listItem",
              spread: false,
              children: blocks(li) as M.BlockContent[],
            }),
          ),
      }
    case "PRE": {
      const language = el.getAttribute("data-language")
      const text = preText(el)
      return language === VERBATIM
        ? { type: "html", value: text }
        : { type: "code", lang: language || null, value: text }
    }
    case "HR":
      return { type: "thematicBreak" }
    case "TABLE": {
      const rows = [...el.querySelectorAll("tr")].map(
        (tr): M.TableRow => ({
          type: "tableRow",
          children: [...tr.children].map(
            (cell): M.TableCell => ({
              type: "tableCell",
              children: phrasing(cell),
            }),
          ),
        }),
      )
      return {
        type: "table",
        align: (rows[0]?.children ?? []).map(() => null),
        children: rows,
      }
    }
    default:
      throw new Error(`editor-markdown: <${el.tagName}> is not a block`)
  }
}

/** The editor's HTML as a markdown file. */
export function htmlToMarkdown(html: string): string {
  const body = new DOMParser().parseFromString(html, "text/html").body
  return toMarkdown(
    { type: "root", children: blocks(body) },
    {
      extensions: [gfmToMarkdown(), frontmatterToMarkdown([...FRONTMATTER])],
      bullet: "-",
      emphasis: "*",
      strong: "*",
      rule: "-",
      fence: "`",
      fences: true,
      listItemIndent: "one",
    },
  )
}

// ── markdown typed and pasted: what a hand writes as markdown becomes the thing it
// means, in either format. The block shortcuts (`# `, `> `, `- `, `1. `, three
// backticks, `---`) are Wordgard's own, on the blocks that carry them; the inline ones
// are here, closed by their last delimiter: `**strong**` or `__strong__`, `*em*` or
// `_em_`, `` `code` ``, `~~struck~~` where struck text exists, `[words](url)`. Plain
// text pasted that reads as markdown arrives as what it means, through the same
// conversion a file goes through; any other text arrives as text. A web address pasted
// over chosen words links them; one typed links itself at the space after it.

/** A span closed by typing: group 1 is the whole of it, delimiters included, group 2
 *  its words; `mark` gives the mark from the match (a link's target is group 3). */
function closing(
  expr: RegExp,
  mark: (match: InputRule.MatchArray) => Mark | null,
): InputRule {
  return InputRule.define({
    expr,
    apply: (_state, match) => {
      const whole = match[1]
      const words = match[2]
      const m = mark(match)
      if (!whole || !words || !m) return null
      return {
        changes: {
          from: whole.from.pos,
          to: whole.to.pos,
          insert: [Leaf.text(words.text, m.addToSet(Mark.none))],
        },
        annotations: history.isolate.of(true),
      }
    },
  })
}

/** Text that reads as markdown: a block's opening or an inline span's delimiters. */
const MARKDOWNISH =
  /^(?:#{1,6} |[-*+] |\d+[.)] |> |```|---\s*$|\|.+\|\s*$)|\*\*\S|__\S|`[^`\n]+`|~~\S|\[[^\]\n]+\]\([^)\s]+\)/m

/** The markdown typed and pasted, for a schema that holds struck text or not. */
export function markdownInput(struck: boolean) {
  return [
    closing(/(\*\*([^*\s](?:[^*]*[^*\s])?)\*\*)$/, () => Strong),
    closing(/(?:^|[^_\w])(__([^_\s](?:[^_]*[^_\s])?)__)$/, () => Strong),
    closing(/(?:^|[^*\w])(\*([^*\s](?:[^*]*[^*\s])?)\*)$/, () => Emphasis),
    closing(/(?:^|[^_\w])(_([^_\s](?:[^_]*[^_\s])?)_)$/, () => Emphasis),
    closing(/(`([^`]+)`)$/, () => Code),
    ...(struck
      ? [closing(/(~~([^~\s](?:[^~]*[^~\s])?)~~)$/, () => Strikethrough)]
      : []),
    closing(/(\[([^\]]+)\]\(([^)\s]+)\))$/, (match) =>
      match[3] ? Link.of(match[3].text) : null,
    ),
    // A web address typed, then a space: it links itself, a sentence's closing
    // punctuation left out of it.
    InputRule.define({
      expr: /(?:^|\s)(https?:\/\/[^\s<>]*[^\s<>.,;:!?)'"\]])([.,;:!?)'"\]]*\s)$/,
      apply: (_state, match) => {
        const url = match[1]
        const after = match[2]
        if (!url || !after) return null
        return {
          changes: {
            from: url.from.pos,
            to: after.to.pos,
            insert: [
              Leaf.text(url.text, Link.of(url.text).addToSet(Mark.none)),
              Leaf.text(after.text),
            ],
          },
          annotations: history.isolate.of(true),
          userEvent: "input.autolink",
        }
      },
    }),
    Wordgard.clipboardTextParser.of((text, state) => {
      if (!MARKDOWNISH.test(text)) return null
      const holder = document.createElement("div")
      holder.innerHTML = markdownToHtml(text)
      return parse.slice(state.schema, holder).slice
    }),
    // A web address pasted over chosen words makes them its link, as a chat or a doc
    // does; over nothing it arrives as text.
    Wordgard.domEventHandler("paste", (event, wg) => {
      const url = event.clipboardData?.getData("text/plain").trim() ?? ""
      if (wg.state.selection.isCursor || !/^https?:\/\/\S+$/.test(url))
        return false
      const linked = toggleMark(wg, Link.of(url))
      if (!linked) return false
      event.preventDefault()
      wg.dispatch(linked)
      return true
    }),
  ]
}
editor-keys.ts
// THE EDITOR'S KEYS, the ones a document app has taught every hand. Structure: Tab
// nests a list item under the one above it, ⇧Tab brings it back out a level (⌘] and ⌘[
// the same), Tab in a code block indents its line; Enter on an empty item leaving the
// list and Backspace at an item's start joining it up are Wordgard's own. Anywhere
// else Tab is left alone (a table's cells keep theirs, the page its focus). Blocks and
// marks, as Google Docs has them: ⌘⌥1 to ⌘⌥6 a heading (again: back to a paragraph),
// ⌘⌥0 a paragraph, ⌘⇧7 a numbered list, ⌘⇧8 a bulleted one, ⌘⇧X struck, and Notion's
// ⌘E for code and ⌘⌥8 for a code block. Wordgard's own (⌘B, ⌘I, ⌘K, ⌃⇧1…) stay.
//
// The document is a stream of tokens, each one position wide: a plot's opening tag,
// its content, its `Plot.End`. Nesting and un-nesting an item are a few tokens moved,
// and lists that end up side by side are joined by Wordgard's own auto-join.

import {
  autoJoinBlocks,
  Command,
  setTextblockType,
  toggleList,
  toggleMark,
} from "wordgard/command"
import { Leaf, Node, Plot, type Pos } from "wordgard/doc"
import { KeyBinding } from "wordgard/editor"
import type { GardState } from "wordgard/state"
import {
  BulletList,
  Code,
  CodeBlock,
  Heading,
  OrderedList,
  Paragraph,
  Strikethrough,
} from "wordgard/types"

/** The list item the cursor is in: its innermost block whose parent is a list. */
function itemAt(state: GardState): Pos.Plot | null {
  if (!state.selection.isCursor) return null
  for (let p = state.sel.head.textblockParent; p; p = p.parent)
    if (p.parent?.node.type.hasRole(Node.Role.List)) return p
  return null
}

/** Tab: the item goes to the end of the item above it, in a list of its own kind
 *  (joined to one already there). The first item of a list has nothing to nest under
 *  and stays, the key still taken so focus does not leave the document. */
const indent: Command = (wg) => {
  const item = itemAt(wg.state)
  if (!item) return false
  if (item.index === 0) return true
  const list = item.parent as Pos.Plot
  return autoJoinBlocks(wg.state, {
    changes: [
      // the item above stays open, a list opening inside it…
      {
        from: item.before - 1,
        to: item.before,
        insert: [list.node.tag.split(false)],
      },
      // …which closes after the item, and then the item above closes
      { from: item.after, insert: [Plot.End, Plot.End] },
    ],
    scrollIntoView: true,
    userEvent: "indent.list",
  })
}

/** ⇧Tab: a nested item comes out to follow the item it was in; the items after it come
 *  with it, nested under it, so nothing changes order. A top-level item, or one whose
 *  list has more of its parent after it, stays. */
const outdent: Command = (wg) => {
  const item = itemAt(wg.state)
  if (!item) return false
  const list = item.parent as Pos.Plot
  const parent = list.parent
  if (
    !parent?.parent?.node.type.hasRole(Node.Role.List) ||
    list.index !== parent.node.content.length - 1
  )
    return true
  const last = item.index === list.node.content.length - 1
  return autoJoinBlocks(wg.state, {
    changes: [
      item.index === 0
        ? // the list opened just for it goes; the parent closes there instead
          { from: list.before, to: list.before + 1, insert: [Plot.End] }
        : // the list and the parent close before it
          { from: item.before, insert: [Plot.End, Plot.End] },
      last
        ? // the closes that followed it are spent
          { from: item.after, to: item.after + 2 }
        : // the items after it open a list inside it, closed by what closed them
          {
            from: item.after - 1,
            to: item.after,
            insert: [list.node.tag.split(false)],
          },
    ],
    scrollIntoView: true,
    userEvent: "outdent.list",
  })
}

/** Tab in a code block: two spaces at the cursor. */
const indentCode: Command = (wg) => {
  const { state } = wg
  const block = state.sel.head.textblockParent
  if (!state.selection.isCursor || !block?.node.type.hasRole(Node.Role.Code))
    return false
  return {
    changes: { from: state.sel.head.pos, insert: [Leaf.text("  ")] },
    userEvent: "input.indent",
  }
}

const either =
  (...commands: Command[]): Command =>
  (wg, param) => {
    for (const command of commands) {
      const done = command(wg, param)
      if (done) return done
    }
    return false
  }

/** The block a key names, or a paragraph when the cursor's block already is one: the
 *  same key sets it and takes it back. */
const blockType =
  (tag: Plot.Tag): Command =>
  (wg) => {
    const block = wg.state.sel.head.textblockParent
    return setTextblockType(wg, block?.node.tag.eq(tag) ? Paragraph : tag)
  }

/** A tag Wordgard's types always make (a heading of a level, an ordered list from 1). */
const made = <T>(tag: Plot.Tag<T> | null, what: string) => {
  if (!tag) throw new Error(`editor-keys: Wordgard made no ${what}`)
  return tag
}
const heading = (level: number) =>
  made(Heading.of(level), `heading of level ${level}`)

/** The keys, for the editor's config. `rich`: the schema holds code blocks and struck
 *  text (a markdown file's does), so their keys are bound. */
export function structureKeys(rich: boolean) {
  return [
    KeyBinding.of({
      key: "Tab",
      run: either(indentCode, indent),
      shift: outdent,
    }),
    KeyBinding.of({ key: "Mod-]", run: indent }),
    KeyBinding.of({ key: "Mod-[", run: outdent }),
    KeyBinding.of({
      key: "Mod-Alt-0",
      run: Command.bind(setTextblockType, Paragraph),
    }),
    ...[1, 2, 3, 4, 5, 6].map((level) =>
      KeyBinding.of({
        key: `Mod-Alt-${level}`,
        run: blockType(heading(level)),
      }),
    ),
    KeyBinding.of({
      key: "Mod-Shift-7",
      run: Command.bind(toggleList, made(OrderedList.default, "ordered list")),
    }),
    KeyBinding.of({
      key: "Mod-Shift-8",
      run: Command.bind(toggleList, BulletList),
    }),
    KeyBinding.of({ key: "Mod-e", run: Command.bind(toggleMark, Code) }),
    ...(rich
      ? [
          KeyBinding.of({
            key: "Mod-Shift-x",
            run: Command.bind(toggleMark, Strikethrough),
          }),
          KeyBinding.of({ key: "Mod-Alt-8", run: blockType(CodeBlock) }),
        ]
      : []),
  ]
}
editor-slash.ts
// THE / MENU, as Notion taught it: in an empty paragraph, `/` offers the blocks the
// document can hold, what follows the slash narrows them (by name or a word for them:
// `/h2`, `/quote`, `/todo`… whatever the words say), ↑ ↓ walk them, Enter or Tab turn
// the paragraph into the one marked, Esc leaves the slash as typed. The words typed
// are taken away with the choice. Only at the start of an empty paragraph: a slash in
// the middle of a sentence (a path, a date, "and/or") is a slash.
//
// The engine knows where the menu is and what it offers and runs the choice; the
// editor's React draws it from what `report` says (`SlashState`), at the cursor.

import {
  type Command,
  setTextblockType,
  toggleBlock,
  toggleList,
} from "wordgard/command"
import { ChangeSet, type Plot } from "wordgard/doc"
import { KeyBinding, Wordgard } from "wordgard/editor"
import { history } from "wordgard/history"
import { GardSelection, GardState } from "wordgard/state"
import {
  Blockquote,
  BulletList,
  CodeBlock,
  Heading,
  HorizontalRule,
  OrderedList,
  Paragraph,
} from "wordgard/types"

/** One block the menu offers, as React draws it. */
export interface SlashChoice {
  id: string
  label: string
  /** the keys that make it without the menu */
  keys?: string
}

/** What the menu shows, or null while it is closed. */
export interface SlashState {
  query: string
  choices: readonly SlashChoice[]
  /** which choice Enter takes */
  marked: number
  /** where the cursor is on screen: the menu hangs under it */
  at: { left: number; top: number; bottom: number }
  /** a choice pressed with the pointer */
  choose: (i: number) => void
}

interface Block extends SlashChoice {
  /** words it answers to besides its label */
  words: string
  run: Command
}

/** The longest a query runs before a slash is taken for text. */
const QUERY_MAX = 24

const made = <T>(tag: Plot.Tag<T> | null, what: string) => {
  if (!tag) throw new Error(`editor-slash: Wordgard made no ${what}`)
  return tag
}

/** A rule where the paragraph was, the cursor after it: the `---` rule's own move. */
const rule: Command = (wg) => {
  const block = wg.state.sel.head.textblockParent
  if (!block) return false
  const changes = ChangeSet.create(wg.state.doc, {
    from: block.start,
    to: block.end,
    insert: [HorizontalRule],
    fit: true,
  })
  const hr = changes.findInserted((t) => t === HorizontalRule)
  if (hr == null) return false
  return {
    changes,
    selection: (cx) => GardSelection.near(cx, hr + 1, 1),
    userEvent: "insert.horizontalrule",
  }
}

/** The blocks this document holds, in the order a writer reaches for them. */
function blocksOf(rich: boolean): Block[] {
  const set =
    (tag: Plot.Tag<unknown>): Command =>
    (wg) =>
      setTextblockType(wg, tag)
  return [
    {
      id: "text",
      label: "text",
      words: "paragraph plain",
      keys: "mod+alt+0",
      run: set(Paragraph),
    },
    ...[1, 2, 3].map((level) => ({
      id: `h${level}`,
      label: `heading ${level}`,
      words: `h${level} title`,
      keys: `mod+alt+${level}`,
      run: set(made(Heading.of(level), `heading ${level}`)),
    })),
    {
      id: "bullets",
      label: "bulleted list",
      words: "ul unordered bullet list -",
      keys: "mod+shift+8",
      run: (wg) => toggleList(wg, BulletList),
    },
    {
      id: "numbers",
      label: "numbered list",
      words: "ol ordered number list 1.",
      keys: "mod+shift+7",
      run: (wg) => toggleList(wg, made(OrderedList.default, "ordered list")),
    },
    {
      id: "quote",
      label: "quote",
      words: "blockquote citation >",
      run: (wg) => toggleBlock(wg, Blockquote),
    },
    ...(rich
      ? [
          {
            id: "code",
            label: "code block",
            words: "pre snippet ```",
            keys: "mod+alt+8",
            run: set(CodeBlock),
          },
          {
            id: "rule",
            label: "divider",
            words: "hr rule line separator ---",
            run: rule,
          },
        ]
      : []),
  ]
}

const matches = (b: Block, query: string) => {
  const q = query.trim().toLowerCase()
  return (
    q === "" ||
    b.label.includes(q) ||
    b.words.split(" ").some((w) => w.startsWith(q))
  )
}

/** The menu, for the editor's config: `report` is told whenever what it shows
 *  changes, null once it closes. */
export function slashMenu(
  rich: boolean,
  report: (state: SlashState | null) => void,
) {
  const blocks = blocksOf(rich)
  // Where the slash and its query are, while the menu is open.
  let open: { from: number; to: number; query: string } | null = null
  let marked = 0
  // A slash Esc dismissed: left as text until the paragraph is emptied again.
  let dismissed: number | null = null
  let shown: Block[] = []

  const publish = (wg: Wordgard) => {
    if (!open || shown.length === 0) return report(null)
    const rect = wg.coordsAtPos(open.to)
    report({
      query: open.query,
      choices: shown.map(({ id, label, keys }) => ({ id, label, keys })),
      marked,
      at: { left: rect.left, top: rect.top, bottom: rect.bottom },
      choose: (i) => choose(wg, i),
    })
  }

  /** The slash's place, read from the state: an empty paragraph's text from its
   *  start to the cursor reading `/query`. */
  const read = (state: GardState) => {
    const sel = state.selection
    const block = state.sel.head.textblockParent
    if (!sel.isCursor || !block?.node.tag.eq(Paragraph)) return null
    const text = block.node.textContent()
    const head = state.sel.head.pos
    const typed = /^\/([^\n/]*)$/.exec(text)
    if (!typed || head !== block.end) return null
    const query = typed[1] ?? ""
    if (query.length > QUERY_MAX) return null
    return { from: block.start, to: head, query }
  }

  const close = (wg: Wordgard) => {
    open = null
    publish(wg)
  }

  function choose(wg: Wordgard, i: number) {
    const block = shown[i]
    if (!open || !block) return
    const { from, to } = open
    open = null
    report(null)
    // The slash and its words go, as one step undone with the choice.
    wg.dispatch({
      changes: { from, to },
      annotations: history.isolate.of(true),
      userEvent: "delete.slash",
    })
    const spec = block.run(wg, null)
    if (spec && spec !== true) wg.dispatch(spec)
    wg.focus()
  }

  const keys = GardState.prec.highest(
    ["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].map((key) =>
      KeyBinding.of({
        key,
        run: (wg) => {
          if (!open || shown.length === 0) return false
          if (key === "Escape") {
            dismissed = open.from
            close(wg)
            return true
          }
          if (key === "ArrowDown" || key === "ArrowUp") {
            const n = shown.length
            marked = (marked + (key === "ArrowDown" ? 1 : -1) + n) % n
            publish(wg)
            return true
          }
          choose(wg, marked)
          return true
        },
      }),
    ),
  )

  const watch = Wordgard.updateListener.of((update) => {
    const wg = update.editor
    // Leaving the document closes the menu; coming back reads the slash again.
    if (update.focusChanged && !wg.hasFocus) {
      if (open) close(wg)
      return
    }
    if (!update.docChanged && !update.selectionSet && !update.focusChanged)
      return
    const now = read(update.state)
    if (!now) {
      dismissed = null
      if (open) close(wg)
      return
    }
    if (dismissed === now.from) return
    const changed = open?.query !== now.query
    open = now
    shown = blocks.filter((b) => matches(b, now.query))
    if (changed) marked = 0
    publish(wg)
  })

  return [keys, watch]
}
editor.css
/* biome-ignore-all lint/complexity/noImportantStyles: every rule overrides a style Wordgard injects at runtime, unlayered */
/* WORDGARD'S OWN NODES, restyled. Everything the editor draws itself (the field's
   well or glass, the row the tools sit in, the document's ring, its padding through
   --ag-editor-pad and --ag-editor-min) is utilities in editor.tsx. What is here is
   what Wordgard creates at runtime and styles with a sheet it injects without a
   layer: an unlayered rule beats every utility whatever its specificity, so each
   declaration here is !important (an important declaration in any layer beats an
   unlayered normal one), inside @layer components. The colours are currentColor at
   the alpha ladder's tiers, so one set of rules reads on the page, in a plain field
   and on glass. */
@layer theme, base, components, utilities;

@layer components {
  /* Wordgard's frame: its root, and the toolbar's node (it wears Wordgard's theme
     classes too). Its border and focus outline go; its menus, dialogs, tooltips and
     table picker draw with these variables, so they take the registry's colours
     here. --wg-panel-color is a popup's ground (menu's plain popup: the popover). */
  [data-slot="editor-document"] > *,
  [data-slot="editor-panels"] {
    --wg-highlight-color: currentColor !important;
    --wg-border-color: color-mix(
      in oklab,
      currentColor 8%,
      transparent
    ) !important;
    --wg-panel-color: var(--popover) !important;
    --wg-menu-color: currentColor !important;
    --wg-menu-item-size: 1.125rem !important;
    border: 0 !important;
    outline: none !important;
  }

  /* The row the tools sit in is the editor's (data-slot=editor-toolbar): Wordgard's
     panel group is only what holds them, with no ground or place of its own. */
  [data-slot="editor-panels"] > wg-panels {
    position: static !important;
    background: transparent !important;
    font: inherit !important;
  }
  [data-slot="editor-panels"] wg-menubar {
    align-items: center !important;
    gap: 0.125rem !important;
    padding: 0 !important;
    border: 0 !important;
    color: inherit !important;
  }
  [data-slot="editor-panels"] wg-menu-spacer {
    width: 0.5rem !important;
  }

  /* A tool: an icon, or the block's name, at the quiet tier; a wash under the hand;
     on (the selection is bold, a heading) a wash and its words lit, never a fill.
     The wash is a share of the tool's own colour, so it follows the words. 28px, the
     registry's small icon button. */
  [data-slot="editor-panels"] .wg-menu-button {
    display: inline-flex !important;
    align-items: center !important;
    justify-content: center !important;
    box-sizing: border-box !important;
    height: 1.75rem !important;
    min-width: 1.75rem !important;
    padding: 0 0.3125rem !important;
    border-radius: calc(var(--radius) * 0.8) !important;
    background: transparent !important;
    color: color-mix(in oklab, currentColor 50%, transparent) !important;
    cursor: pointer;
    transition:
      background-color 150ms,
      color 150ms;
  }
  [data-slot="editor-panels"]
    .wg-menu-button:not([aria-disabled]):is(:hover, [aria-expanded="true"]) {
    color: color-mix(in oklab, currentColor 85%, transparent) !important;
    background: color-mix(in oklab, currentColor 7%, transparent) !important;
  }
  [data-slot="editor-panels"] .wg-menu-button[aria-pressed="true"] {
    color: color-mix(in oklab, currentColor 92%, transparent) !important;
    background: color-mix(in oklab, currentColor 9%, transparent) !important;
  }
  [data-slot="editor-panels"] .wg-button-label {
    padding: 0 0.125rem !important;
    line-height: 1.25rem;
  }
  /* Focus is @ag/tokens' ring, the keyboard's only (a press moves no focus here:
     Wordgard keeps it in the document). Wordgard rings any focus in its own blue;
     its ring is replaced by the tokens' line, a focused tool lit to the words'
     colour so the ring reads as its outline. */
  [data-slot="editor-panels"] .wg-menu-button:focus {
    outline: none !important;
  }
  [data-slot="editor-panels"]
    wg-menubar
    > :is(.wg-menu-button, wg-submenu > .wg-menu-button):focus-visible:not(
      [data-focus-pointer]
    ) {
    color: color-mix(in oklab, currentColor 92%, transparent) !important;
    outline: var(--focus-ring-width) solid
      color-mix(in oklab, currentColor var(--focus-ring-alpha), transparent) !important;
    outline-offset: var(--focus-ring-offset) !important;
  }

  /* A tool's menu (the block's kinds, the table's verbs): menu's plain popup and its
     rows (menu.tsx's FACE.plain), a row lit by a wash under the hand or the keys
     (its focus is that highlight, as a menu's row's is), the current one in full
     words. Wordgard builds it inside its toolbar, so it cannot be the menu item. */
  [data-slot="editor-panels"] wg-menu-list {
    gap: 0 !important;
    min-width: 11rem;
    margin-top: 0.375rem;
    padding: 0.25rem !important;
    border: 1px solid var(--border) !important;
    border-radius: var(--radius) !important;
    background: var(--popover) !important;
    color: var(--popover-foreground) !important;
    line-height: 1.25rem !important;
    box-shadow:
      0 4px 6px -1px rgb(0 0 0 / 0.1),
      0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
  }
  [data-slot="editor-panels"] wg-menu-list .wg-menu-button {
    justify-content: flex-start !important;
    width: 100% !important;
    height: auto !important;
    padding: 0.375rem 0.5rem !important;
    color: color-mix(in oklab, currentColor 85%, transparent) !important;
  }
  [data-slot="editor-panels"]
    wg-menu-list
    .wg-menu-button:not([aria-disabled]):is(:hover, :focus) {
    outline: none !important;
    color: currentColor !important;
    background: color-mix(in oklab, currentColor 8%, transparent) !important;
  }
  [data-slot="editor-panels"]
    wg-menu-list
    .wg-menu-button[aria-pressed="true"] {
    color: currentColor !important;
    background: transparent !important;
  }
  [data-slot="editor-panels"] .wg-dimension-cell {
    stroke: color-mix(in oklab, currentColor 20%, transparent) !important;
  }

  /* Wordgard's dialogs (a picture's address, a link's) open in the toolbar's row,
     under the tools: its buttons and fields on the registry's washes and hairlines. */
  [data-slot="editor-panels"] wg-dialog {
    margin-top: 0.25rem;
    padding: 0.5rem 1.5rem 0.5rem 0.25rem !important;
    border-bottom: 0 !important;
    border-top: 1px solid color-mix(in oklab, currentColor 8%, transparent) !important;
  }
  [data-slot="editor-panels"] .wg-dialog-button {
    padding: 0.25rem 0.625rem !important;
    border-radius: calc(var(--radius) * 0.8) !important;
    background: color-mix(in oklab, currentColor 8%, transparent) !important;
    cursor: pointer;
  }
  [data-slot="editor-panels"] .wg-dialog-button:hover {
    background: color-mix(in oklab, currentColor 12%, transparent) !important;
  }
  [data-slot="editor-panels"] .wg-dialog-close {
    background: transparent !important;
    color: color-mix(in oklab, currentColor 50%, transparent) !important;
    cursor: pointer;
  }
  [data-slot="editor-panels"]
    wg-dialog
    input:not([type="radio"], [type="file"]) {
    padding: 0.25rem 0.5rem;
    border: 0;
    border-radius: calc(var(--radius) * 0.8);
    background: color-mix(in oklab, currentColor 4%, transparent);
    box-shadow: inset 0 0 0 1px
      color-mix(in oklab, currentColor 12%, transparent);
    color: inherit;
    font: inherit;
    outline: none;
  }

  /* The document's node: its padding and its height when empty are the variant's
     (editor.tsx), its look markdown's (MARKDOWN, on this node). */
  [data-slot="editor-document"] wg-content {
    padding: var(--ag-editor-pad) !important;
    min-height: var(--ag-editor-min) !important;
  }
  [data-slot="editor-document"] wg-placeholder {
    opacity: 1 !important;
    color: color-mix(in oklab, var(--foreground) 40%, transparent) !important;
  }
  /* A chosen picture or rule: a quiet line round it, not Wordgard's blue. */
  [data-slot="editor-document"] .wg-selected-node {
    outline: 1.5px solid color-mix(in oklab, currentColor 35%, transparent) !important;
    outline-offset: 2px;
  }
  [data-slot="editor-document"] wg-dropcursor {
    border-color: currentColor !important;
  }
  /* A code block's language: a quiet word in its corner. */
  [data-slot="editor-document"] select.wg-code-language {
    background: transparent !important;
    color: color-mix(in oklab, currentColor 45%, transparent) !important;
    font: inherit !important;
    font-size: 0.75em !important;
  }
  /* A link's address, shown under the hand: menu's plain popup, small. */
  [data-slot="editor-document"] .wg-tooltip {
    border: 1px solid var(--border) !important;
    border-radius: calc(var(--radius) * 0.8) !important;
    color: var(--popover-foreground) !important;
    font-family: inherit !important;
    font-size: 12px !important;
    line-height: 1.5 !important;
    box-shadow:
      0 4px 6px -1px rgb(0 0 0 / 0.1),
      0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
  }

  /* Wordgard's tables draw a full grid in their own border colour and select cells in
     a light blue; the document's tables are markdown's (a hairline under the head and
     between rows, no grid) and a selected cell is a wash of the page's own colour. */
  [data-slot="editor-document"] wg-content :is(td, th) {
    border: 0 !important;
    border-bottom: 1px solid
      color-mix(in oklab, var(--foreground) 5%, transparent) !important;
    padding: 0.75em 1em !important;
  }
  [data-slot="editor-document"] wg-content th {
    border-bottom-color: color-mix(
      in oklab,
      var(--foreground) 8%,
      transparent
    ) !important;
  }
  [data-slot="editor-document"] wg-content .wg-selected-cell {
    background: color-mix(
      in oklab,
      var(--foreground) 8%,
      transparent
    ) !important;
  }
}