Extension Architecture
The editor shell (Editor.tsx) owns only generic state: the document (with undo/redo history), the camera, the current selection, and which tool is active. Everything domain-specific — how a tool reacts to input, how a layer type is inspected, what a dialog shows, what an "add …" button does — lives in a self-registering module discovered at load time. This is the architecture documented in depth in the in-repo packages/; this page is its doc-site counterpart and stays consistent with it.
This is what lets multiple contributors add tools/inspectors/dialogs/add-actions in parallel without merge conflicts: each one is a NEW file, and no existing file is edited to hook it up.
How discovery works
registry/ runs, once, at import:
import.meta.glob('../tools/*.{ts,tsx}', { eager: true });
import.meta.glob('../inspectors/*.{ts,tsx}', { eager: true });
import.meta.glob('../add-actions/*.{ts,tsx}', { eager: true });
import.meta.glob('../dialogs/*.{ts,tsx}', { eager: true });Eagerly importing every file in those four folders executes each file's top-level register*() call as a side effect. Do not replace these globs with a hand-maintained import list, and do not introduce a shared array/switch that every new module must be added to — that shared edit point is exactly what this design removes.
You can verify this yourself: drop a new file into tools/ that calls registerTool(...) and it appears in the toolbar with zero edits anywhere else.
Note
import.meta.glob is a Vite/Vitest build-time primitive — the eager form inlines static imports at build time, so tree-shaking and HMR still work normally. It is not a runtime filesystem scan.
The glob patterns also exclude co-located test files (!../tools/*.test.{ts,tsx}, etc.). This exclusion is required, not cosmetic: a plain *.{ts,tsx} glob would also match *.test.{ts,tsx} files, and eagerly evaluating a test file outside Vitest's own file-collection pass would re-register its describe/it blocks as children of whatever suite happens to be running at the time — a real, order-dependent cross-file leak.
Adding a tool
Create tools/:
import { registerTool } from '../registry/tools';
registerTool({
id: 'my-tool',
label: 'My Tool',
shortcut: 'm', // optional; single key, matched case-insensitively
cursor: 'crosshair', // optional CSS cursor while active
onPointerDown(e, ctx) {
/* e: ToolPointerEvent (screen px + doc mm), ctx: ToolContext */
},
onPointerMove(e, ctx) {},
onPointerUp(e, ctx) {},
onKeyDown(e, ctx) {
// return true to mark handled and stop app-level fallbacks — this is how a
// tool owns Enter/Esc without editing any global keyboard switch
return false;
},
renderDraft(draft, ctx) {
// draw an in-progress preview (e.g. the pen path) on top of the scene.
// draft.inMmSpace(() => { ... }) runs with 1 unit == 1mm, like the layers.
},
onActivate(ctx) {},
onDeactivate(ctx) {},
});Keep any cross-event gesture state (a pen draft, a drag origin) in module scope inside your tool file — there is only ever one active gesture at a time. Call ctx.requestRepaint() when that draft state changes so renderDraft re-runs. See tools/ for the full pattern (hit-test, one-undo-entry gestures via beginGesture + streamed replace, screen↔mm conversion).
ctx (ToolContext) essentials
ctx.doc,ctx.camera,ctx.selectedId,ctx.selectedLayer— LIVE reads (never a stale render closure).ctx.toMm(screen)/ctx.toScreen(mm)— coordinate conversion.ctx.commit(next)— one atomic change = one undo entry.ctx.beginGesture()then streamedctx.replace(next)— a whole drag = one undo entry.ctx.select(id),ctx.setActiveTool(id),ctx.setCamera(next).ctx.openDialog(id, props)/ctx.closeDialog().
Tool descriptions
ToolModule carries an optional description field — a 2–4 sentence, human-readable explanation of what the tool does, its key pointer interactions, and its shortcut. The sidebar Help panel renders it for whichever tool is active.
registerTool({
id: 'my-tool',
label: 'My Tool',
shortcut: 'm',
description:
'Click to do the primary thing; drag to do the continuous thing. ' +
'Modifier notes and the shortcut go here. Shortcut: M.',
// …handlers
});It is a purely additive contract field: it has no default and nothing about tool routing depends on it, so tools that predate it (or don't want a blurb) simply omit it and the Help panel falls back to a generic "No description available" line. Adding the field to an existing tool needs no other change — the same self-registration surface as every other tool property. Keep the prose short and imperative, matching the built-in tools' entries; it is UI copy, not reference documentation.
Adding an inspector
Create inspectors/ and call registerInspector('shape', MyInspector). The inspector receives { layer, onChange, ctx }; call onChange(patch, { commit }) — commit: false while scrubbing a slider, commit: true (the default) for a discrete edit. See Inspectors.
Adding an add-action
Create add- and call registerAddAction({ id, label, icon, run(ctx) }). It shows up on the left toolbar's add section automatically. See Add-actions.
Adding a dialog
Create dialogs/ and call registerDialog({ id, component }). The component receives { props, close, ctx }. Open it from anywhere with ctx.openDialog('my-dialog', props). See Dialogs.
Keyboard routing order
The global handler routes each keydown to the active tool's onKeyDown first. Only if the tool does not return true do the app-level fallbacks run (tool shortcuts, undo/redo, delete, arrow-nudge, Esc). So a tool's Enter/Esc/draft handling stays inside its own module — no shared switch statement to touch when adding a tool. See the full fallback order.
Tip
This page mirrors packages/ in the repo. If the two ever drift, the README is the source of truth — it's read directly by contributors working in that folder.