zudo-panel-designer docs
GitHub repository

Type to search...

to open search from anywhere

Tools

Tools are the modules that interpret pointer and keyboard input on the canvas. Each one is a self-registered ToolModule (see Extension architecture) discovered from packages/app/src/editor/tools/. This page documents the five built-in tools and the event-routing contract every tool implements against.

Built-in tools

ToolShortcutCursorSummary
SelectVdefaultThe full multi-selection tool: click/marquee-select, move, rotate, resize, and edit path anchors/handles.
PenPcrosshairDraw bezier paths anchor by anchor; close into a filled shape or finish as an open stroke.
TextTtextClick the canvas to drop a new text layer, then hand off to Select.
PanHgrabDrag to pan the camera. Also reached transiently by holding Space in any other tool.
ZoomZzoom-inClick to zoom in anchored at the pointer; Alt-click to zoom out.

Select (V)

tools/select.tsx is the reference tool — it exercises every part of the tool contract and is the file to copy when building a new one. It is a full multi-selection vector-editing tool: selecting, moving, rotating, resizing, and path-anchor editing all live in this one file. Its Help-panel description is the short version; this is the full vocabulary. Which gestures apply to which layer type is governed by the layer eligibility matrix below.

On pointer down it resolves grabs in a fixed precedence, each winning over the next: a path node/bezier handle on the current selection (tryGrabNode — within 7px for anchors, 6px for handles) → the rotate knob (tryGrabRotateHandle) → one of the 8 resize handles (tryGrabResizeHandle) → a corner handle of a multi-selection's combined box (tryGrabMultiResizeHandle) → otherwise a top-down hit-test (topmostHit) that selects, toggles, or — on empty space — starts a marquee.

Selecting

  • Click a layer to select it (and begin a move); click empty space to deselect.

  • Drag on empty canvas rubber-bands a marquee. Its semantics are intersection, not containment: any layer whose (rotation-aware) bounding box overlaps the rectangle is selected. Bounds come from the renderer's canonical layerBbox, so the marquee and the on-screen chrome agree to the pixel. The marquee only materializes once the pointer passes the 4px drag threshold, so a plain click never flashes one.

  • Shift-click adds a layer to (or removes it from) the selection; Meta/Ctrl-click toggles exactly one layer. A modifier held while marqueeing unions the marquee hits with the down-time selection.

  • Hover outlines the layer under the cursor with a subtle solid outline — weaker than the dashed selection chrome, so it reads as "would select". It clears when the pointer leaves the canvas.

  • Pattern squares hit-test last, and never join a marquee. A pattern layer is a movable square object (see Pattern squares below) that only wins a click when no other layer is under the pointer, and a marquee drag never picks one up even when it overlaps — see Pattern squares for the full two-tier rule.

Moving

  • Drag a selected layer to move it; dragging any member of a multi-selection moves the whole selection as one gesture. Movement grid-snaps to 0.1mm and snaps to guides when a same-axis guide is within range.

  • Alt-drag duplicates instead of moving: the clone is sampled the instant the drag crosses the threshold, the originals stay put, and the selection follows the clones. An Alt-click that never crosses clones nothing.

  • Shift while dragging constrains movement to the dominant axis, re-evaluated live so the drag can flip axes without releasing.

  • Arrow keys nudge the selection (see the keyboard fallbacks).

Resizing & rotating

  • Single resize — with one shape or image selected, drag its 8 handles. A rotated shape resizes too (the only type that both rotates and resizes): the handles ride the rotated corners and the drag resolves in the layer's local frame via resizeRotatedRect (@zpd/core). Axis-aligned resizes grid- and guide-snap; a rotated resize keeps float hygiene only, because independently snapping a rotated rect's edges would visibly detach the anchored corner.

  • Rotate — a shape or text layer wears a round knob on a stem above its top edge; drag it to rotate about the bbox center. Shift snaps to 45° steps, measured from where the gesture began.

  • Multi-resize — with more than one layer selected, the combined box wears corner handles only. Dragging one scales the whole selection uniformly about the opposite corner; Alt re-anchors the scale to the center. The scale is uniform by construction, so Shift is a deliberate no-op here (patterns in the selection stay put — see below).

Pattern squares

