Four nested elements, artboard-space hit testing, cursor-anchored zoom, the tool and drag modes, snapping, keyboard and snapshot history.
Canvas.tsx owns zoom, pan, tools, drag, resize, marquee, drawing, snapping, keyboard and media drops. useStudio.ts owns all state and history.
Four nested elements, and the split between them is load-bearing:
overflow-hidden, owns the wheel and pointer listenerstranslate(-50%,-50%) translate(panX, panY)width*zoom × height*zoom, painted by slidePaint. Selection chrome, handles and the marquee are children here, in screen spacewidth × height with transform: scale(zoom). LayerViews, safe zones and snap guides live here, in artboard spacetoBoard(clientX, clientY) = (client - boardRect.left) / zoom. Everything past that point is artboard pixels.
Every layer is pointerEvents: "none". All hit testing is done in artboard coordinates by Canvas against geometry.ts, never by DOM events. Only a contentEditable being edited re-enables pointer events.
| Control | Behaviour |
|---|---|
| Fit | min((hostW - 120)/w, (hostH - 120)/h), clamped [0.05, 2] |
| Wheel | zooms — inverted from the browser default. ctrl/meta/shift pans. Step ×1.12, clamp [0.05, 4] |
| Keyboard | Cmd+= / - / 0, step ×1.2 |
Zoom is cursor-anchored: pan is recomputed so the artboard point under the pointer stays put. The wheel handler is registered once with { passive: false } and reads zoom through a ref, because the handler from whichever render installed it would otherwise hold a stale value.
A ResizeObserver on the host re-runs fit, which discards the user’s pan and zoom on any host resize.
Tool = select | text | rect | ellipse | triangle | line | icon.
Pointer-down with a draw tool creates a 1×1 layer and enters draw mode. On release, anything still ≤4px in either axis was a click rather than a drag and gets a default size: text 640×120, line 400×8, everything else 240×240. Text then enters editing. The tool resets to select — draw tools are single-shot.
Hit testing runs topmost first and requires visible && !locked. Shift toggles membership. Clicking an already-selected layer preserves the whole selection, so multi-drag works.
| Mode | Behaviour |
|---|---|
move | Snaps unless Alt is held; positions rounded |
resize | Shift constrains aspect. No snapping. Every selected layer re-derives from its captured origin rect |
marquee | Selects everything intersecting — including locked layers, unlike click |
draw | Normalised, clamped to ≥4 |
pan | Space held, or middle mouse button |
SNAP_PX = 6 screen pixels, divided by zoom before reaching geometry.snap.
Targets per axis: artboard 0, midpoint and full extent, plus every other rect’s start, mid and end. Moving edges are the moving box’s start, mid and end. Tolerance is strict <, so exactly-at-tolerance does not snap.
Guides only appear when a correction is actually applied. A box already perfectly aligned produces dx = 0 and therefore no visible guide.
Window-level, skipped while typing (INPUT, TEXTAREA, contentEditable).
| Key | Action |
|---|---|
Space | Pan (checked before the typing guard) |
Cmd+Z / Shift+Cmd+Z | Undo / redo |
Cmd+D / Cmd+A | Duplicate / select all on slide |
Cmd+] / Cmd+[ | Forward / backward |
Delete / Backspace | Remove selection |
Escape | Clear editing, clear selection, tool → select |
Enter | Edit text (exactly one text layer selected) |
v t r o l i | select, text, rect, ellipse, line, icon |
| Arrows | Nudge 1px, 10px with Shift |
There is no shortcut for the triangle tool.
Snapshot-based: commit(doc, coalesceTag?) pushes the previous Doc onto past. LIMIT = 120 (the array peaks at 121). Undo covers everything — layer edits, reorders, slide operations — with no command class per action.
Coalescing suppresses a push when the tag matches the previous one, so a drag is one undo step rather than sixty. It has no time or gesture boundary — see the defect list.
commit is also where auto-naming happens: a document whose name is one the app supplied takes its name from the first text layer on slide 1. A name you chose is never overwritten.