Scratchpad: Refactoring a Canvas Engine to Its Core
Scratchpad is a real-time collaborative drawing canvas — multiplayer strokes over a Go WebSocket server, a Paper.js vector engine on the client, and a gesture-recognition layer that snaps freehand input into clean shapes. The full picture lives on the project page. This entry is about the un-glamorous work that made the rest sustainable: the last stretch wasn't features, it was carving the canvas engine down to a modular core.
Why refactor, not ship
Canvas code rots toward god-objects. A single `CanvasTools` module had accreted state, color management, debug helpers, selection, image import, persistence, viewport math, and an SVG overlay — all tangled together. New tools meant editing the monolith and praying. So the goal was structural: extract cohesive units, delete what no longer earns its place, and make the boundaries (viewport / tools / drawing / sync) legible again.
What changed
- Decomposed the monolith. Pulled `canvasState`, `canvasColors`, and `canvasDebug` out of `CanvasTools`, then extracted `useCanvasSelection`, `useCanvasImageImport`, and dedicated composables for persistence, viewport, and the SVG overlay. The canvas core is now a set of focused composables instead of one file.
- Split shape detection per shape. `shapeDetection.ts` became per-shape modules — each detector isolated, testable, and independently improvable.
- Consolidated utilities. Merged `utils.ts` into `lib/canvas/paperUtils.ts` and unified duplicated date formatters into a single `utils/dateFormat`. One home per concern.
- Deleted dead subsystems. Removed the legacy OCR system, an unused `useScreenLog` composable, orphaned components, and unused dependencies — plus the experimental gesture/Gemini scaffolding that never made the cut. Scope boundaries (no OCR/ML/handwriting) are now reflected in the code, not just the docs.
- Extracted the gesture surface. Lifted `useGestureCollection`, `useGestureMetrics`, and a `GestureTable` component out of the gestures pages so recognition logic stops living in view code.
A real bug, not just tidying
Refactoring surfaced a genuine defect: an inverted center tangent in the Bézier curve fitting. Freehand strokes fit to curves via tangent estimation, and a flipped sign at the center control point bent smoothed strokes the wrong way. Fixed — smoothed curves now follow the input. (Also retired the removed `HandwriteTool`, defaulting the toolbar to the Diagram tool.)
Where it stands
Live at pad.exesiv.dev. The engine is leaner, the layers are separable, and adding a tool no longer means surgery on a 1,000-line file. Dead weight — OCR, unused ML experiments, orphaned utils — is gone.
Next steps
- Land tests against the new per-shape detection modules now that they're isolated.
- Tighten the WebSocket reconnect/resync path so a dropped client rejoins to the exact current canvas, not a stale snapshot.
- Document the gesture-set training flow end-to-end (define → wizard → score) for new users.
Looking forward
The bet behind a cleanup-heavy stretch is leverage: a canvas app earns its keep at the seams between viewport, tools, drawing, and sync. With those seams clean, the interesting work — better gesture recognition, richer real-time collaboration — gets cheaper to build instead of more expensive. Scratchpad is now in a state where features add value without adding entropy.