A pattern layer is a positioned square object — x/y/size in mm, the pattern drawn inside (x,y)(x+size,y+size) — not an implicit whole-panel fill. It behaves like an image layer for move purposes, with one deliberate twist for how it's picked up:

  • Cover-by-default placement. A freshly added pattern (or one whose panel size just changed) is sized and centered to fully cover the panel at any aspect ratio — side = the larger panel dimension, patternCoverGeometry() (@zpd/core). Changing the panel's HP does not re-run this automatically, so the square can stop covering the panel; the inspector's Cover panel button (see Inspectors → Pattern) re-centers and re-sizes it back to covering on demand.

  • Two-tier hit-test. A pattern square only wins a click when no other layer is under the pointer — it never steals a click from a shape/text/path/image stacked on top of or below it in z-order. It also never joins a marquee, even when the marquee rectangle overlaps it: dragging over an unselected pattern square draws a marquee exactly as if empty canvas were there, so a cover-sized square never blocks rubber-band-selecting whatever's on top of it.

  • Selecting one still takes a click. A press-then-release on an unselected pattern square that never crosses the drag threshold (toggle-)selects it, same as any other layer — the marquee-exclusion above only changes what a drag does, not what a click does.

  • A selected pattern drags like any layer. Once selected (by that click, or from the layer list), the next drag on it moves it — grid-snapped, guide-snapped, part of a multi-selection drag like anything else. There are no resize/rotate handles on a pattern square, though: change its size from the Properties panel or the Cover panel reset instead.

  • Excluded from multi-resize, rotation, and align/distribute. A pattern square is unaffected by a multi-selection's uniform scale (see above), has no rotation field, and is excluded from Align & Distribute — the same "no independent resizable rect the group math can act on" reasoning as elsewhere on this page.

  • Arrow keys move it too. Nudging the selection (below) translates a pattern square's x/y the same as any other movable member — it is not pinned.

  • The off-panel part ghosts. Drag a pattern square partway off the panel and, with Show content outside the panel on, the part outside the panel edge renders as a dimmed ghost like any other layer's overflow — see Rendering & Camera → The off-panel ghost pass.

  • Copyable, cuttable, duplicable, pasteable — but not Select-All'd. Clipboard treats a selected pattern square like any other layer for Copy/Cut/Duplicate/paste; only ⌘/Ctrl+A Select All deliberately skips patterns, so "select everything" never drags a background-ish cover square along for the ride.

Editing path anchors

With a single path selected, drag its anchors to move them (grid-snapped) or its bezier handles to reshape the curve; holding Alt while dragging a handle breaks the mirror so the two sides move independently. This is why path has no box-resize — you reshape it by its anchors instead.

Gestures & history

Dragging streams the change via ctx.replace() and opens an undo entry (ctx.beginGesture()) lazily — only once the change is actually non-zero. A pure click, or a sub-snap jitter that nets zero effective change, leaves history untouched; an Alt-duplicate opens the entry immediately, since inserting the clone is itself a real change. pointerUp/onDeactivate both clear all drag state, so a tool switch mid-drag can never leave a stale gesture open.

Clipboard and align/distribute

