- JavaScript 84.5%
- CSS 8.2%
- HTML 6.8%
- Dockerfile 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| lib | ||
| public | ||
| test | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| server.js | ||
Whiteboard
A multi-user infinite whiteboard that runs in the browser. Create as many boards as you like; on each one, draw, write text and paste or upload images together in real time, and zoom in or out without limit.
npm install
npm start # http://localhost:3000
The home page lists every board, with when it was last edited and who's on it
now. You can create, rename and delete boards there. Each board has its own URL
(/b/<id>): share it with others on your network (HOST=0.0.0.0 npm start)
and everyone on that link edits the same board.
The address bar also keeps track of where you are on the board (/b/<id>#x=…),
so copying it, or using the link button next to the zoom controls, shares your
exact view. Whoever opens it lands on the same spot at the same zoom; if their
window is smaller than yours, it zooms out just enough to show everything you
could see. A board link without a view opens where you last left that board.
Boards are saved to disk as they're edited, within half a second of each change:
data/
boards.json index of boards: names, created/edited times
boards/<id>.json one file per board, holding its elements
uploads/ images, shared by all boards (content-addressed)
Every write goes to a temp file first and is then renamed into place, so a
crash can't leave a half-written board. A board file that's missing from the
index is recovered at startup. A data/board.json from the earlier
single-board version is migrated into a board called "Whiteboard".
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP / WebSocket port |
HOST |
all | Interface to listen on |
DATA_DIR |
./data |
Where the board and images are kept |
Deploying with Docker and Traefik
compose.yml runs the whiteboard behind an existing
Traefik instance, served over HTTPS with a Let's Encrypt
certificate. It assumes Traefik:
- uses the Docker provider,
- is attached to an external Docker network (default
traefik), and - defines a Let's Encrypt certificate resolver named
letsencrypt.
cp .env.example .env # set WHITEBOARD_HOST, and the network/entrypoint if yours differ
docker compose up -d --build
| Variable | Default | Purpose |
|---|---|---|
WHITEBOARD_HOST |
whiteboard.localhost |
Hostname Traefik routes to the app |
TRAEFIK_NETWORK |
traefik |
External network shared with Traefik |
TRAEFIK_ENTRYPOINT |
websecure |
Traefik entrypoint (HTTPS) |
Boards and uploads are kept in the whiteboard-data volume. No port is
published on the host: all traffic goes through Traefik, and the realtime
WebSocket connection works through it with no extra config. The app has no
login, so if it's reachable from the internet, add a Traefik auth middleware
to the router (for example basicauth or forwardauth).
Using it
| Action | How |
|---|---|
| Draw | Pen tool (P) |
| Erase | Eraser tool (E) removes whatever it touches |
| Text | Text tool (T), click to place; Esc or click away to finish |
| Images | Image button (I), paste (Ctrl+V), or drag files onto the board |
| Select / move / resize | Select tool (V); drag the corner handle to resize, Delete to remove, double-click text to edit |
| Wrap text | Drag a text box's corner handle: the words rewrap to the new width; the text size stays the same |
| Text size | The A menu in the toolbar sets the size of new text, or of the selected text |
| Recolor | Select an element, then pick a color |
| Pan | Pan tool (H), hold Space, middle/right drag, two-finger scroll, or drag empty space with Select |
| Zoom | Mouse wheel, trackpad pinch, Ctrl+scroll, + / -, two-finger pinch on touch |
| Back to origin | Crosshair button or 0 |
| Rename the board | Click its name (top left), or the pencil on the home page |
| Back to all boards | Grid button (top left) |
| Light / dark mode | Theme button (top right, or on the home page): System → Light → Dark |
| Undo / redo | Ctrl+Z / Ctrl+Shift+Z (your own changes) |
| Find a collaborator | Click their avatar (top right) to jump to their view |
| Share your exact view | Copy the address bar, or the link button (top left) |
| Rename yourself | Click your own avatar |
How it works
Server (Node + ws):
server.js: HTTP routes. It serves the pages, accepts image uploads (PNG/JPEG/GIF/WebP, ≤ 20 MB), and provides the boards API:GET/POST /api/boardsandGET/PATCH/DELETE /api/boards/:id.lib/sync.js: one realtime room per board over/ws?board=<id>. It relays board operations:add,update,append(points added to an in-progress stroke) anddelete, plus cursor and view presence. The server holds the authoritative state and validates every operation (lib/validate.js). New clients receive a full snapshot. Concurrent edits to the same element resolve last-writer-wins, which is plenty for ~10 people sketching together.lib/boards.js: storage. A board is loaded into memory when its first user joins, and saved and unloaded after the last one leaves.
Client (public/, plain ES modules, no build step): a single <canvas>
redrawn on demand. Edits apply locally right away and are sent to the server.
Live strokes stream in 40 ms batches, so other users see you draw as it happens.
Dark mode
Each viewer picks their own theme (saved in their browser; "System" follows the
OS setting). Board colors are shared, so they're always stored as drawn in
light mode. In dark mode the canvas shows dark inks with their lightness
mirrored (black ink appears near-white, navy appears light blue). Hue and
saturation are kept, colors that are already light are left alone, and images
are never altered (darkInk in public/theme.js). Everyone sees readable ink
whichever theme they use.
Unbounded zoom
Ordinary 64-bit floats run out of precision after zooming in about 2^50×, at
which point drawings start to jitter and snap. To avoid that, world positions
are arbitrary-precision BigInt fixed-point numbers (value × 2^-precision).
The zoom level is stored as a log2 exponent, and the camera's precision grows
as you zoom in (public/coords.js).
Every element stores its origin in that format, along with the zoom level it was created at. Its own geometry is kept in ordinary floats relative to that origin. Rendering subtracts the camera position from the origin exactly in BigInt, and only converts the small screen-space result to a float. So a sketch drawn at 10^3000× magnification is just as crisp as one at 100%, and the tests check sub-pixel accuracy after zooming in 2^10000×. The dot grid uses power-of-two spacings that cross-fade, so you can always see the zoom moving.
Tests
npm test
Covers the coordinate math (deep zoom in and out, panning, moving) and the server: the boards API, isolation between boards, relay and snapshots, validation, presence, uploads, saving while editing, unloading and reloading, restarts, and migration.
Limitations
- No authentication: anyone who can reach the server can see, edit, rename and delete every board. Put it behind a reverse proxy with auth for anything public.
- Deleting a board is permanent. Its uploaded images stay in
data/uploads/, since other boards may use them too. - Undo covers your own changes and is lost on page reload.
- Edits made while disconnected are discarded; the UI only allows panning and zooming until the connection returns.