Usage
React
The wrapper is the quickest way in — render <Paper> and read edits from onChange:
import { Paper, type PaperHandle } from "@beaket/paper/react";
import { useRef } from "react";
function Editor() {
const ref = useRef<PaperHandle>(null);
return (
<Paper
ref={ref}
defaultValue={"# Hello\n\nStart writing…"}
onChange={(markdown) => console.log(markdown)}
/>
);
}The editor is uncontrolled: pass defaultValue for the initial document and call ref.current.setValue(...) to replace it programmatically.onChange fires with the full markdown on user edits only — it is IME-guarded, andsetValue does not echo back through it. There is no internal debounce, so debouncing (e.g. before autosave) is yours to add.
Reading & replacing the document
// Replace the whole document (e.g. after loading from a server).
// Deferred until any active IME composition settles; does not fire onChange.
ref.current?.setValue("# Loaded document\n\n…");
// Read the current markdown on demand.
const markdown = ref.current?.getValue();For anything the curated handle does not expose, ref.current.getView() returns the underlying CodeMirror EditorView as an escape hatch. It returns undefineduntil the view has mounted (e.g. when called from a ref callback), so guard withconst v = ref.current?.getView(); if (!v) return;. See theAPI Reference for the full prop and handle surface.
Controlled value (a bridge)
Paper is uncontrolled by design — a live value prop would fight IME composition and make React state a second source of truth. If you want the React-idiomatic value/onChangeshape anyway, wrap the uncontrolled surface in a thin bridge guarded byvalue !== getValue(). Thecontrolled-value recipehas the full code, the rationale, and the one caveat that keeps the cursor from jumping.
Try it — both panes below share one React value. Type in the editor and the textarea follows; edit the textarea (or hit a button) and the editor syncs.
Selections & highlights
Paper reports the live selection through onSelect — { text, anchor, rect }, or null when empty — and renders a highlights list you own. It draws no toolbar of its own: you place UI from rect and persist the opaque anchor.
Tip — drag a sentence in the editor below to watch the whole loop: select → a toolbar appears → save → click the highlight to activate it.
import { Paper, type SelectionInfo, type HighlightInput } from "@beaket/paper/react";
import { useState } from "react";
function Annotator() {
const [sel, setSel] = useState<SelectionInfo | null>(null);
const [highlights, setHighlights] = useState<HighlightInput[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
return (
<>
{/* Paper reports the selection; you draw the toolbar from sel.rect. */}
{sel?.rect && (
<Toolbar rect={sel.rect} onPick={() => {
setHighlights((hs) => [...hs, { id: crypto.randomUUID(), anchor: sel.anchor }]);
setSel(null); // selection consumed
}} />
)}
<Paper
highlights={highlights} // live prop — re-resolved on change (IME-safe)
activeHighlightId={activeId} // live prop — declarative visual sync
onSelect={setSel} // { text, anchor, rect } | null
onHighlightClick={setActiveId} // a highlight was clicked
/>
</>
);
}The anchor from onSelect is built for exactly this — store it inhighlights and Paper re-resolves it against the document (deferred during IME) on every change, while activeHighlightId syncs the visual emphasis declaratively. The full demo component (toolbar positioning, state) is onGitHub ↗.
Mermaid diagrams
Give Paper a renderer for the mermaid fence language and a ```mermaidblock shows as a diagram when the cursor is outside it, and reveals its raw source when you click in (the usual Live-Preview rule). Paper bundles no renderer — you bring mermaidand hand it the fence body. The whole integration:
import { Paper } from "@beaket/paper/react";
import mermaid from "mermaid";
mermaid.initialize({ startOnLoad: false });
let id = 0;
<Paper
codeBlockRenderers={{
// the key is the fence language; turn its body into DOM — that's the whole API
mermaid: async (code, el) => {
el.innerHTML = (await mermaid.render(`m${id++}`, code)).svg;
},
}}
/>;That is the entire API: codeBlockRenderers maps a fence language to a(code, el) => void | Promise<void> that fills el. (It is not mermaid-specific — the same hook renders KaTeX, ABC notation, or anything you can draw into an element.)
For production you will want two refinements — lazy-load mermaid so its ~3MB only ships when a diagram first appears, and pass ctx.colorScheme into it so diagrams follow light/dark (Paper re-renders the block on a theme flip, and caches per (language, scheme, code) so a flip-back is instant):
// Lazy-load mermaid so its ~3MB only loads when a diagram first appears, and read
// ctx.colorScheme so diagrams follow light/dark — Paper re-renders the block on a flip.
let load: Promise<typeof import("mermaid").default> | undefined;
let id = 0;
const renderMermaid = async (code, el, ctx) => {
const mermaid = await (load ??= import("mermaid").then((m) => m.default));
mermaid.initialize({ startOnLoad: false, theme: ctx.colorScheme === "dark" ? "dark" : "default" });
el.innerHTML = (await mermaid.render(`m${id++}`, code)).svg; // throws on a syntax error
};
<Paper codeBlockRenderers={{ mermaid: renderMermaid }} />;If your renderer throws or rejects, Paper shows the message in place — a mermaid syntax error surfaces for free, and you can catch-and-rethrow a friendlier message of your own (e.g. "Install mermaid to render diagrams"). Languages you do not register stay ordinary code blocks.
Try it — the editor on the intro page wires exactly this; the mermaid fence near the top renders live (click into it to edit the source).
Framework-agnostic core
The core has zero React. Mount it onto any element and it returns a PaperEditor handle:
import { createEditor } from "@beaket/paper";
const editor = createEditor(document.querySelector("#editor")!, {
doc: "# Hello",
onChange: (markdown) => console.log(markdown),
});
// The handle is IME-safe: calls made mid-composition apply once it settles.
editor.setValue("# Loaded document");
editor.setHighlights([{ id: "h1", anchor: { quote: "Hello", offset: 2 } }]);
editor.setColorScheme("dark");
// Tear down when the host unmounts.
editor.destroy();It takes the initial document as doc, plus the options the editor accepts anywhere —onChange, onSelect, onInsertImage, slashItems. What the React wrapper takes as live props, the handle takes as methods —setHighlights, setActiveHighlight, setColorScheme,setReadOnly — and every one of them is held during an IME composition, so a call can never break CJK input. The React wrapper is thin wiring over exactly this handle.getView() returns the underlying CodeMirror EditorView as an unsafe escape hatch with no cross-version guarantees.