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.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 streamed ctx.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/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.

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.