Delta Components
uieditorsource: ok · refreshed
Editor
Always-on live-preview markdown editor: one surface that is both the rendered document and the text you type into, with debounced autosave and a per-construct typography map.
npx shadcn add "https://deltacomponents.dev/r/editor.json"free
Where it goes
testimonial
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@codemirror/commands@codemirror/lang-markdown@codemirror/language@codemirror/state@codemirror/view@lezer/common@lezer/markdown
Registry dependencies
https://www.deltacomponents.dev/r/utils.jsonhttps://www.deltacomponents.dev/r/button.jsonhttps://www.deltacomponents.dev/r/tooltip.json
Files3 · source cached 10/8/2026, 10:06:18 AM
registry/ui/editor.tsx
"use client";
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { Compartment, EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import {
liveMarkdownBase,
livePreview,
type EditorElements,
} from "@/lib/live-markdown";
import { cn } from "@/lib/utils";
/** What the corner indicator is reporting. `error` means the write rejected
* and the text is only in the browser. */
export type SaveState = "idle" | "dirty" | "saving" | "saved" | "error";
const SAVE_LABEL: Record<SaveState, string> = {
idle: "",
dirty: "Unsaved",
saving: "Saving",
saved: "Saved",
error: "Not saved",
};
// The view, published to whatever the editor renders above its text (the
// controls). A version counter rather than the view itself: the view is one
// mutable object for the editor's whole life, so the way to say "something
// changed" is a tick, and `useSyncExternalStore` turns ticks into renders of
// the subscriber alone — a keystroke never re-renders the editor.
interface EditorStore {
view: EditorView | null;
version: number;
listeners: Set<() => void>;
}
const EditorContext = createContext<EditorStore | null>(null);
function notify(store: EditorStore) {
store.version++;
for (const listener of store.listeners) listener();
}
/** The live `EditorView` of the enclosing `<Editor>`, re-rendering the caller
* on every transaction — document, selection or focus — so pressed states
* track the caret. Null before the editor has mounted and outside one. */
export function useEditorView(): EditorView | null {
const store = useContext(EditorContext);
if (!store) throw new Error("useEditorView must be used inside <Editor>");
const subscribe = useCallback(
(listener: () => void) => {
store.listeners.add(listener);
return () => void store.listeners.delete(listener);
},
[store],
);
useSyncExternalStore(
subscribe,
() => store.version,
() => 0,
);
return store.view;
}
export interface EditorProps {
/** Seeds the document. The editor owns the text from then on — see the note
* on the save contract in the docs. */
defaultValue?: string;
placeholder?: string;
/** Every keystroke. For persistence use `onSave`, which is debounced. */
onChange?: (doc: string) => void;
/** Fired after `saveDelay` of idle, on blur, and on unmount — never with a
* document it has already stored. Reject (or throw) to report a failed
* write: the indicator says so and the next flush retries. */
onSave?: (doc: string) => void | Promise<void>;
saveDelay?: number;
/** Hide the corner save indicator and drive your own from `onSaveState`. */
showSaveStatus?: boolean;
onSaveState?: (state: SaveState) => void;
/** Which element each markdown construct renders as, and its classes.
* Omitted keys fall back to `defaultElements`. */
elements?: EditorElements;
/** Extra CodeMirror extensions, appended after the editor's own. Read once
* at mount like `defaultValue` — reconfiguring would rebuild state the
* user is mid-edit in. */
extensions?: Extension;
/** Rendered above the text, sharing its width — this is where
* `<EditorControls />` goes. */
children?: ReactNode;
className?: string;
}
/**
* Always-on live-preview markdown editor: one surface that is both the
* rendered document and the text you type into. Syntax marks conceal
* everywhere the caret isn't.
*
* The editor is created exactly once per mount (empty-dep effect, callbacks
* held in refs) — rebuilding the view on re-render would drop the caret and
* the undo history.
*/
export const Editor = forwardRef<HTMLDivElement, EditorProps>(function Editor(
{
defaultValue = "",
placeholder = "",
onChange,
onSave,
saveDelay = 800,
showSaveStatus = true,
onSaveState,
elements,
extensions,
children,
className,
},
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const [store] = useState<EditorStore>(() => ({ view: null, version: 0, listeners: new Set() }));
const [mounted, setMounted] = useState(false);
const [saveState, setSaveState] = useState<SaveState>("idle");
// One compartment so an `elements` change swaps only the preview plugin;
// rebuilding the whole extension set would construct a fresh history() and
// lose the user's undo stack.
const [elementsCompartment] = useState(() => new Compartment());
// Read once at mount: re-seeding the document from props mid-edit would
// fight the user for the caret.
const seedRef = useRef({ defaultValue, placeholder, elements, extensions });
// The editor owns the document after mount; these refs only orchestrate
// saving. `savedRef` is the last value known to be STORED and `sendingRef`
// the one in flight, so idle ticks, blurs and double-flushes are no-ops.
const draftRef = useRef(defaultValue);
const savedRef = useRef(defaultValue);
const sendingRef = useRef<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Only the newest save may set the indicator: a slow write that resolves
// after a later one would otherwise report stale news.
const saveTokenRef = useRef(0);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const onSaveStateRef = useRef(onSaveState);
const saveDelayRef = useRef(saveDelay);
useEffect(() => {
onChangeRef.current = onChange;
onSaveRef.current = onSave;
onSaveStateRef.current = onSaveState;
saveDelayRef.current = saveDelay;
});
const report = useCallback((state: SaveState) => {
setSaveState(state);
onSaveStateRef.current?.(state);
}, []);
const flush = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
const next = draftRef.current;
// Saved verbatim — leading and trailing newlines are part of the document.
if (next === savedRef.current || next === sendingRef.current) return;
const save = onSaveRef.current;
if (!save) {
savedRef.current = next;
return;
}
const token = ++saveTokenRef.current;
sendingRef.current = next;
report("saving");
Promise.resolve()
.then(() => save(next))
.then(() => {
// Advanced only once the write lands, so a rejected save stays
// pending and the next flush sends it again.
savedRef.current = next;
if (token === saveTokenRef.current) report("saved");
})
.catch(() => {
if (token === saveTokenRef.current) report("error");
})
.finally(() => {
if (sendingRef.current === next) sendingRef.current = null;
});
}, [report]);
const flushRef = useRef(flush);
flushRef.current = flush;
useEffect(() => {
const seed = seedRef.current;
const view = new EditorView({
parent: containerRef.current!,
state: EditorState.create({
doc: seed.defaultValue,
extensions: [
liveMarkdownBase(seed.placeholder),
elementsCompartment.of(livePreview(seed.elements)),
seed.extensions ?? [],
EditorView.updateListener.of((update) => {
notify(store);
if (!update.docChanged) return;
const doc = update.state.doc.toString();
draftRef.current = doc;
onChangeRef.current?.(doc);
if (onSaveRef.current && doc !== savedRef.current) report("dirty");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => flushRef.current(), saveDelayRef.current);
}),
EditorView.domEventHandlers({
blur: () => {
flushRef.current();
},
}),
],
}),
});
viewRef.current = view;
store.view = view;
notify(store);
setMounted(true);
return () => {
// Flush on unmount so navigating away mid-typing never drops work.
flushRef.current();
view.destroy();
viewRef.current = null;
store.view = null;
notify(store);
};
}, [elementsCompartment, report, store]);
// Keyed on the map's contents, not its identity: a caller passing an inline
// object literal would otherwise reconfigure on every render.
const elementsKey = JSON.stringify(elements ?? {});
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({ effects: elementsCompartment.reconfigure(livePreview(elements)) });
// `elements` is tracked through elementsKey — see above.
}, [elementsKey, elementsCompartment]);
const status = showSaveStatus && onSave && saveState !== "idle" ? saveState : null;
return (
<EditorContext.Provider value={store}>
<div
ref={ref}
className={cn("flex min-h-24 flex-col gap-3 text-base leading-7", className)}
data-slot="editor"
>
{children}
{/* The text and its save chip share a box of their own, so the chip pins
to the top of the TEXT — with controls above, the root's corner would
put it on top of the toolbar. */}
<div className="relative flex-1">
{/* Covers the hydration window at the same typography as the editor, so
line positions hold and only the syntax marks change when CodeMirror
takes over. `min-h` on the root is what gives the mounted editor its
click target too — the theme inherits it. */}
{!mounted && (
<div aria-hidden className="whitespace-pre-wrap">
{defaultValue || <span className="text-muted-foreground">{placeholder}</span>}
</div>
)}
<div ref={containerRef} />
{status && (
// Pinned to the corner rather than placed after the text: it has to
// be in the same spot every time you glance for it, and the document
// is the wrong length for that. Its own ground keeps it legible over
// a long first line.
//
// Visual only: the announcement is the sr-only region below, which
// carries the SETTLED states alone. Announcing "unsaved" and "saving"
// too would narrate every pause in typing — and CodeMirror already
// has a polite region of its own inside this editor.
<div
aria-hidden
className="bg-background/90 text-muted-foreground pointer-events-none absolute top-0 right-0 flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs select-none"
>
<span
aria-hidden
className={cn(
"size-1.5 rounded-full",
status === "saving" && "bg-muted-foreground animate-pulse",
status === "dirty" && "bg-muted-foreground/50",
status === "saved" && "bg-muted-foreground/70",
status === "error" && "bg-destructive",
)}
/>
<span className={cn(status === "error" && "text-destructive")}>
{SAVE_LABEL[status]}
</span>
</div>
)}
{onSave && (
<span className="sr-only" aria-live="polite">
{saveState === "saved" || saveState === "error" ? SAVE_LABEL[saveState] : ""}
</span>
)}
</div>
</div>
</EditorContext.Provider>
);
});
Editor.displayName = "Editor";
registry/ui/editor-controls.tsx
"use client";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { indentLess, indentMore, redo, undo } from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
import { Menu } from "@base-ui/react/menu";
import { Toolbar } from "@base-ui/react/toolbar";
import { useIcons, type IconComponent } from "@/lib/icon-context";
import {
formattingAt,
insertHorizontalRule,
insertLink,
setHeading,
toggleLinePrefix,
toggleWrap,
type Formatting,
} from "@/lib/live-markdown";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useEditorView } from "@/components/ui/editor";
import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
type Command = (view: EditorView) => boolean;
// The keyboard shortcut is spelt for the platform the reader is on, since a
// tooltip that says ⌘B to someone holding Ctrl is a tooltip that lies. Read
// at render: it never appears in the server markup (tooltips open on hover).
const mod = () =>
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl+";
const STYLES: { level: 0 | 1 | 2 | 3; label: string }[] = [
{ level: 0, label: "Paragraph" },
{ level: 1, label: "Heading 1" },
{ level: 2, label: "Heading 2" },
{ level: 3, label: "Heading 3" },
];
/**
* The formatting toolbar for `<Editor>`, rendered as its child. Every button
* runs one of the editor's own commands — the same ones its shortcuts run —
* and reads its pressed state from the caret, so it is a second way in, never
* a second source of truth.
*/
export function EditorControls({ className, style, ...props }: ComponentProps<"div">) {
const view = useEditorView();
const icons = useIcons();
const formatting = view ? formattingAt(view.state) : null;
const run = (command: Command) => {
if (!view) return;
command(view);
view.focus();
};
const rootRef = useScrollCue();
return (
<TooltipProvider>
<Toolbar.Root
ref={rootRef}
aria-label="Formatting"
// One row that scrolls rather than wraps: on a phone a toolbar folded
// into three rows eats the screen the text needed, and a sideways
// strip is what the platform's own editors do. The scrollbar is
// hidden because the strip is a control, not a document — inline,
// so a host stylesheet's own `scrollbar-width` on `*` cannot win.
// With the scrollbar gone the only thing left to say there is more
// toolbar is the fade — see `useScrollCue` for when it is on.
className={cn(
"flex items-center overflow-x-auto py-0.5 [&::-webkit-scrollbar]:hidden",
"data-[overflow]:[mask-image:linear-gradient(to_right,black_calc(100%-24px),transparent)]",
className,
)}
style={{ scrollbarWidth: "none", ...style }}
{...props}
>
<Group>
<Control icon={icons.undo} label="Undo" keys="Z" disabled={!formatting?.canUndo} onRun={() => run(undo)} />
<Control icon={icons.redo} label="Redo" keys="⇧Z" disabled={!formatting?.canRedo} onRun={() => run(redo)} />
</Group>
<Separator />
<Group>
<StyleMenu heading={formatting?.heading ?? 0} view={view} />
</Group>
<Separator />
<Group>
<Control icon={icons.bold} label="Bold" keys="B" pressed={formatting?.strong} onRun={() => run(toggleWrap("**"))} />
<Control icon={icons.italic} label="Italic" keys="I" pressed={formatting?.em} onRun={() => run(toggleWrap("*"))} />
<Control icon={icons.strikethrough} label="Strikethrough" keys="⇧X" pressed={formatting?.del} onRun={() => run(toggleWrap("~~"))} />
<Control icon={icons.code} label="Code" keys="E" pressed={formatting?.code} onRun={() => run(toggleWrap("`"))} />
</Group>
<Separator />
<Group>
<Control icon={icons.link} label="Link" keys="K" pressed={formatting?.link} onRun={() => run(insertLink())} />
<Control icon={icons.image} label="Image" onRun={() => run(insertLink(true))} />
<Control icon={icons.minus} label="Horizontal rule" onRun={() => run(insertHorizontalRule)} />
</Group>
<Separator />
{/* The four that rewrite the head of the line, together: each swaps
the marker the others wrote, so a quote sits with the lists. */}
<Group>
<Control icon={icons.list} label="Bullet list" pressed={formatting?.line === "bullet"} onRun={() => run(toggleLinePrefix("bullet"))} />
<Control icon={icons["list-ordered"]} label="Numbered list" pressed={formatting?.line === "ordered"} onRun={() => run(toggleLinePrefix("ordered"))} />
<Control icon={icons["list-todo"]} label="Task list" keys="L" pressed={formatting?.line === "task"} onRun={() => run(toggleLinePrefix("task"))} />
<Control icon={icons["text-quote"]} label="Quote" pressed={formatting?.line === "quote"} onRun={() => run(toggleLinePrefix("quote"))} />
</Group>
<Separator />
{/* Tab and Shift+Tab on a keyboard; on a phone these two are the only
way to nest a bullet. */}
<Group>
{/* Off on the first row of a list: there is no sibling above to nest
under, and indenting it anyway turns the marker into literal text. */}
<Control icon={icons["indent-increase"]} label="Indent" keys="]" disabled={!formatting?.canIndent} onRun={() => run(indentMore)} />
<Control icon={icons["indent-decrease"]} label="Outdent" keys="[" disabled={!formatting?.canOutdent} onRun={() => run(indentLess)} />
</Group>
</Toolbar.Root>
</TooltipProvider>
);
}
/** Marks the row `data-overflow` while there is toolbar past its right edge,
* which is what the fade hangs off. Dropped at the end of the scroll, so the
* cue only ever points at something there is more of. */
function useScrollCue() {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = ref.current;
if (!root) return;
const update = () =>
root.toggleAttribute("data-overflow", root.scrollWidth - root.clientWidth - root.scrollLeft > 1);
update();
const observer = new ResizeObserver(update);
observer.observe(root);
root.addEventListener("scroll", update, { passive: true });
return () => {
observer.disconnect();
root.removeEventListener("scroll", update);
};
}, []);
return ref;
}
function Group({ children }: { children: React.ReactNode }) {
return <Toolbar.Group className="flex shrink-0 items-center">{children}</Toolbar.Group>;
}
function Separator() {
return <Toolbar.Separator className="mx-1 h-5 w-px shrink-0 bg-border" />;
}
interface ControlProps {
icon: IconComponent;
label: string;
/** The key after the modifier, for the tooltip. */
keys?: string;
pressed?: boolean;
disabled?: boolean;
onRun: () => void;
}
function Control({ icon: Icon, label, keys, pressed = false, disabled = false, onRun }: ControlProps) {
return (
<Tooltip content={keys ? `${label} ${mod()}${keys}` : label}>
<Toolbar.Button
disabled={disabled}
// A control must not take the focus it is about to act on: the click
// lands with the caret still in the text, and the selection it wraps
// is the one the reader made.
onMouseDown={(event) => event.preventDefault()}
onClick={onRun}
render={
<Button
variant="ghost"
size="icon-compact"
active={pressed}
aria-label={label}
aria-pressed={pressed}
// `data-disabled`, not `:disabled`: a toolbar item stays focusable
// when it is off, so Base UI never sets the native attribute and
// the button's own `disabled:` classes never fire — Undo with
// nothing to undo was drawn exactly like Undo with something.
// 44px under a fingertip (WCAG 2.2), the dense 28px under a mouse.
className="data-[disabled]:opacity-30 data-[disabled]:hover:text-muted-foreground pointer-coarse:size-11"
>
<Icon />
</Button>
}
/>
</Tooltip>
);
}
function StyleMenu({ heading, view }: { heading: Formatting["heading"]; view: EditorView | null }) {
const [open, setOpen] = useState(false);
const icons = useIcons();
return (
<Menu.Root open={open} onOpenChange={setOpen}>
<Toolbar.Button
render={
<Menu.Trigger
render={
<Button
variant="ghost"
size="compact"
trailingIcon={icons["chevron-down"]}
active={open}
className="pointer-coarse:h-11"
>
Style
</Button>
}
/>
}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={6} align="start" className="z-50">
<Menu.Popup
// Back to the text on close, not the trigger: the reader picked a
// style in order to keep writing. This is the ONLY thing that may
// take focus here — see the note on onValueChange below.
finalFocus={() => view?.contentDOM ?? null}
className={cn(
"min-w-40 rounded-[var(--radius-bg,var(--radius,0.5rem))] bg-popover p-1 text-popover-foreground",
"shadow-[0_0_0_1px_var(--border),0_8px_24px_-8px_rgb(0_0_0/0.25)]",
"origin-(--transform-origin) transition-[opacity,scale] duration-(--motion-moderate) ease-spring",
"data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:duration-(--motion-moderate-exit)",
"data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
)}
>
<Menu.RadioGroup
value={heading}
// Runs the command WITHOUT focusing the editor, unlike every
// other control here. Focusing mid-close moves focus out of the
// open popup, which Base UI reads as a focus-out: it suppresses
// its own return-focus and re-closes, so `finalFocus` never runs
// and the caret is left on <body>. The popup hands focus back.
onValueChange={(level) => view && setHeading(level)(view)}
>
{STYLES.map(({ level, label }) => (
<Menu.RadioItem
key={level}
value={level}
closeOnClick
className={cn(
"flex cursor-default items-center justify-between gap-6 rounded-[calc(var(--radius-bg,var(--radius,0.5rem))-4px)] px-2 py-1.5 text-[13px] outline-none select-none",
"data-[highlighted]:bg-hover",
level === 1 && "text-base font-semibold",
level === 2 && "text-[15px] font-semibold",
level === 3 && "font-semibold",
)}
>
{label}
<Menu.RadioItemIndicator render={<icons.check size={14} className="size-3.5" />} />
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
registry/lib/live-markdown.ts
// CodeMirror 6 extension set behind <Editor>: one always-on surface that is
// both the view and the editor. Markdown renders in place and the raw syntax
// reveals only where the caret is, Obsidian-style. Pure CM6 — no React — so
// the decoration logic stays headlessly testable.
//
// Type and colour are NOT in here. Every decoration reads an `EditorElements`
// map, which is this editor's answer to an MDX components map: the same keys,
// each saying which ELEMENT the construct renders as and which classes it
// carries. It cannot be a map of React components, because these ranges are
// live editable text — a `<strong>` here wraps the very characters the caret
// is moving through, so it has to be a tag CodeMirror can mark text with, not
// a node handed to a renderer. Emitting the real tags is what makes an
// existing prose stylesheet apply to the editor without being restated.
//
// The theme below holds only what must not vary — padding, caret, selection,
// list indent, and the box model of those tags (see the reset in it).
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
redoDepth,
undoDepth,
} from "@codemirror/commands";
import {
deleteMarkupBackward,
insertNewlineContinueMarkupCommand,
markdownLanguage,
pasteURLAsLink,
} from "@codemirror/lang-markdown";
import { Language, indentUnit, syntaxTree } from "@codemirror/language";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
drawSelection,
keymap,
placeholder as cmPlaceholder,
type DecorationSet,
type KeyBinding,
type ViewUpdate,
} from "@codemirror/view";
import {
EditorSelection,
StateEffect,
StateField,
type EditorState,
type Extension,
type Range,
type StateCommand,
} from "@codemirror/state";
import type { SyntaxNode } from "@lezer/common";
import type { MarkdownParser } from "@lezer/markdown";
/** What one construct renders as: a class string, or an element and its
* classes. `{ tag: "strong", className: "font-semibold" }` emits
* `<strong class="font-semibold">` around the live text. */
export type EditorElement = string | { tag?: string; className?: string };
/** The editor's element map. Keys are the HTML elements an MDX components map
* uses, plus the three constructs markdown has and HTML doesn't. Anything
* omitted falls back to `defaultElements`. */
export interface EditorElements {
h1?: EditorElement;
h2?: EditorElement;
h3?: EditorElement;
h4?: EditorElement;
h5?: EditorElement;
h6?: EditorElement;
strong?: EditorElement;
em?: EditorElement;
del?: EditorElement;
code?: EditorElement;
a?: EditorElement;
/** The whole list row. The indent itself is structural and always applied. */
li?: EditorElement;
/** The whole quoted row. The rule down its left edge is structural. */
blockquote?: EditorElement;
/** The `<hr>` standing in for `---` once the caret has left its line. */
hr?: EditorElement;
/** The • / ◦ glyph standing in for a `-` marker. */
bullet?: EditorElement;
/** The real `<input type="checkbox">` standing in for `[ ]` / `[x]`. */
checkbox?: EditorElement;
/** The text of a checked task row. */
taskDone?: EditorElement;
}
// Tailwind utilities and the matching tag, so semantics come for free (a
// screen reader meets a real heading, not a div) and a consumer's own element
// CSS lands on the right thing. Colours are tokens, which flip on `.dark`, so
// one set covers both modes and installing the editor needs no stylesheet.
//
// The three keys with no tag are the ones where an element would be a lie: a
// list ROW is a `.cm-line` div (CodeMirror owns it), and an `<li>` outside a
// list, or a `<del>` around half a line, is worse markup than a class.
export const defaultElements: Required<Record<keyof EditorElements, { tag?: string; className: string }>> = {
h1: { tag: "h1", className: "text-2xl font-semibold" },
h2: { tag: "h2", className: "text-xl font-semibold" },
h3: { tag: "h3", className: "text-lg font-semibold" },
h4: { tag: "h4", className: "font-semibold" },
h5: { tag: "h5", className: "font-semibold" },
h6: { tag: "h6", className: "font-semibold" },
strong: { tag: "strong", className: "font-semibold" },
em: { tag: "em", className: "italic" },
del: { tag: "del", className: "line-through" },
code: { tag: "code", className: "font-mono text-sm bg-muted rounded px-1" },
a: { tag: "a", className: "underline underline-offset-2 decoration-muted-foreground" },
li: { className: "" },
blockquote: { className: "text-muted-foreground" },
hr: { className: "border-border" },
bullet: { className: "text-muted-foreground" },
checkbox: { className: "size-3.5 pointer-coarse:size-[18px] accent-[var(--primary)] cursor-pointer" },
taskDone: { className: "text-muted-foreground line-through" },
};
function resolve(
elements: EditorElements | undefined,
key: keyof EditorElements,
): { tag?: string; className: string } {
const value = elements?.[key];
if (value === undefined) return defaultElements[key];
if (typeof value === "string") return { tag: defaultElements[key].tag, className: value };
return { tag: value.tag, className: value.className ?? "" };
}
// Chromeless and type-free: the editor inherits the font, size and leading of
// whatever it is mounted in, and every colour reads a token. `min-height`
// inherits so one `min-h-*` on the wrapper sizes both the editor's click
// target and the pre-hydration fallback.
const chromelessTheme = EditorView.theme({
"&": { backgroundColor: "transparent", fontSize: "inherit" },
"&.cm-focused": { outline: "none" },
".cm-scroller": {
fontFamily: "inherit",
lineHeight: "inherit",
overflow: "visible", // the page scrolls, not the editor
},
".cm-content": {
padding: "0",
minHeight: "inherit",
caretColor: "var(--foreground)",
// Kill the 300ms double-tap-zoom wait and the grey flash a tap leaves on
// mobile WebKit. Neither affects long-press selection or double-tap word
// select — those are selection gestures, not browser zoom gestures.
touchAction: "manipulation",
WebkitTapHighlightColor: "transparent",
},
".cm-line": { padding: "0" },
// The emitted tags (h1…h6, strong, code, a) sit INSIDE a line div, and a
// source editor's vertical rhythm is its blank lines, not element margins:
// a heading that brought `margin: 1rem 0` with it would push the caret off
// its own text. Type, weight and colour still come through from whatever
// styles the tag — only the box model is pinned.
".cm-line :is(h1,h2,h3,h4,h5,h6,p,blockquote)": {
display: "inline",
margin: "0",
padding: "0",
},
// List rows read as a block. Structural, and applied regardless of caret
// position — layout must never toggle as the caret moves.
".cm-line.cm-md-li": { paddingLeft: "0.5rem" },
// A quoted row reads as a block the same way: the rule is structural and
// stays put as the caret moves; what the element map adds is colour.
".cm-line.cm-md-quote": {
paddingLeft: "0.75rem",
boxShadow: "inset 2px 0 0 var(--border)",
},
// Hanging indent: a wrapped line continues under its own text, not back
// at the left edge. `--hang` is the measured width of what precedes the
// text — leading spaces, and a bullet or checkbox plus its space — set per
// line by the preview (see hangMetrics). Padding pushes every row of the
// line in by that much and the negative text-indent pulls only the first
// row back out, so the marker still sits flush.
".cm-line.cm-md-hang": {
paddingLeft: "var(--hang)",
textIndent: "calc(var(--hang) * -1)",
},
".cm-line.cm-md-li.cm-md-hang": { paddingLeft: "calc(0.5rem + var(--hang))" },
// One marker column for every list kind. The bullet glyph, the checkbox and
// an ordered row's `1.` each centre in a box `--marker-w` wide — the widest
// of the three, measured and set per line beside `--hang` — so the markers
// share a centre axis and the text after them starts at the same x. The
// column stops at the marker: the space that follows it is document text on
// all three, so it lines them up too. `text-indent: 0` because the hanging
// indent's negative indent inherits into these boxes and would drag the
// marker out of the column. The `auto` fallback is what the metric probes
// measure against, before any `--marker-w` exists. A minimum, not a width:
// the number is text, and a `10.` wider than the column must spill past
// it rather than wrap inside its own box.
".cm-md-bullet, .cm-md-check, .cm-md-num": {
display: "inline-block",
minWidth: "var(--marker-w, auto)",
textAlign: "center",
textIndent: "0",
whiteSpace: "nowrap",
},
// The checkbox wrapper. Sizing the box is the element map's job; this only
// sits it on the text baseline, and `display: block` on the input keeps the
// wrapper's baseline at its bottom edge rather than on a line box that
// shifts with the box's size. `content-box` so the coarse-pointer padding
// below grows OUTSIDE the column and the input stays on the column's centre.
".cm-md-check": { verticalAlign: "-0.09375rem", boxSizing: "content-box" },
// The rule fills its line and sits on the text's midline, so the row keeps
// the height of the `---` it replaces and the caret lands beside it.
".cm-md-hr": {
display: "inline-block",
width: "100%",
margin: "0",
verticalAlign: "middle",
borderWidth: "1px 0 0",
borderStyle: "solid",
},
// Centred in the column, not flush against its left edge.
".cm-md-check > input": { display: "block", marginInline: "auto" },
".cm-cursor": { borderLeftColor: "var(--foreground)" },
// drawSelection() replaces the native selection and caret (the native caret
// spans the full line box and reads oversized); its layers need explicit
// colours.
".cm-selectionBackground": {
backgroundColor: "color-mix(in oklab, var(--foreground) 12%, transparent)",
},
"&.cm-focused .cm-selectionBackground": {
backgroundColor: "color-mix(in oklab, var(--focus-ring, #6B97FF) 30%, transparent)",
},
".cm-placeholder": { color: "var(--muted-foreground)" },
// --- Touch ---------------------------------------------------------------
"@media (pointer: coarse)": {
// iOS zooms the page when an editable field under 16px takes focus, and
// leaves the user zoomed in with no way back. A floor, not a size: a
// surface already larger than 16px keeps whatever it inherited.
".cm-content": { fontSize: "max(1rem, 1em)" },
// WCAG 2.2 target size (24×24 minimum) — a 14px checkbox is a coin toss
// with a fingertip. The HIT AREA grows, not the box: padding takes the
// label past 24px in both axes and an equal negative margin gives the
// space straight back, so the line keeps its height and the text keeps
// its distance. Sizing the box itself to 24px is what made it tower over
// 16px type with the sentence jammed against it.
".cm-md-check": { padding: "0.375rem", margin: "-0.375rem" },
},
});
/** True on a device whose primary input is a finger. Read once, at mount —
* extensions are built there and a pointer type does not change mid-session
* in any way worth rebuilding an editor over. */
function isTouchDevice(): boolean {
return typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches === true;
}
/** How much of the window the on-screen keyboard is covering.
*
* iOS and Android open the keyboard OVER the page: the layout viewport keeps
* its height and only the visual viewport shrinks, so nothing in CSS knows
* the bottom of the window is gone. */
function keyboardInset(): number {
const viewport = typeof window !== "undefined" ? window.visualViewport : null;
if (!viewport) return 0;
return Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
}
// Both halves of keeping the caret off the keyboard: tell CodeMirror how much
// of the window is obscured whenever it scrolls the selection into view, and
// re-run that scroll when the keyboard opens, closes or changes height (a
// suggestion strip appearing is a resize too). Without the second half, the
// keyboard opens over the line you just tapped and nothing moves.
const keyboardAware: Extension = [
EditorView.scrollMargins.of(() => {
const inset = keyboardInset();
return inset > 0 ? { bottom: inset + 16 } : null;
}),
ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
window.visualViewport?.addEventListener("resize", this.onViewportResize);
}
// A frame later: iOS reports the resize while the keyboard is still
// sliding, and scrolling into a viewport that is still moving lands
// short of where it settles.
onViewportResize = () => {
if (!this.view.hasFocus) return;
requestAnimationFrame(() => {
if (!this.view.hasFocus) return;
this.view.dispatch({
effects: EditorView.scrollIntoView(this.view.state.selection.main.head),
});
});
};
destroy() {
window.visualViewport?.removeEventListener("resize", this.onViewportResize);
}
},
),
];
/* ------------------------------------------------------------------
* Formatting shortcuts
* ------------------------------------------------------------------ */
/** Toggle an inline marker pair (** / * / ~~ / `) around each selection range.
* Empty selections get an empty pair with the caret inside; a second
* invocation right away (or on an already-wrapped selection) unwraps. */
export function toggleWrap(marker: string): StateCommand {
return ({ state, dispatch }) => {
const len = marker.length;
const char = marker[0];
// The full run of `char` touching a position, not just one marker's
// width of it — `*` and `**` share a character, so telling italic from
// bold needs the WHOLE run outside the range, not just its last `len`
// characters (which is what let toggling italic on a bold word strip
// one star off each side instead of adding a third).
const runBefore = (pos: number) => {
let n = 0;
while (pos - n > 0 && state.sliceDoc(pos - n - 1, pos - n) === char) n++;
return n;
};
const runAfter = (pos: number) => {
let n = 0;
while (pos + n < state.doc.length && state.sliceDoc(pos + n, pos + n + 1) === char) n++;
return n;
};
const changes = state.changeByRange((range) => {
const { from, to } = range;
// Markers just outside the range (also the caret-between-markers
// case). `*` only counts as already-applied when the run on both
// sides is odd — an even run is a `**` pair, not `*` plus leftover —
// every other marker has no such overlap, so any run at least its own
// length counts.
const before = runBefore(from);
const after = runAfter(to);
const wrapped = marker === "*" ? before % 2 === 1 && after % 2 === 1 : before >= len && after >= len;
if (wrapped) {
return {
changes: [
{ from: from - len, to: from },
{ from: to, to: to + len },
],
range: EditorSelection.range(from - len, to - len),
};
}
// Markers included at the edges of the selection.
const inner = state.sliceDoc(from, to);
if (inner.length >= 2 * len && inner.startsWith(marker) && inner.endsWith(marker)) {
return {
changes: [
{ from, to: from + len },
{ from: to - len, to },
],
range: EditorSelection.range(from, to - 2 * len),
};
}
// Caret sitting right before this marker's own closer (typed content
// in between, so `wrapped` above missed it) — step past it instead of
// opening a second, empty pair right next to the first.
if (
range.empty &&
state.sliceDoc(to, to + len) === marker &&
state.sliceDoc(to + len, to + len + 1) !== char &&
state.sliceDoc(to - len, to) !== marker
) {
return { range: EditorSelection.cursor(to + len) };
}
return {
changes: [
{ from, insert: marker },
{ from: to, insert: marker },
],
range: EditorSelection.range(from + len, to + len),
};
});
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
return true;
};
}
/** The block constructs a line can open with. Exactly one applies per line:
* the outermost marker, so a `> - item` row is a quote here. */
export type LinePrefix = "quote" | "task" | "bullet" | "ordered";
// Indent, then the marker with its space. The task pattern comes before the
// bullet it extends. The heading marker is deliberately absent: a heading and
// a list marker never share a row, and `setHeading` handles it on its own.
const LINE_PREFIX = /^(\s*)(?:(> )|(- \[[ xX]\] )|([-*+] )|(\d+[.)] ))?/;
/** What a line opens with, and how far it reaches into the text. */
export function linePrefixOf(text: string): { indent: number; kind: LinePrefix | null; end: number } {
const m = LINE_PREFIX.exec(text)!;
const kind = m[2] ? "quote" : m[3] ? "task" : m[4] ? "bullet" : m[5] ? "ordered" : null;
return { indent: m[1].length, kind, end: m[0].length };
}
/** Every line the selection touches, once, in document order. */
function selectedLines(state: EditorState) {
const seen = new Set<number>();
const lines = [];
for (const range of state.selection.ranges) {
const last = state.doc.lineAt(range.to).number;
for (let n = state.doc.lineAt(range.from).number; n <= last; n++) {
if (seen.has(n)) continue;
seen.add(n);
lines.push(state.doc.line(n));
}
}
return lines;
}
/** Apply a per-line prefix rewrite, keeping the caret after whatever was
* inserted in front of it. Without `assoc: 1` the default mapping leaves it
* before a fresh "- [ ] ", visually hidden behind the checkbox. */
function rewriteLines(
{ state, dispatch }: Parameters<StateCommand>[0],
rewrite: (line: { from: number; text: string }, index: number) => { from: number; to?: number; insert?: string } | null,
): boolean {
const changes = selectedLines(state)
.map((line, i) => rewrite(line, i))
.filter((c) => c !== null);
if (changes.length === 0) return false;
const changeSet = state.changes(changes);
dispatch(
state.update({
changes: changeSet,
selection: state.selection.map(changeSet, 1),
userEvent: "input",
}),
);
return true;
}
/** Toggle a block prefix on every selected line: a line already carrying
* `kind` loses it, one carrying another marker swaps, a plain line gains it.
* So Cmd+L on a bullet turns it into a task and again into plain text. */
export function toggleLinePrefix(kind: LinePrefix): StateCommand {
return (target) =>
rewriteLines(target, (line, i) => {
const { indent, kind: current, end } = linePrefixOf(line.text);
const from = line.from + indent;
if (current === kind) return { from, to: line.from + end };
const marker =
kind === "quote" ? "> " : kind === "task" ? "- [ ] " : kind === "bullet" ? "- " : `${i + 1}. `;
return { from, to: line.from + end, insert: marker };
});
}
const HEADING = /^(#{1,6}) /;
/** Set the heading level of every selected line — 0 is a paragraph. A line
* already at that level is left alone rather than toggled, so a menu that
* re-selects "Heading 2" is a no-op, not a demotion. */
export function setHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): StateCommand {
return (target) =>
rewriteLines(target, (line) => {
const current = HEADING.exec(line.text);
const currentLevel = current ? current[1].length : 0;
if (currentLevel === level) return null;
const insert = level ? "#".repeat(level) + " " : "";
return { from: line.from, to: line.from + (current?.[0].length ?? 0), insert };
});
}
/** Wrap each range as `[text](url)` — or `` for an image — and
* leave the placeholder URL selected, so typing replaces it. An empty range
* gets a placeholder label too. The image form is written raw: rendering an
* image is the `extensions` seam's job, not the text surface's. */
export function insertLink(image = false): StateCommand {
return ({ state, dispatch }) => {
const open = image ? "`;
const urlFrom = range.from + open.length + label.length + 2;
return {
changes: { from: range.from, to: range.to, insert },
range: EditorSelection.range(urlFrom, urlFrom + 3),
};
});
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
return true;
};
}
/** Put a `---` on a line of its own after the caret's line, and the caret on
* a fresh line under it. The blank line before it is not optional: markdown
* reads `text` directly over `---` as a setext heading, not a rule. */
export const insertHorizontalRule: StateCommand = ({ state, dispatch }) => {
const line = state.doc.lineAt(state.selection.main.head);
const previousHasText = line.number > 1 && state.doc.line(line.number - 1).length > 0;
const lead = line.length > 0 ? "\n\n" : previousHasText ? "\n" : "";
const insert = `${lead}---\n`;
dispatch(
state.update({
changes: { from: line.to, insert },
selection: { anchor: line.to + insert.length },
scrollIntoView: true,
userEvent: "input",
}),
);
return true;
};
/** What is in force at the caret, for a toolbar's pressed states. Inline
* constructs come from the syntax tree (so only closed ones count, which is
* also what the eager styling of an unclosed `**` would mislead about); the
* block state is read off the line the same way the commands write it. */
export interface Formatting {
heading: 0 | 1 | 2 | 3 | 4 | 5 | 6;
line: LinePrefix | null;
strong: boolean;
em: boolean;
del: boolean;
code: boolean;
link: boolean;
canUndo: boolean;
canRedo: boolean;
/** Whether the caret's line can take another indent step. */
canIndent: boolean;
/** Whether the caret's line has an indent to give back. */
canOutdent: boolean;
}
const LIST_KINDS: LinePrefix[] = ["bullet", "task", "ordered"];
/** A list row nests under the row above it, so the FIRST item of a list has
* nothing to nest under: indenting it writes ` - a`, which markdown reads
* as a paragraph continuation of the item before, not a nested list — the
* marker comes back as a literal `-`. A plain line has no such constraint. */
function canIndentLine(state: EditorState, line: { number: number; text: string }): boolean {
const { indent, kind } = linePrefixOf(line.text);
if (!kind || !LIST_KINDS.includes(kind)) return true;
for (let n = line.number - 1; n >= 1; n--) {
const previous = state.doc.line(n);
if (previous.text.trim() === "") continue;
const above = linePrefixOf(previous.text);
// Same indent is the previous sibling; deeper is a child of it, and its
// own parent is then a sibling of ours.
return above.kind !== null && LIST_KINDS.includes(above.kind) && above.indent >= indent;
}
return false;
}
const INLINE_NODES: Record<string, "strong" | "em" | "del" | "code" | "link"> = {
StrongEmphasis: "strong",
Emphasis: "em",
Strikethrough: "del",
InlineCode: "code",
Link: "link",
Image: "link",
};
export function formattingAt(state: EditorState): Formatting {
const head = state.selection.main.head;
const line = state.doc.lineAt(head);
const formatting: Formatting = {
heading: (HEADING.exec(line.text)?.[1].length ?? 0) as Formatting["heading"],
line: linePrefixOf(line.text).kind,
strong: false,
em: false,
del: false,
code: false,
link: false,
canUndo: undoDepth(state) > 0,
canRedo: redoDepth(state) > 0,
canIndent: canIndentLine(state, line),
canOutdent: linePrefixOf(line.text).indent > 0,
};
// Side -1 so a caret sitting just past `**bold**|` still reads as bold,
// which is where it lands the moment the closing marks are typed.
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(head, -1); node; node = node.parent) {
const key = INLINE_NODES[node.name];
if (key) formatting[key] = true;
}
return formatting;
}
const EMPTY_QUOTE = /^\s*(> ?)+$/;
/** Enter on a row that is only a quote marker leaves the quote — and leaves
* a BLANK line behind it. Clearing the marker in place is not enough: in
* CommonMark the next sentence typed directly under a quote is a lazy
* continuation of it, and the rule carries on down the left edge.
*
* Lists are left to CodeMirror, which clears the marker and stays on the
* row: the glyph goes and the caret does not move, which is what a second
* Enter feels like it should do. A sentence typed there is a continuation
* of the item in markdown terms, but nothing on the surface says so, so the
* extra row would only read as a jump. `> > ` leaves the whole nesting at
* once, as it always has. */
const exitEmptyQuote: StateCommand = ({ state, dispatch }) => {
const { main } = state.selection;
if (!main.empty) return false;
const line = state.doc.lineAt(main.head);
if (!EMPTY_QUOTE.test(line.text)) return false;
dispatch(state.update({ changes: { from: line.from, to: line.to, insert: "\n" }, userEvent: "input" }));
return true;
};
/** Home on a row that opens with a marker goes to the start of the TEXT. The
* marker is chrome — a bullet glyph or a checkbox — and landing in front of
* it is never what was meant; returning false at the text start hands the
* second press to the stock command, which is the row start. */
const cursorTextStart: StateCommand = ({ state, dispatch }) => {
const { main } = state.selection;
const line = state.doc.lineAt(main.head);
const { indent, end } = linePrefixOf(line.text);
if (end === indent || main.head <= line.from + end) return false;
dispatch(state.update({ selection: { anchor: line.from + end }, scrollIntoView: true }));
return true;
};
/** Enter at the start of a list item's text opens an empty item above it and
* keeps the caret on its own words. CodeMirror's continuation writes the new
* marker with NO trailing space (`-\n- one`), and a space is what commits a
* block marker here — so its row came out as a bare `-`. */
const openItemAbove: StateCommand = ({ state, dispatch }) => {
const { main } = state.selection;
if (!main.empty) return false;
const line = state.doc.lineAt(main.head);
const { indent, kind, end } = linePrefixOf(line.text);
if (kind === null || kind === "quote") return false;
if (main.head !== line.from + end || line.length === end) return false;
const lead = line.text.slice(0, indent);
const marker = line.text.slice(indent, end);
// The row being pushed down becomes the second item, so its number moves on.
const next = kind === "ordered" ? marker.replace(/^\d+/, (n) => String(Number(n) + 1)) : marker;
const insert = `${lead}${marker}\n${lead}${next}`;
dispatch(
state.update({
changes: { from: line.from, to: line.from + end, insert },
selection: { anchor: line.from + insert.length },
scrollIntoView: true,
userEvent: "input",
}),
);
return true;
};
const formattingKeymap: readonly KeyBinding[] = [
{ key: "Mod-b", run: toggleWrap("**"), preventDefault: true },
{ key: "Mod-i", run: toggleWrap("*"), preventDefault: true },
{ key: "Mod-e", run: toggleWrap("`"), preventDefault: true },
{ key: "Mod-Shift-x", run: toggleWrap("~~"), preventDefault: true },
{ key: "Mod-k", run: insertLink(), preventDefault: true },
{ key: "Mod-l", run: toggleLinePrefix("task"), preventDefault: true },
];
// Escape and Cmd+Enter blur. Saving is the component's debounced autosave, so
// blur is "I'm done here" — it flushes, but it isn't the commit gesture.
const blurKeymap: readonly KeyBinding[] = [
{
key: "Escape",
run: (view) => {
view.contentDOM.blur();
return true;
},
},
{
key: "Mod-Enter",
run: (view) => {
view.contentDOM.blur();
return true;
},
},
];
/* ------------------------------------------------------------------
* Hanging-indent metrics
* ------------------------------------------------------------------
* The editor sits in a proportional prose font, so the width of the indent
* cannot be written in `ch` — a space, a bullet glyph and the checkbox are
* each measured once, inside `.cm-content` so they inherit its type, and
* again when the geometry changes (a web font landing, a resize). Ordinary
* lines never touch the DOM for this.
*/
interface HangMetrics {
space: number;
bullet: number;
subBullet: number;
checkbox: number;
ordered: number;
}
/** The shared marker column. Every list marker — the bullet glyph, the
* checkbox, an ordered row's `1.` — centres in a box this wide, so the three
* sit on one axis and their text starts at the same x. The widest marker
* wins. It tracks `1.`, not `10.`: a two-digit number spills into the space
* that follows it rather than widening the column for every other row, which
* is what most editors do. */
function markerColumn(metrics: HangMetrics): number {
return Math.max(metrics.bullet, metrics.subBullet, metrics.checkbox, metrics.ordered);
}
const setHangMetrics = StateEffect.define<HangMetrics>();
const hangMetrics = StateField.define<HangMetrics | null>({
create: () => null,
update(value, tr) {
for (const effect of tr.effects) if (effect.is(setHangMetrics)) return effect.value;
return value;
},
});
function measureHangMetrics(view: EditorView, marks: Marks): HangMetrics {
const probe = document.createElement("span");
probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre";
view.contentDOM.appendChild(probe);
const width = (child: Node, className = "") => {
probe.className = className;
probe.replaceChildren(child);
return probe.getBoundingClientRect().width;
};
const text = (value: string, className?: string) =>
width(document.createTextNode(value), className);
const metrics = {
space: text(" "),
bullet: text("•", `cm-md-bullet ${marks.bullet}`),
subBullet: text("◦", `cm-md-bullet ${marks.bullet}`),
// The real widget, margins and all: on touch it carries a padding the
// negative margin gives straight back, so only the wrapper's width is
// the advance the text sees.
checkbox: width(new CheckboxWidget(false, marks.checkbox).toDOM(view)),
// An ordered row's marker is the document's own text, so it is measured
// as text — with the widest digit, because `1` is the narrowest in a
// proportional face and a column cut to it wrapped `2.` inside itself.
// The probes see `min-width: var(--marker-w, auto)` and fall through to
// auto — `--marker-w` is set per line, never on the probe.
ordered: text("0."),
};
probe.remove();
return metrics;
}
// Measured off the DOM, so it goes through requestMeasure — a plugin's own
// update() may not read layout — and lands in state through an effect, so
// the decorations rebuild from state alone. The measure phase runs inside
// an update, where a dispatch throws, so the effect goes out a microtask
// later, once the cycle has closed.
function hangMeasurer(marks: Marks) {
return ViewPlugin.fromClass(
class {
destroyed = false;
constructor(readonly view: EditorView) {
this.schedule();
}
update(update: ViewUpdate) {
if (update.geometryChanged) this.schedule();
}
destroy() {
this.destroyed = true;
}
schedule() {
this.view.requestMeasure({
read: () => measureHangMetrics(this.view, marks),
write: (next, view) => {
const current = view.state.field(hangMetrics, false);
if (
current &&
current.space === next.space &&
current.bullet === next.bullet &&
current.subBullet === next.subBullet &&
current.checkbox === next.checkbox &&
current.ordered === next.ordered
)
return;
queueMicrotask(() => {
if (!this.destroyed) view.dispatch({ effects: setHangMetrics.of(next) });
});
},
});
}
},
);
}
// One line decoration per distinct pair of widths, so unchanged lines compare
// equal across rebuilds instead of being redrawn. `--marker-w` rides the same
// decoration as `--hang`: every row that has a marker has a hanging indent, so
// this is already the one place a per-line width reaches the markers inside.
const hangDecorations = new Map<string, Decoration>();
function hangLine(width: number, column: number): Decoration {
const round = (value: number) => Math.round(value * 100) / 100;
const px = round(width);
const columnPx = round(column);
const key = `${px}|${columnPx}`;
let deco = hangDecorations.get(key);
if (!deco) {
deco = Decoration.line({
class: "cm-md-hang",
attributes: { style: `--hang:${px}px;--marker-w:${columnPx}px` },
});
hangDecorations.set(key, deco);
}
return deco;
}
/* ------------------------------------------------------------------
* Live-preview decorations
* ------------------------------------------------------------------
* Conceal markdown syntax marks except where the selection touches the
* construct (node-scoped reveal — never whole-line, which is where the
* Obsidian-style jitter comes from). Heading classes are LINE decorations
* applied unconditionally, so the line box never changes with the caret;
* only the "#" marker's visibility does.
*/
class BulletWidget extends WidgetType {
constructor(
readonly glyph: string,
readonly className: string,
) {
super();
}
eq(other: BulletWidget) {
return other.glyph === this.glyph && other.className === this.className;
}
toDOM() {
const span = document.createElement("span");
span.className = `cm-md-bullet ${this.className}`;
span.textContent = this.glyph;
return span;
}
}
class HrWidget extends WidgetType {
constructor(readonly className: string) {
super();
}
eq(other: HrWidget) {
return other.className === this.className;
}
toDOM() {
const hr = document.createElement("hr");
hr.className = `cm-md-hr ${this.className}`;
return hr;
}
}
class CheckboxWidget extends WidgetType {
constructor(
readonly checked: boolean,
readonly className: string,
/** How far past the widget's own start the `[ ]` sits: the decoration
* covers the whole `- [ ]`, so its position is the ROW's, not the
* marker's. */
readonly markerOffset = 0,
) {
super();
}
eq(other: CheckboxWidget) {
return (
other.checked === this.checked &&
other.className === this.className &&
other.markerOffset === this.markerOffset
);
}
toDOM(view: EditorView) {
// A <label>, not a bare input: on touch the hit area has to reach past
// the box (see the target-size rule in the theme), and a label forwards a
// click on that margin to the input natively — no handler of our own.
const label = document.createElement("label");
label.className = "cm-md-check";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = this.checked;
input.className = `cm-md-checkbox ${this.className}`;
input.setAttribute("aria-label", this.checked ? "Mark task not done" : "Mark task done");
input.addEventListener("change", () => {
// Toggle by rewriting the three characters of the marker, which sits
// markerOffset past where the widget starts.
const pos = view.posAtDOM(label) + this.markerOffset;
view.dispatch({
changes: { from: pos, to: pos + 3, insert: this.checked ? "[ ]" : "[x]" },
});
});
// Ticking a task is not editing. Without this the press focuses the
// editor it lands in, and on a phone that raises the keyboard over the
// list being ticked. Cancelling the press keeps focus wherever it was —
// the click, and so the toggle, still goes through.
label.addEventListener("mousedown", (event) => event.preventDefault());
label.appendChild(input);
return label;
}
// Default ignoreEvent() is true: CM leaves clicks to the checkbox, so
// toggling doesn't move the caret.
}
// An ordered row's marker stays the document's own "1." — no widget to centre
// it, so the column class goes on the text itself. Structural only: colour and
// type are the surrounding line's, as before.
const orderedMark = Decoration.mark({ class: "cm-md-num" });
interface Marks {
hide: Decoration;
strong: Decoration;
em: Decoration;
del: Decoration;
code: Decoration;
link: Decoration;
taskDone: Decoration;
listLine: Decoration;
quoteLine: Decoration;
hr: string;
headingLines: Decoration[];
/** Null where the heading key carries no tag — then the line class is the
* whole treatment. */
headingTags: (Decoration | null)[];
bullet: string;
checkbox: string;
/** Unclosed delimiters, in match priority order. */
unclosed: { re: RegExp; len: number; deco: Decoration }[];
}
export function createMarks(elements?: EditorElements): Marks {
const at = (key: keyof EditorElements) => resolve(elements, key);
const marked = (key: keyof EditorElements) => {
const { tag, className } = at(key);
// The link keeps its href off (Cmd+click is what opens it), so it says
// what it is with a role instead — for screen readers, and for crawlers
// that otherwise report a bare <a> as an uncrawlable link.
const attributes = tag === "a" ? { role: "link" } : undefined;
return Decoration.mark({ tagName: tag, class: className, attributes });
};
const strong = marked("strong");
const em = marked("em");
const del = marked("del");
const code = marked("code");
const headings = (["h1", "h2", "h3", "h4", "h5", "h6"] as const).map(at);
return {
hide: Decoration.replace({}),
strong,
em,
del,
code,
link: marked("a"),
taskDone: marked("taskDone"),
listLine: Decoration.line({ class: `cm-md-li ${at("li").className}` }),
quoteLine: Decoration.line({ class: `cm-md-quote ${at("blockquote").className}` }),
hr: at("hr").className,
// The SIZE rides the line, not the tag: it has to apply to the whole line
// box or a wrapped heading's second row would come out at body size.
headingLines: headings.map((h) => Decoration.line({ class: h.className })),
headingTags: headings.map((h) => (h.tag ? Decoration.mark({ tagName: h.tag }) : null)),
bullet: at("bullet").className,
checkbox: at("checkbox").className,
// The parser only creates emphasis/code nodes for CLOSED constructs, so
// "**hello" being typed would stay plain until the closing "**" —
// headings style eagerly (no closer needed) and users expect the same
// here. A delimiter must be followed by a non-space (mirrors CommonMark's
// left-flanking rule; also keeps "* " list bullets out).
unclosed: [
{ re: /\*\*(?=\S)/, len: 2, deco: strong },
{ re: /(?<!\*)\*(?=[^\s*])/, len: 1, deco: em },
{ re: /~~(?=\S)/, len: 2, deco: del },
{ re: /`(?=\S)/, len: 1, deco: code },
],
};
}
/** Where a task row's marker starts: at the list bullet, not at the `[`. The
* whole `- [ ]` is drawn as one checkbox, so that is where the decoration and
* the atomic range both begin. */
function taskMarkerFrom(taskMarker: SyntaxNode): number {
return taskMarker.parent?.parent?.getChild("ListMark")?.from ?? taskMarker.from;
}
/** `- [ ] ` counts as one character to the caret. The marker and the space
* after it render as a single checkbox, so every position inside them is
* invisible — arrow keys and clicks land at the row start or the text start,
* never in between, where Backspace used to eat the space and with it the
* checkbox. */
export function taskMarkerRanges(
state: EditorState,
visible: readonly { from: number; to: number }[],
): DecorationSet {
const ranges: Range<Decoration>[] = [];
const tree = syntaxTree(state);
for (const { from, to } of visible) {
tree.iterate({
from,
to,
enter: (node) => {
if (node.name !== "TaskMarker") return;
const line = state.doc.lineAt(node.to);
ranges.push(atomicMarker.range(taskMarkerFrom(node.node), Math.min(node.to + 1, line.to)));
},
});
}
return Decoration.set(ranges, true);
}
// Never rendered — atomicRanges reads only the range bounds.
const atomicMarker = Decoration.replace({});
const atomicTaskMarkers = EditorView.atomicRanges.of((view) =>
taskMarkerRanges(view.state, view.visibleRanges),
);
/** Pure decoration computation, separated from the view for testability.
*
* `hasFocus` gates the caret-reveal: an unfocused editor still HAS a
* selection (position 0 on load), which would otherwise keep the first
* construct's syntax revealed until the user clicks — everything conceals
* when the editor isn't focused.
*
* Walks only `visible` (the viewport, plus CodeMirror's own overdraw
* margin) rather than the whole document — cost per keystroke must not grow
* with document length. */
export function computeLiveDecorations(
state: EditorState,
hasFocus: boolean,
marks: Marks,
visible: readonly { from: number; to: number }[],
): DecorationSet {
const ranges: Range<Decoration>[] = [];
const sel = state.selection.main;
const touches = (from: number, to: number) => hasFocus && sel.from <= to && sel.to >= from;
// A block marker only counts once a space follows it. Markdown already
// calls a lone "*" or "#" an empty list item or heading, so without this a
// "*" typed to open *italic* flashes a bullet, and "#" jumps to heading size
// before there is a heading. Until the space it is the text that was typed.
const committed = (markerEnd: number) => /[ \t]/.test(state.doc.sliceString(markerEnd, markerEnd + 1));
const tree = syntaxTree(state);
const metrics = state.field(hangMetrics, false) ?? null;
// What stands before the text on a list row, in px, keyed by the row's line
// start — filled from the tree walk, read in the line loop. Every kind of
// marker occupies the same column, so it is the same number on all three.
const markerWidth = new Map<number, number>();
const column = metrics ? markerColumn(metrics) : 0;
for (const { from: rangeFrom, to: rangeTo } of visible) {
// Inline constructs the parser DID match, per line — used to scan only
// leftover text for unclosed delimiters below.
const covered: [number, number][] = [];
tree.iterate({
from: rangeFrom,
to: rangeTo,
enter: (node) => {
if (node.name.startsWith("ATXHeading")) {
const headerMark = node.node.getChild("HeaderMark");
if (!headerMark || !committed(headerMark.to)) return;
const level = Number(node.name.slice("ATXHeading".length));
const line = state.doc.lineAt(node.from);
ranges.push(marks.headingLines[level - 1].range(line.from));
const tag = marks.headingTags[level - 1];
if (tag && node.to > node.from) ranges.push(tag.range(node.from, node.to));
if (!touches(node.from, node.to)) {
// Swallow the space after "#" too, so the text sits flush left.
const end =
state.doc.sliceString(headerMark.to, headerMark.to + 1) === " "
? headerMark.to + 1
: headerMark.to;
ranges.push(marks.hide.range(headerMark.from, end));
}
return;
}
switch (node.name) {
case "StrongEmphasis":
case "Emphasis":
case "Strikethrough": {
covered.push([node.from, node.to]);
const mark =
node.name === "StrongEmphasis" ? marks.strong : node.name === "Emphasis" ? marks.em : marks.del;
ranges.push(mark.range(node.from, node.to));
if (!touches(node.from, node.to)) {
const markName = node.name === "Strikethrough" ? "StrikethroughMark" : "EmphasisMark";
for (const child of node.node.getChildren(markName)) {
ranges.push(marks.hide.range(child.from, child.to));
}
}
break;
}
case "HorizontalRule": {
// The three dashes come back while the caret is on the line, the
// way a heading's `#` does — that is how the rule is edited.
if (touches(node.from, node.to)) break;
ranges.push(Decoration.replace({ widget: new HrWidget(marks.hr) }).range(node.from, node.to));
break;
}
case "Blockquote": {
// Every row of the quote carries the rule, lazy continuation lines
// included. The markers themselves are handled by the QuoteMark
// case: only the FIRST row's `>` is a child of the Blockquote —
// a continued row's belongs to the Paragraph inside it.
const opener = node.node.getChild("QuoteMark");
if (!opener || !committed(opener.to)) break;
const last = state.doc.lineAt(node.to).number;
for (let n = state.doc.lineAt(node.from).number; n <= last; n++) {
ranges.push(marks.quoteLine.range(state.doc.line(n).from));
}
break;
}
case "QuoteMark": {
// Concealed even with the caret on the row, as a bullet is: the
// rule already says "quote", and a `>` surfacing on the line you
// are typing is noise. Backspace at the head still removes it.
// Nested `> > ` gives two adjacent marks, which do not overlap.
if (!committed(node.to)) break;
ranges.push(marks.hide.range(node.from, node.to + 1));
break;
}
case "InlineCode": {
covered.push([node.from, node.to]);
ranges.push(marks.code.range(node.from, node.to));
if (!touches(node.from, node.to)) {
for (const mark of node.node.getChildren("CodeMark")) {
ranges.push(marks.hide.range(mark.from, mark.to));
}
}
break;
}
case "ListItem": {
const listMark = node.node.getChild("ListMark");
if (!listMark || !committed(listMark.to)) break;
ranges.push(marks.listLine.range(state.doc.lineAt(node.from).from));
break;
}
case "ListMark": {
if (!committed(node.to)) break;
const item = node.node.parent; // ListItem
const lineFrom = state.doc.lineAt(node.from).from;
// Whatever the marker is, it fills the column and the document's
// own space follows it, so the text starts at the same x on all
// three kinds of row — and the row wraps under that text.
if (metrics) markerWidth.set(lineFrom, column + metrics.space);
if (item?.parent?.name !== "BulletList") {
// An ordered row keeps its "1." as text; only the column is ours.
ranges.push(orderedMark.range(node.from, node.to));
break;
}
const isTask = item.getChild("Task") !== null;
// Bullets render as glyphs even with the caret adjacent — a fresh
// "- " from Enter shows its bullet before any content is typed.
// Backspace still removes the marker via deleteMarkupBackward.
if (isTask) {
// Nothing here: the TaskMarker case draws "- [ ]" as ONE
// checkbox, so the bullet is inside that decoration.
} else {
// Nesting depth picks the glyph (• then ◦).
let depth = 0;
for (let p = item.parent.parent; p; p = p.parent) {
if (p.name === "BulletList" || p.name === "OrderedList") depth++;
}
ranges.push(
Decoration.replace({
widget: new BulletWidget(depth === 0 ? "•" : "◦", marks.bullet),
}).range(node.from, node.to),
);
}
break;
}
case "TaskMarker": {
const checked = state.doc.sliceString(node.from, node.to).toLowerCase().includes("x");
// One decoration over the whole "- [ ]", not a hidden "- " beside
// a checkbox: the seam between two replaced ranges is a legal
// caret position that renders nowhere, and Backspace in it ate the
// space and took the checkbox with it.
const from = taskMarkerFrom(node.node);
ranges.push(
Decoration.replace({
widget: new CheckboxWidget(checked, marks.checkbox, node.from - from),
}).range(from, node.to),
);
if (checked) {
const line = state.doc.lineAt(node.to);
if (line.to > node.to + 1) {
ranges.push(marks.taskDone.range(node.to + 1, line.to));
}
}
break;
}
case "Link": {
// [label](url) — conceal to just the styled label; reveal raw when
// the selection is anywhere inside. Cmd+click opens (handler below).
const linkMarks = node.node.getChildren("LinkMark");
if (linkMarks.length < 2) break;
covered.push([node.from, node.to]);
const labelFrom = linkMarks[0].to;
const labelTo = linkMarks[1].from;
if (labelTo > labelFrom) ranges.push(marks.link.range(labelFrom, labelTo));
if (!touches(node.from, node.to)) {
ranges.push(marks.hide.range(node.from, labelFrom));
ranges.push(marks.hide.range(labelTo, node.to));
}
break;
}
}
},
});
// Eager styling for unclosed constructs: scan each line's text that isn't
// part of a parsed construct; the first unmatched delimiter styles the
// rest of the line, and (like closed constructs) the delimiter itself
// conceals once the caret leaves the range.
//
// `covered` is in tree pre-order, so already ascending by `from`: `first`
// only moves forward, where filtering it per line was quadratic.
let first = 0;
const firstLine = state.doc.lineAt(rangeFrom).number;
const lastLine = state.doc.lineAt(rangeTo).number;
for (let lineNo = firstLine; lineNo <= lastLine; lineNo++) {
const line = state.doc.line(lineNo);
if (line.length === 0) continue;
if (metrics) {
// Leading whitespace plus the row's marker, if any. A tab counts as
// one space's width — the indent unit writes spaces, so a tab here
// was pasted in and is rare enough to be off by a little.
const indent = /^[ \t]*/.exec(line.text)![0].length * metrics.space;
const hang = indent + (markerWidth.get(line.from) ?? 0);
if (hang > 0) ranges.push(hangLine(hang, column).range(line.from));
}
while (first < covered.length && covered[first][1] <= line.from) first++;
const segments: [number, number][] = [];
let cursor = line.from;
for (let j = first; j < covered.length && covered[j][0] < line.to; j++) {
const [f, t] = covered[j];
if (t <= line.from) continue;
if (f > cursor) segments.push([cursor, Math.min(f, line.to)]);
cursor = Math.max(cursor, t);
}
if (cursor < line.to) segments.push([cursor, line.to]);
outer: for (const [segFrom, segTo] of segments) {
const segText = state.doc.sliceString(segFrom, segTo);
for (const { re, len, deco } of marks.unclosed) {
const m = re.exec(segText);
if (!m) continue;
const delimFrom = segFrom + m.index;
ranges.push(deco.range(delimFrom + len, line.to));
if (!touches(delimFrom, line.to)) {
ranges.push(marks.hide.range(delimFrom, delimFrom + len));
}
break outer;
}
}
}
}
return Decoration.set(ranges, true);
}
// Cmd/Ctrl+click opens the link under the pointer (plain click edits).
const linkClickHandler = EditorView.domEventHandlers({
mousedown(event, view) {
if (!(event.metaKey || event.ctrlKey)) return false;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null) return false;
let node: SyntaxNode | null = syntaxTree(view.state).resolveInner(pos, 0);
while (node && node.name !== "Link") node = node.parent;
const url = node?.getChild("URL");
if (!url) return false;
window.open(view.state.doc.sliceString(url.from, url.to), "_blank", "noopener");
return true;
},
});
/** The live-preview plugin alone, so an element-map change can be swapped
* through a `Compartment` without rebuilding the editor's history. */
export function livePreview(elements?: EditorElements): Extension {
const marks = createMarks(elements);
return [
hangMeasurer(marks),
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = computeLiveDecorations(view.state, view.hasFocus, marks, view.visibleRanges);
}
update(update: ViewUpdate) {
// IME guard: rebuilding decorations mid-composition detaches the
// candidate popup. Map the existing ones through the change instead.
if (update.view.composing) {
this.decorations = this.decorations.map(update.changes);
return;
}
// The tree check is not optional: Lezer parses in time-sliced chunks
// and reports each one as a transaction that changes neither the doc
// nor the selection. Without it, a document whose first parse pass
// ran out of budget — a long one, or a busy page with several
// editors — keeps the decorations of the fragment that WAS parsed and
// shows the rest as raw markdown, at random. `viewportChanged` covers
// scrolling into a new part of the document, which touches none of
// the above but still needs its own now-visible range decorated.
if (
update.docChanged ||
update.selectionSet ||
update.focusChanged ||
update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state) ||
update.startState.field(hangMetrics, false) !== update.state.field(hangMetrics, false)
) {
this.decorations = computeLiveDecorations(
update.state,
update.view.hasFocus,
marks,
update.view.visibleRanges,
);
}
}
},
{ decorations: (v) => v.decorations },
),
];
}
// The same language, minus CommonMark's indented code blocks: four leading
// spaces are what Tab writes here, and a paragraph someone indented to set it
// apart must keep its **bold** and its links rather than turn into verbatim
// code (which also let the eager unclosed-`**` styling run away with the
// rest of the line). Fenced ``` blocks are untouched. Built the way
// lang-markdown builds its own, so no other behaviour changes.
const proseMarkdown = new Language(
markdownLanguage.data,
// Typed as the generic Parser; it is lezer-markdown's, which configures.
(markdownLanguage.parser as MarkdownParser).configure({ remove: ["IndentedCode"] }),
[],
"markdown",
);
/** Everything that is not typography: the markdown language, the keymaps,
* history, the placeholder and the chromeless theme. Pair with
* `livePreview()`. */
export function liveMarkdownBase(placeholderText = ""): Extension[] {
return [
// The keymap below is ours rather than markdownKeymap: its default Enter
// command turns a tight list non-tight on the second empty item (blank
// line + carried bullet) — nonTightLists: false makes double-Enter exit
// the list, removing the empty item's marker instead.
//
// The bare language, not markdown(): that wrapper drags the HTML, CSS
// and JS language packages (plus autocomplete) into a prose editor.
// pasteURLAsLink is the one piece of it this editor uses.
proseMarkdown,
pasteURLAsLink,
atomicTaskMarkers,
keymap.of([
{ key: "Enter", run: exitEmptyQuote },
{ key: "Enter", run: openItemAbove },
{ key: "Enter", run: insertNewlineContinueMarkupCommand({ nonTightLists: false }) },
{ key: "Backspace", run: deleteMarkupBackward },
{ key: "Home", run: cursorTextStart, preventDefault: true },
// Cmd+Left only, mirroring standardKeymap: `Mod-ArrowLeft` is word-left
// off the Mac, and line start is a Mac-only binding there too.
{ mac: "Cmd-ArrowLeft", run: cursorTextStart, preventDefault: true },
]),
linkClickHandler,
hangMetrics,
history(),
// drawSelection() forces `caret-color: transparent` and paints its own
// caret and selection. On a desktop that is an improvement — the native
// caret spans the whole line box and reads oversized. On a phone it takes
// away the things a finger actually uses: the caret you drag, the
// selection handles, the magnifier. The platform keeps its own there.
...(isTouchDevice() ? [] : [drawSelection()]),
// Tab is trapped here for indenting every selected line (Shift+Tab
// outdents); Escape, bound above in blurKeymap, is the keyboard way out.
// Four spaces a step: a nested bullet still nests, and the extra width
// reads as a level in prose type where two spaces barely register. See
// proseMarkdown for why that doesn't turn a paragraph into code.
indentUnit.of(" "),
keymap.of([...blurKeymap, ...formattingKeymap, indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.lineWrapping,
// Only with text to show: the extension writes `aria-placeholder` from
// whatever it is given, and an empty one is an attribute that says nothing.
...(placeholderText ? [cmPlaceholder(placeholderText)] : []),
keyboardAware,
EditorView.contentAttributes.of({
// A textbox has to be named. The placeholder is the caller's own words
// for this field, so it names it when there is one; without it the
// fallback at least says what the field is, where "" left a screen
// reader announcing an unlabelled edit box.
"aria-label": placeholderText || "Markdown editor",
// CodeMirror ships code-editor defaults (spellcheck off, autocorrect
// off, autocapitalize off). This is prose: without these, typing a
// sentence on a phone gives you no capital, no autocorrect and no
// spellcheck, which reads as a broken text field rather than a choice.
autocorrect: "on",
autocapitalize: "sentences",
spellcheck: "true",
}),
chromelessTheme,
];
}