A local-first whiteboard and sketching editor. Rectangles, ellipses, diamonds, lines, arrows, text and freehand strokes on an infinite pannable canvas, with multiple named drawings, undo/redo, and PNG/SVG/JSON export.
Live at marcelerz.github.io/draft-pad.
Everything runs in the browser. There is no server and no account: drawings
live in your own localStorage, and sharing is a compressed URL fragment.
npm install
npm run dev # http://localhost:3000/draft-padThe /draft-pad prefix is not a typo: the app is configured for the base path it
is deployed under, so local and deployed builds are identical. See Deploying.
| Script | What it does |
|---|---|
npm run dev |
Development server (Turbopack) |
npm run build |
Static export to out/ |
npm start |
Serve the built out/ at http://localhost:3000/draft-pad |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint |
npm test |
Vitest |
next.config.ts sets output: 'export', so npm run build produces a static
out/ directory that any file host can serve.
Every push to main publishes it to GitHub Pages, from the deploy job in
.github/workflows/ci.yml. That job runs only after typecheck, lint and the
test suite pass, so a red build cannot reach the site.
Because Pages serves this as a project site, the app lives under /draft-pad
rather than at the domain root, and the export has to emit prefixed asset URLs.
next.config.ts sets basePath: '/draft-pad' — that alone is enough. Next
derives assetPrefix from it and Turbopack bakes the same prefix into the
dynamic-chunk loader, so setting assetPrefix separately is redundant.
trailingSlash is not needed either: there is a single route, Pages redirects
/draft-pad to /draft-pad/, and out/404.html covers everything else.
To host it somewhere else, change basePath to that prefix, or to '' for a
domain root.
Drawings do not travel between the two. localStorage is scoped to an
origin, so anything drawn at localhost stays there and the hosted site starts
empty. Use Export → JSON backup and import it if you want to move a drawing
across. For the same reason DraftPad shares an origin with every other project
page under marcelerz.github.io; its keys are all draftpad:-prefixed, which
avoids collisions but is not isolation.
Share links stay private regardless of hosting: the scene rides in the URL fragment, which browsers never send to the server.
| Area | Location | Notes |
|---|---|---|
| Rendering | src/canvas/ |
Two stacked 2D canvases: a static scene layer and an interactive overlay for selection chrome. geometry.ts is shared with the SVG exporter so the two stay in step. |
| Tools | src/tools/ |
BaseTool subclasses dispatched by ToolManager. SelectTool is an explicit state machine. |
| State | src/state/store.ts |
Zustand. History lives in the store behind captureBefore / commit, so one gesture is one undo step. |
| Storage | src/state/storage.ts |
Versioned, lz-string-compressed payloads with a plain-JSON manifest index. session.ts owns the autosave target and its gate. |
| UI | src/components/ |
React 19 and Tailwind 4. |
- Adding an element type means adding a variant to
DraftPadElementand then fixing the three compile errorsassertNeverproduces, inElementRenderer,HitTestandexport.ts. That is deliberate: those switches used to havedefault:arms that silently swallowed new types. - Renderers are pure functions of their arguments. They take
ThemeColorsand viewport state rather than reading the store, which is what makes offscreen and test rendering possible. - Every mutation should go through the store, and gesture-scoped edits
should bracket with
captureBefore/commitrather than snapshotting on each pointermove. - Storage writes never throw. They return a
SaveResult; surface it rather than swallowing it.
npm test runs Vitest against the pure modules — geometry, hit testing,
resize maths, history semantics, scene normalisation and the storage
round-trip, including a throwing localStorage. Rendering itself is not
covered; changes there still want a look in the browser.