zudo-panel-designer docs
GitHub repository

Type to search...

to open search from anywhere

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/app/src/editor/README.md; 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/index.ts 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/my-tool.tsx:

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/select.tsx 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.selectedIds, ctx.selectedId, ctx.selectedLayer — LIVE reads (never a stale render closure). See the selection contract.

  • ctx.toMm(screen) / ctx.toScreen(mm) — coordinate conversion.

  • ctx.commit(next) — one atomic change = one undo entry.

  • ctx.beginGesture() then streamed ctx.replace(next) — a whole drag = one undo entry.

  • ctx.select(id) / ctx.selectIds(ids), ctx.setActiveTool(id), ctx.setCamera(next).

  • ctx.openDialog(id, props) / ctx.closeDialog().

The selection contract (selectedIds)

ToolContext.selectedIds is the single source of truth for what's selected — a readonly string[] that is always normalized (selection.ts): de-duplicated, filtered to ordinary leaf/group ids that still exist (fixed container ids are never admitted), and in projected document/tree order rather than click order. So chrome and inspectors always iterate a stable, valid list.

Two derived single-selection views are kept so single-selection tools and inspectors compile and behave unchanged:

  • ctx.selectedId / ctx.selectedLayer — non-null only when exactly one layer is selected. A multi-selection reads both as null.

And two setters:

  • ctx.selectIds(ids) — the multi-selection setter.

  • ctx.select(id) — sugar for selectIds(id === null ? [] : [id]), so tools written against the old single-select API keep working unchanged. This is why the migration to multi-select was purely additive: selectedId/select still mean exactly what they did.

Normalization is applied lazily at read time (inside the ctx getters), not inside selectIds(): a tool often selects a layer it just committed within the same event handler, before the Editor's live doc ref has synced, so filtering eagerly would wrongly drop the fresh id. This mirrors the same staleness rule the other live-ref getters (ctx.doc, ctx.selectedLayer) already follow.

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/my-type.tsx 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-actions/add-thing.ts 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/my-dialog.tsx 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.

Those app-level fallbacks are themselves not a discovered-via-glob registry like the four above — they're commands.ts's flat, hand-listed CommandDef array (tool-switch commands are the one exception, derived live from the tool registry). See Shortcuts & Command Palette for that contract, and how it powers the searchable shortcuts overlay and the command palette.

Tip

This page mirrors packages/app/src/editor/README.md 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.