Two more selection-driven operations live outside this file, as their own modules, but act on exactly the selection Select maintains: Clipboard (⌘/Ctrl+C/X/D/A, plus paste) and Align & Distribute (the sidebar panel, or the command palette's chordless Align commands). The two disagree on patterns, deliberately: Copy/Cut/Duplicate/paste treat a selected pattern square like any other layer (only Select All skips patterns — see Pattern squares above), while Align & Distribute excludes pattern layers entirely, the same eligibility rule this page's layer eligibility matrix documents for multi-resize/rotate.

Pen (P)

tools/pen.tsx builds a bezier path one anchor at a time:

  • Click appends a grid-snapped corner anchor.

  • Click-drag on the anchor just placed pulls out mirrored hout/hin bezier handles, turning it into a smooth anchor.

  • Click near the first anchor (path needs ≥3 anchors) closes the path into a filled shape in the Copper container (fill enabled, no stroke).

  • Enter finishes the path open instead (a stroke, no fill) — needs ≥2 anchors.

  • Esc cancels the in-progress draft.

The whole multi-click gesture never touches ctx.doc until the path is finished — unlike Select's live-dragging, nothing is mid-flight in the document, so finishing collapses into exactly one ctx.commit() (one undo entry). A floating hint bar (its own self-mounted React tree, since the tool contract has no chrome slot) shows the available actions and their enabled state. The in-progress path is drawn by the tool's renderDraft hook, not by the main renderer.

Text (T)

tools/text.tsx is intentionally the smallest tool: a pointer-down creates a TextLayer at the click point (default content "TEXT", size 6mm) in the Silkscreen container, commits it, selects it, and immediately switches the active tool back to select so the freshly placed text is draggable/resizable/editable right away.

Pan (H / Space-drag)

tools/pan.tsx drags the camera's screen-px offset. It's also reached without switching tools: holding Space while any other tool is active makes the Editor route pointer events to pan instead (see Space-drag override below), so a user can pan mid-edit without losing their place in, say, an in-progress pen path.

Zoom (Z)

tools/zoom.tsx zooms by a fixed factor (1.5× in, 1/1.5× out) anchored at the click point via camera.zoomAt — the mm point under the pointer stays stationary. Alt-click zooms out instead of in.

Layer eligibility matrix

The Select tool's gestures are defined against zpd's actual document model, which is narrower than a general-purpose vector editor's — not every gesture applies to every layer type:

Layer typeCanvas clickCanvas marqueeMove / nudgeSingle resizeRotateMulti-resize (uniform)
shapeyesyesyesyes (incl. rotated — the only such type)yesyes
textyesyesyesno (no independent width/height)yesyes (scales sizeMm)
pathyesyesyesno (edit anchors instead)no (no rotation field)yes (scales points + handles)
imageyesyesyesyes (never rotated)no (no rotation field)yes
patternyes, but last (two-tier — see below)noyesno (resize via inspector fields, not handles)no (no rotation field)no
  • Patterns click-select but never marquee-select. A pattern layer's x/y/size square hit-tests after every other layer type — a click only lands on it when nothing else is under the pointer — and it is entirely excluded from marquee/rubber-band selection, so a cover-sized square can't swallow every rectangle drag. Once selected it moves/nudges like an image; there's no resize/rotate handle for it, but the inspector's x/y/size fields (or its Cover panel reset) are. See Select → Pattern squares above for the full interaction rule.

  • Rotation is single-selection, shape/text only. Only those two types carry a rotation field, so the rotate knob is offered only for them; path/image have no rotation to set and the chrome must not invent one. Rotating a whole multi-selection isn't representable for path/image either, so there is no group rotate.

  • Multi-resize is uniform scale, corner handles only, Shift a no-op. Non-uniform scaling of a rotated rect isn't representable in the x/y/width/height/rotation model (it would need a shear), and text has no independent width/height (only sizeMm) — so a group scale can only be uniform. This is a scope reduction forced by the model, not by taste.

  • shape is the only type that both rotates and single-resizes. ImageLayer has no rotation field at all, and text has no independent width/height — so rotation-aware resize math has exactly one real consumer: a rotated shape.

Discoverability: tooltips & the Help panel

Two surfaces make a tool's purpose legible without opening the docs — both fed straight from the ToolModule data:

  • Toolbar tooltips. Each toolbar button (components/toolbar.tsxChromeButton) shows a hover tooltip built from the tool's label and shortcut — e.g. Select (V), Pen (P). The tooltip text is also the button's aria-label, so it is the button's accessible name too. It appears on hover after a short delay and, because it keys off :focus-visible (not plain focus), a mouse click that leaves the button focused doesn't leave the tooltip stuck open. Add-action buttons get the same treatment from their label (they have no shortcut).

  • The sidebar Help panel. The pinned Help footer (components/help-panel.tsx) shows the currently active tool's name, a shortcut badge, and its longer description — a 2–4 sentence explanation of what the tool does, its key pointer interactions, and its shortcut. Each built-in tool supplies one via the optional ToolModule.description field (see Extension architecture → Tool descriptions); a tool that omits it falls back to a generic "No description available" line. Because the panel follows activeToolId, holding Space for a transient pan does not swap the shown description.

The pointer/keyboard event contract

The Editor shell (Editor.tsx) owns exactly one rule for routing input: the active tool gets first refusal, then app-level fallbacks run.

Pointer events

Every canvas pointer event is normalized into a ToolPointerEvent (screen px relative to the canvas, the corresponding document mm, button/buttons, modifier keys, pointerId) and handed to onPointerDown / onPointerMove / onPointerUp / onDoubleClick on whichever tool is currently effective.

Space-drag override

The effective tool is normally activeToolId, but while Space is held it is forced to 'pan' regardless of what's active — so pan is available as a momentary override from any tool without changing activeToolId itself. Releasing Space (or a buttons === 0 move, which catches Space being released mid-drag) restores the previously active tool.

Keyboard events

On keydown, the shell first calls the active tool's onKeyDown(e, ctx). If — and only if — that returns true, routing stops there: the tool has claimed the key and no app-level fallback runs. This is how Pen owns Enter/Esc for finishing/cancelling its draft without the shell needing a shared keyboard switch that every tool would otherwise have to edit.

When the tool does not return true, the shell runs its fallbacks in this order:

  1. ⌘/Ctrl+Z (undo) / ⌘/Ctrl+Shift+Z (redo).

  2. A single-key tool shortcut (V/P/T/H/Z, case-insensitive) — matched only when no modifier key is held, so it never fires ahead of the undo/redo check.

  3. Esc — deselect.

  4. Delete/Backspace — delete the current selection (every selected layer, as one undo entry).

  5. Arrow keys — nudge the selection by 0.1mm, or 1mm with Shift held. Every movable member takes the same delta so the selection translates as a rigid unit; shape/text/image/pattern layers all translate via their x/y origin (a pattern square moves like an image's — see Pattern squares above), and path layers translate their whole point set.

Keyboard input is ignored globally while an <input>/<textarea>/<select> is focused (e.g. the layer-rename field, an inspector's number field), so typing "V" into a text box never switches tools.

Tip

When building a new tool, onKeyDown returning true is the only way to intercept a key before the shell's fallbacks — there is no separate priority list to edit. See Adding a tool.