A self-hosted, password-gated, collaborative multi-page whiteboard built on
top of the @excalidraw/excalidraw
React component.
Excalidraw upstream has no pages, and the maintainers have deferred the feature in favour of Excalidraw+ scenes. SketchSpace is not a fork — it consumes the editor as a package and supplies the two things that package does not ship: a page model and a collaboration server you own. The editor itself is vendored from a local excalidraw checkout so your own editor patches come along; see Where the editor comes from.
See NOTES.md for lessons learned and the roadmap.
- Boards hold an ordered list of pages. One tab strip, one link.
- Real-time collaboration on every page, with per-page cursors and board-wide presence.
- Password gate for the whole instance; past it, all boards are shared.
- SQLite + a files directory — one
.dbfile and one folder to back up.
The editor is vendored from a local excalidraw checkout, not installed from npm (see Where the editor comes from), so build it once first:
cd ../excalidraw && git switch SketchSpace && yarn build:packages
cd ../SketchSpace && npm install && npm run sync:editor
cp .env.example .env # then set SKETCHSPACE_PASSWORD
npm run build
npm startOpen http://localhost:3000.
npm start reads .env natively (Node's --env-file-if-exists), so there is
nothing to export. For a deployment, docker compose up --build instead — the
image takes real environment variables and ignores .env.
Keep the data directory out of Dropbox / OneDrive / iCloud. SQLite in WAL mode keeps
.db,.db-shmand.db-walopen together, and a file-sync client will both lock them and sync them out of step with each other — which risks a corrupt database, not just noise. This repo lives in Dropbox, so.envpointsSKETCHSPACE_DATA_DIRoutside it. The Docker setup is unaffected: it uses a named volume.
| Command | What it does |
|---|---|
npm start |
Run the built server, reading .env. |
npm run dev |
Watch mode. Vite on :5173 proxying to the server on :3000 — open 5173, not 3000. |
npm run build |
Build client then server. Refuses to run if vendor/ is missing. |
npm run typecheck |
Both tsconfigs. Run after every sync:editor — see below. |
npm run sync:editor |
Re-vendor the editor from your excalidraw checkout. |
npm run smoke |
End-to-end collaboration suite against a running server. |
After changing server code you must npm run build before npm start.
After changing client code, rebuild and hard-reload the browser
(Ctrl+Shift+R); no server restart is needed,
since the server only serves static files from dist/client.
From any board, open the main menu (☰) and choose Restart SketchSpace or Stop SketchSpace. Both save everything first: pending layout writes and every open page. Anyone signed in can use them, since everyone past the password is trusted alike, so both ask for confirmation.
- Restart starts a new server the same way this one was started, then the
page reloads by itself once the new one answers. The reload also loads a
rebuilt client, so after
npm run buildthis is the whole update. The new server runs in the background with its output in<dataDir>/server.log, even if the old one was started in a terminal. Restart is refused in development mode (npm run devrestarts itself on changes) and in Docker (restart the container). - Stop leaves it stopped. Opening a layout from Blender starts it again, or
run
npm start.
Ctrl+C in its terminal also shuts down cleanly. Killing the process does not:
on Windows, Stop-Process ends it without running its shutdown, so anything
not yet written is lost. Use it only when the server no longer answers. Find it
by port, because the process command line is a relative path, so filtering
on the word "sketchspace" matches nothing:
Get-NetTCPConnection -LocalPort 3000 -State Listen |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }Starting a second instance while one is already up prints that same command
along with an EADDRINUSE message, rather than a stack trace.
scripts/smoke.mjs drives three concurrent clients against a running server and
checks the whole path — auth, scene deltas, conflict resolution, page isolation,
presence, and persistence. Point it at a throwaway data directory and port
so it does not write into your real boards:
npm run build
SKETCHSPACE_PASSWORD=dev PORT=3111 SKETCHSPACE_DATA_DIR=C:/sketchspace-smoke \
node dist/server/index.js &
npm run smoke -- http://localhost:3111 devAll 18 checks should report ok.
The server listens first and adopts its boards afterwards, so the port answers in about a second even on a machine with real projects, while the boards are still being caught up with in the background. The log says how long each took:
listening on http://localhost:3000 (1.5s)
boards adopted in 4.1s
The smoke test waits for the port, so the two commands above are safe to paste together - but if you script anything else against a freshly started server, wait for the port rather than assuming it is up.
The address bar always points at what is on screen:
#/board/<boardId>/<pageId>?v=<x1>,<y1>,<x2>,<y2>
Pan or zoom and it rewrites itself (via replaceState, throttled, so the back
button stays useful). Copy the URL and you have shared the sheet and the region
- "look at this detail on A501" becomes a link rather than a description, which matters on a construction sheet where most of the page is not the thing you mean.
Opening such a link frames that region with fit: "contain". Only the box is
encoded, not a zoom level, so a link resolves to the same content on a laptop
and a 4K monitor rather than reproducing someone else's pixel zoom.
The idea is lifted from first-draft's URL view sharing,
which encodes the viewport as l/t/r/b/z query parameters. Excalidraw's
getVisibleSceneBounds and setViewport({ target }) speak scene-coordinate
boxes directly, so no coordinate maths of our own is involved.
| Variable | Required | Default | Notes |
|---|---|---|---|
SKETCHSPACE_PASSWORD |
yes | — | The single shared password. The server refuses to boot without it. |
SKETCHSPACE_SESSION_SECRET |
strongly | random | Signs session cookies. If unset, everyone is logged out on restart. |
PORT |
no | 3000 |
|
SKETCHSPACE_DATA_DIR |
no | ./data |
/data in the container. |
SKETCHSPACE_SECURE_COOKIE |
no | false |
Set true when behind TLS. |
SKETCHSPACE_PYTHON |
no | python |
A Python with ifcopenshell, to fill view-titles and titleblocks. See Titles and titleblocks. |
SKETCHSPACE_BONSAI_BRIDGE |
no | on | off stops taking those values live from a running Bonsai. |
SKETCHSPACE_BONSAI_WEBUI |
no | found | Where Bonsai's web server records its ports. Found in each Blender version's user folder unless set. |
SketchSpace does not depend on @excalidraw/excalidraw from npm. It vendors a
build from your own excalidraw checkout into ./vendor, so any patch you carry
there — a custom preference, a behaviour fix — is actually in this app. The npm
package only ever contains what upstream has released.
cd ../excalidraw
git switch SketchSpace # our long-lived editor branch
yarn build:packages # after any editor change
cd ../SketchSpace && npm run sync:editorEditor work lives on the SketchSpace branch of
OpeningDesign/excalidraw, a fork of
upstream and that fork's default branch. master there stays a pristine mirror of
excalidraw/excalidraw, so git log master..SketchSpace answers "what is ours?"
exactly. To take upstream changes: git fetch upstream && git merge upstream/master
on SketchSpace, then re-run sync:editor and npm run typecheck.
scripts/sync-editor.mjs copies the built prod and types output of five
packages — excalidraw, common, element, math, fractional-indexing — and
prints the commit it took them from. Set EXCALIDRAW_REPO to use a checkout
somewhere other than ../excalidraw. npm run build refuses to run if the
vendor directory is missing.
Three things are worth knowing about this arrangement:
- Five packages, not one.
scripts/buildPackage.jsupstream marks the four sibling packages as esbuild externals, so the built editor imports them rather than inlining them. (The 0.18.1 npm release did inline them, which is why depending on the published package needed none of this.)client/vite.config.tsaliases each sibling — bare and/*— onto its singleindex.js, matching what each package's own exports map does. - The editor's own dependencies are in
devDependencies. The upstream build runs esbuild withpackages: "external", so every bare import —roughjs,jotai,radix-ui,pako, and ~25 more — has to resolve here. They are copied frompackages/excalidraw/package.jsonand are needed at build time only; the runtime image installs with--omit=dev. - You are tracking master, not a release. That is the point, but it cuts both
ways: upstream can rename things.
excalidrawAPIbecameonExcalidrawAPIin #10870 after 0.18.1, and because the vendoredtypesare what tsc reads,npm run typecheckcatches that class of break at build time rather than in the browser.
vendor/ is gitignored — it is build output. A fresh clone needs an excalidraw
checkout and one sync:editor before it can build.
client/src/Board.tsx passes an explicit <MainMenu> to <Excalidraw>. This
is not cosmetic, and removing it silently costs you features.
When a host app supplies no <MainMenu> child, the editor falls back to
DefaultMainMenu in packages/excalidraw/components/LayerUI.tsx — and that
fallback contains no <MainMenu.DefaultItems.Preferences /> at all. Everything
inside the Preferences submenu therefore renders nowhere: box-selection mode,
snap mode, grid mode, object snapping, and any preference you add yourself in
your excalidraw checkout. The item is present in the shipped bundle and simply
has no route to the screen.
This is easy to misdiagnose as the vendoring having failed. It has not; grep the
built bundle to confirm the feature is there before suspecting sync:editor:
grep -o wheelBehavior dist/client/assets/index-*.js | head -1excalidraw.com never hits this because excalidraw-app/components/AppMainMenu.tsx
supplies its own menu including Preferences. Any embedder relying on the
fallback does hit it.
Scene load/save entries are deliberately omitted from our menu: a page lives on
the server, not in a local file, so "Save to active file" would be misleading.
ToggleTheme uses allowSystemTheme={false} because system theme requires the
host to own theme state, and SketchSpace does not.
Two socket.io rooms per viewer:
board:{boardId} always joined — page list + board-wide presence
page:{pageId} joined/left on switch — scene updates + cursors
Splitting them is what keeps traffic proportional to what you are looking at: someone editing page 1 never receives a packet of page 7's scene updates, but still appears in the presence list and still sees pages appear and get renamed.
Unlike excalidraw.com — where collaboration is end-to-end encrypted and the server is a blind relay — you own this server, so it holds the authoritative element map for every open page. Each incoming batch is reconciled against it and only the winning elements are rebroadcast as a delta.
The reconciliation rule in server/src/reconcile.ts
is a deliberate mirror of Excalidraw's own
(packages/excalidraw/data/reconcile.ts): higher version wins, ties broken by
the lower versionNonce. Keeping the two in agreement is what lets a late
joiner receive a correct snapshot and two simultaneous editors converge — the
piece a naive rebroadcast server gets wrong.
| Table | Holds |
|---|---|
boards |
id, name, timestamps |
pages |
board, name, fractional index, soft-delete flag |
page_scenes |
one JSON element blob per page |
files |
image metadata; the data URLs live in data/files/ |
Open pages are cached in memory, written back on a 1s debounce, and evicted
five minutes after the last viewer leaves. Everything dirty is flushed on
SIGTERM.
Pages carry a fractional index string rather than an integer position, the
same technique Excalidraw uses for element z-order. Inserting between two pages
mints a key strictly between theirs, so two people reordering at once converge
without a coordinator. See server/src/fracIndex.ts.
Images are stored per board, not per page, so one can be moved between pages without re-uploading. They are kept as data URLs exactly as the editor produced them — about 33% larger on disk than raw binary, in exchange for exact round-tripping.
One board per IFC file, one tab per sheet. A drawing set is a board; each sheet is a page. Once a project is imported the two stay in step automatically, in both directions.
Set Bonsai's Layout SVG Command preference to (one line, JSON):
[["node", "--env-file-if-exists=D:/path/to/SketchSpace/.env", "D:/path/to/SketchSpace/scripts/open-layout.mjs", "path"]]bpy.ops.bim.open_layout() then opens the sheet in SketchSpace instead of
Inkscape. Bonsai runs this through subprocess.Popen with no shell, so the
executable must be resolvable by shutil.which, every argument is a separate
list entry, and the literal token "path" is replaced with the layout's path.
The script handles three cases: the sheet is already a tab (opens it), the board exists but this sheet is new (adds a tab), or neither (imports the whole set and creates the board). First use on a project imports every sheet, which can take a minute; it happens once.
It also starts SketchSpace if it is not running, detached, so the button
works from a cold machine - Blender is often the first thing opened in the
morning. That first press takes around fifteen seconds while the server boots;
later ones are instant. The server's output goes to <dataDir>/server.log.
Pass --no-start, or set SKETCHSPACE_NO_AUTOSTART=1, to have it refuse rather
than start anything.
Database work happens first and the server is started after: creating a board while nothing is running is entirely safe, and the server's startup reconcile then sees a finished board rather than one being built underneath it.
npm run import:sheets -- "<project>/Models/Bonsai" # the whole set
npm run import:sheets -- "<...>/layouts/A001 - SITE PLAN.svg" # a single sheet
npm run import:sheets -- <path> --board <boardId> # add tabs to a boardThe board takes its name from the .ifc file beside layouts/, since Bonsai
resolves every drawing path relative to that file's directory.
- Move a drawing here and the layout SVG is rewritten about two seconds after
it settles. A pill bottom-right reads
layout saved. Runbpy.ops.bim.create_sheets()in Blender to rebuild the sheet. - Add a sheet in Bonsai and a tab appears.
- Rename a sheet and the tab follows it, rather than duplicating.
- Delete a sheet and the tab goes — unless you have drawn on it.
- Add, remove or regenerate a drawing and the sheet updates in place, including Bonsai's reflow of its neighbours.
- Edit a linked file — a titleblock, a view-title asset, a drawing redrawn without changing size — and every sheet placing it refreshes.
- Save the model with a renamed sheet or drawing, a new scale, or a new git tag, and the view-titles and titleblocks showing it update — see below.
The directories watched for that last one are derived from the layouts
themselves, not hardcoded and not read from the IFC: each <image href> is
resolved against its layout's directory and the containing directory is watched.
So schedules/ is watched only on projects that place one, and a reference
living in another repo via a git submodule is followed just the same.
Redlines are never written to the layout; they carry no customData.bonsai.
View-titles and titleblocks are Mustache templates — {{Name}},
{{Identification}}, {{Scale}}, a {{#revisions}} table — that Bonsai fills
only when it builds a sheet. SketchSpace fills them the same way, so a tab reads
1 · MY STOREY PLAN · 1/4"=1'-0" rather than its placeholders:
| Template | Values | From |
|---|---|---|
| View-title of a drawing | Name, Identification, Scale, and every Sheet… attribute |
The drawing's reference on the sheet; EPset_Drawing.HumanScale (or NTS). An unnamed drawing uses its file name. |
| View-title of a schedule or reference | The same, less Scale |
Its reference on the sheet. An unnamed one uses the document's name, or Unnamed. |
| Titleblock | The sheet's attributes; the project address; the revisions table; north arrows |
The sheet; the site's address (below); git tags on the IFC's repository; the model's georeferencing |
This follows Bonsai's sheeter.py field for field, down to its quirks: an
unset attribute prints as None, as it does on the built sheet. The revisions
table mirrors Bonsai's git-tag feature — oldest tag at the bottom, dated by the
tagged commit, the first line of its message, the tagger's initials — and is
read with git directly, so it appears whichever Bonsai build made the sheet.
The sheet's site and building. IFC gives a project no address of its own; it belongs to a site or a building, and a model can have several of each. So each sheet says which site and building it is about, and its titleblock shows theirs. One titleblock template serves every sheet.
| Placeholder | From |
|---|---|
{{SiteName}}, {{SiteDescription}} |
The sheet's site |
{{SiteAddress}} |
The site's whole address on one line |
{{SiteAddressLines}}, {{SitePostalBox}}, {{SiteTown}}, {{SiteRegion}}, {{SitePostalCode}}, {{SiteCountry}} |
The parts of that address |
{{BuildingName}}, {{BuildingDescription}}, {{BuildingAddress}}, {{BuildingAddressLines}}, … {{BuildingCountry}} |
The same, for the sheet's building |
A sheet is linked to its site and building in the model itself, by an
IfcRelAssociatesDocument from the sheet to each one. That is ordinary IFC:
it survives merges and re-saves, and goes when the sheet does. Set the links
from SketchSpace's panel (below), or in Bonsai: select the sheet under
Sheets, then pick from the Site and Building dropdowns beneath its
buttons. A sheet with no link uses whatever has a single answer:
- Site: the linked building's site; otherwise the model's only top-level site. The lots of a site complex don't count against it.
- Building: the only building on the sheet's site, or in the model.
Where there are several to choose from and the sheet has no link, those fields
are blank. The alternative is guessing, and showing one building's address
on another building's sheet. A sheet linked to two buildings counts as linked
to none for the same reason. Bonsai's generic Documents panel can create that
by assigning a second one, and IFC keeps the links in no order, so neither is
"first". Both lists then read "2 buildings linked - pick one", and picking any
entry replaces both. Each element shows its own address, so a Revit
export with the address on the building fills {{BuildingAddress}}, not
{{SiteAddress}}. An unset value is blank rather than None, on Bonsai's
built sheet too.
Where the values come from. Two places, in order:
- Live, from Blender, when Blender has the project open. Bonsai answers from the model in memory, so a rename or a new scale shows within a few seconds — before you save. It is Bonsai's own sheet-building code answering, so the values match whatever Bonsai you run.
- The saved
.ifcotherwise, read with IfcOpenShell (server/python/ifc_values.py, run bySKETCHSPACE_PYTHON). This is what everyone without Blender open sees. Unsaved changes appear once saved.
When Blender closes, tabs fall back to the saved file by themselves.
For the saved file, the IFC used is the one whose sheet actually references the layout — project folders often hold merged copies — and the most recently saved if several do. Reads run in the background and are cached under the data directory by file and modification time, so a large model costs its read once. A tab shows raw placeholders until then, and fills in without a reload. Without a usable Python the server logs one warning and everything else works as before. The Docker image does not include one.
Live values need three things:
- A Bonsai that can answer. It needs the
sheetsweb handler and the Keep Web Connection preference — IfcOpenShell#9555, which also carries the editing requests below. It is worth taking IfcOpenShell#9557 with it, which recovers a model reopened after a rename that was never saved. Both are in OpeningDesign's Bonsai build; neither is upstream yet. - Blender connected to Bonsai's web server. In Bonsai's preferences, under Other → Web UI, turn on Keep Web Connection. Blender then connects in the background whenever it runs - starting Bonsai's web server if needed, without opening a browser - and reconnects if the server goes away. It is off by default, and saved with your preferences for that Blender version.
- SketchSpace on the same machine as Blender. Bonsai's web server only
listens on
127.0.0.1. SketchSpace finds it through therunning_pid.jsonfile Bonsai writes, re-read every 10 seconds.
Select a drawing's title or a sheet's titleblock and a small panel appears with the fields its template fills in — a drawing's name and view number, a sheet's number, name and revision. Type a value, press Enter or Save to Bonsai, and it goes to the Blender holding that model, which does the rename properly: a renamed sheet takes its layout and built sheet with it, a renamed drawing its SVG and every layout placing it. The tab then updates itself the way it does for any other change made in Bonsai.
Renaming a sheet from here moves its layout file, and for a moment the Blender holding the model still lists the old name. The panel follows the sheet to its new path and keeps editing it, which needs the right Blender to be identifiable — the one with that project folder open. If a folder holds several models, which happens where merged copies and exports live beside the original, the panel goes read-only until Bonsai sends its sheet list again rather than risk writing your edit into the wrong model. Values come back a few seconds later.
Nothing is written to the .ifc or to the layout from here. Blender keeps the
model in memory, so a write to the file would be invisible to it and lost the
next time it saves. Each save from here is one step in Blender's undo history,
like an edit made in Blender itself, so Ctrl+Z in Blender undoes it. An
edit Bonsai refuses leaves no undo step.
A titleblock that shows site or building fields also gets Site and Building lists at the top of the panel. They set which ones the sheet is linked to. The first entry, Automatic, removes the link and says what the sheet would show without it. Change one and save, and the fields below refill with the new site or building.
The names, descriptions and address parts are editable too. An edit goes to the sheet's site or building, so it shows on every sheet about the same one. Renaming a site or building renames its Blender object too, as Bonsai's own attribute panel does. One with no address gets one. Several address lines are shown and edited as one line. Clearing every part removes the address, because IFC does not allow an empty one. A site and a building that share one address object share the edit as well. A field with no site or building behind it is refused, with a message saying to pick one.
Add to Sheet… in the ☰ menu — or Shift+A — lists what the model can put on this sheet. Type to filter, pick one, and then click where it should go on the sheet — that click is the position, so it is asked for after the choice rather than before. Escape cancels.
Where the file a thing is placed from is not simply its name, the file is shown
beside it. A schedule's document name and its spreadsheet drift apart — one
called THINGER SCHEDULE can live in DOOR SCHEDULE.ods — and Bonsai's own
sheet list names it by the file, so showing both is what makes the same row
recognisable in either tool.
Drawings, schedules and references are all listed, under a heading each and
in that order — the three things a sheet can place, and the three Bonsai has its
own Add … To Sheet command for. The filter matches the kind as well as the
name, so typing schedule narrows to the schedules. A drawing is named to
Bonsai by its IFC GlobalId; a schedule and a reference are documents and have
none, so they are named by the SVG they are placed from.
One already on this sheet, or one that has never been generated, is shown greyed with the reason. Both are refusals Bonsai would make, and a reason given up front beats one after pressing OK. A schedule reads not rendered yet instead, since its SVG comes from its spreadsheet rather than from a camera.
Bonsai does the placing: it adds the sheet's reference and the group in the layout, numbering the view as it would for its own Add … To Sheet. It then reaches the page the way anything Bonsai changes does.
A note says what was placed and then goes. The dialog does not reopen by itself: Shift+A is quicker than any button for filling a sheet, and coming back uninvited is in the way of anyone who wanted one drawing.
If the note reads "Added X, but Bonsai placed it at its own spot", the thing is on the sheet but not where you clicked — Bonsai added it and could not then find the group to move. It is said rather than swallowed because a placement that lands at the next free spot looks close enough to a placement that worked.
Select a drawing and delete it, and it is taken off the sheet in Bonsai too -
bim.remove_drawing_from_sheet, applied to the model Blender has open. Without
that it would not stick: the layout still places the drawing, so the next sync
brings it back.
Only the sheet's reference to the drawing goes. The drawing itself - its annotation, its camera, its SVG - is untouched, and it stays on any other sheet that places it. Put it back with Add Drawing To Sheet in Bonsai.
Undo puts it back. Ctrl+Z restores the drawing to the sheet in Bonsai as well, where it was and with the view number it had. Within a few seconds of deleting, nothing has been sent yet and the undo simply cancels it; after that the drawing is re-added. Either way the sheet ends up as it was.
Undo only puts back what SketchSpace removed, and only for ten minutes. A drawing you removed in Bonsai stays removed, even if its element is briefly still on screen.
With no Blender connected nothing is sent, the drawing returns on the next sync, and the server log says why.
Some fields cannot be typed: a drawing's scale comes from its camera, the
revision table is read from the project repository's tags, and SiteAddress
and BuildingAddress are built from their parts. Those are shown as plain
text rather than a box, with the reason Bonsai gives underneath, instead of
being left out. With no Blender connected the
values still show — they come from the saved file — and the panel says to open
the model to change them.
A rename has to be saved in Blender to stick. Bonsai renames the files the
moment you save from here — the layout, the built sheet, a drawing's SVG — but
the new names only reach the .ifc when someone saves the model. Reopen it
without having saved and it names files that are no longer there: Open Layout
fails and Create Sheets skips the sheet. That is worth knowing here in
particular, because the person renaming is in a browser and the person who can
save is in Blender.
IfcOpenShell#9557
recovers from it by putting the files back under the names the reopened model
uses; without that patch the fix is to rename them back by hand.
The layout file is what SketchSpace shows; the IFC is what Bonsai lists. Adding or removing a drawing writes the layout at once, but the model only keeps it when the IFC is saved — so closing Blender without saving rolls the model back and leaves the layout where it was. The two can end up disagreeing either way round: a layout placing a drawing the model has forgotten, or a model listing one whose group has gone.
Bonsai reconciles both directions when it opens a layout or a sheet, and reports what it changed. Groups the model cannot account for are taken out; references with no group get one back, at the next free spot rather than where it used to sit, because the group that knew the position is the thing that went. Save the IFC afterwards, or the next reopen has the same work to do.
So if a sheet here is missing something Bonsai lists, open that sheet in Bonsai and save. Add to Sheet… will not help: the model still references the drawing, so it is listed as already on this sheet and refused.
A layout SVG records no identity of its own — its root <svg> carries only
id="root" — and Bonsai renames the file when a sheet is renamed. Matching on
path alone therefore reads a rename as a brand new sheet: the old tab is orphaned
and a duplicate appears.
A sheet is instead identified by the set of IFC GlobalIds of the drawings it
places (data-drawing), which survives renaming. That is what lets a rename
rebind the existing tab.
Tab names still come from filenames, so a sheet called A01 - PLANS, SECTIONS
appears as A01 - PLANS SECTIONS — Bonsai strips commas when writing the file.
Only when its layout file is gone and nothing you drew is on it. A tab carrying redlines is kept and reported instead, because redlines exist nowhere else. Deletion is a soft delete, so a wrong call is recoverable from the database.
If a layouts directory suddenly contains no layouts at all, nothing is touched — a disconnected drive or a sync hiccup looks identical to a mass deletion and is far more likely.
- A drawing is written back as its group
transform; a view-title moved on its own, as the title'sx/y. Bonsai'sbuild_drawingscopies each<g>into the built sheet with attributes intact, swapping only the<image>children, so the transform survives the build - and it renders each image at its ownx/ywithin the group, which is what makes a title's offset safe to write. Both are what Inkscape writes for the same drags, and neither touches the foreground coordinates Bonsai re-centres on resize. - The edit is string surgery on one attribute. Re-serialising the XML would reformat the file and destroy the small diff that is the point of layouts.
- Writes refresh the stored baseline. Our own writes are echo-suppressed by
content hash so the watcher ignores them, which means nothing else would
refresh
groupTx; without it every change rewrites the whole sheet. - Nested references are inlined on import. A drawing may reference a raster
underlay relatively, and once the SVG is base64'd into a
data:URL there is no base to resolve it against. - SVG attributes are read by local name. Inkscape rewrites namespace prefixes
inconsistently; matching
xlink:hrefliterally made a sheet that binds the namespace asns3parse to zero placements, silently.
Sibling images of one layout group (foreground + view-title) are bound into an
Excalidraw group so they move together, and the titleblock imports locked,
honouring Bonsai's sodipodi:insensitive. To move a view-title on its own,
double-click into the group and drag just the title; that position is saved too.
Moving a placement edits your project repository within ~2 seconds. git diff
reviews a session; git checkout -- undoes it.
Assets are stored as raw bytes and served from
GET /api/boards/:id/assets/:fileId, not embedded as data: URLs in JSON.
Excalidraw loads images with image.src = dataURL on a plain new Image(), so a
same-origin URL works - and being same-origin keeps the canvas untainted, so PNG
and SVG export still work. Ids are content hashes, so responses are served
immutable: a title block shared by fourteen sheets is fetched once.
On a real set this is the difference between a 26 MB JSON response per sheet and a 1.7 KB one.
What it does not fix: a raster underlay referenced inside a drawing SVG must
still be inlined as a data URL. An SVG loaded through <img> runs in secure
static mode and cannot fetch external resources at all, so an HTTP reference
there renders nothing - the same wall a relative reference hits. One site plan is
19.7 MB for this reason. It is now fetched once and cached rather than re-sent,
but the bytes are still there. Gzipping the asset endpoint and downsampling
underlays at import are the remaining levers.
npm run export:layout -- <boardId> [--dry-run] writes positions from the command
line, and npm run backfill:layout repairs boards imported before write-back
metadata existed. Both refuse to run while the server is up (except
--dry-run, which is read-only): the server caches open pages in memory and would
overwrite a direct database edit on its next flush.
Two halves, with very different value:
| size | replaceable? | |
|---|---|---|
sketchspace.db |
~0.5 MB | No. Redlines, sheet arrangement, which layout maps to which tab |
files/ |
hundreds of MB | Yes — re-importable from Bonsai |
Redlines exist nowhere else. The assets are derived from drawings that live in your own repositories.
npm run backup # to the default location
npm run backup -- --out D:/Dropbox/SS-Backups # anywhere you like
npm run backup -- --timestamped --keep 14 # if you have no versioning
npm run backup -- --assets # mirror the assets too
npm run backup -- --assets E:/SS-Assets # ...or somewhere elseSKETCHSPACE_BACKUP_DIR works too.
The script knows nothing about Dropbox, restic, borg or anything else. Its
only job is to produce a consistent .db at a path you choose; whatever backs
that path up is your business. Point --out at a synced folder and you are done.
It runs against the live server — no downtime. VACUUM INTO produces one
self-contained, compacted file, written under a .partial name and renamed into
place so a backup tool never catches a half-written snapshot. Every run verifies
the result with integrity_check and a row count before reporting success.
Defaults chosen to suit whatever is downstream: one stable filename (Dropbox
versions a path, restic and borg deduplicate against the previous blob, rsync
sends deltas — timestamps defeat all three), no compression (compresses
badly against dedupe; your backup tool does it better), and no retention
unless asked (restic forget, borg prune and Dropbox versions all do it
properly).
files/ is skipped unless --assets is given. It is content-addressed and
written once, so it is safe to mirror live with any tool — but doing it here gets
the ordering right, which matters more than it looks.
The snapshot is taken first and the assets mirrored after. Everything the snapshot references was written before it was taken, so a mirror run afterwards necessarily contains all of it; a few extra assets copied in between are harmless. The reverse order is not safe — an asset written between the mirror and the snapshot would be referenced but missing, and the restore shows a broken image.
The copy never deletes. A filename is a hash of its contents, so a name that already exists is already correct and is skipped, and an asset that an older snapshot still references is never removed just because the current database has stopped using it. First run copies everything; later runs copy only what is new.
# 1. stop the server
# 2. put the snapshot and the assets in one directory
cp sketchspace-2026-09-09.db <restore-dir>/sketchspace.db
cp -r <old data dir>/files <restore-dir>/files
# 3. point the server at it
SKETCHSPACE_DATA_DIR=<restore-dir> npm startThis procedure is exercised, not assumed: a server booted from a snapshot
recovers every board and tab, and assets serve normally. Restore the .db
without files/ and the boards return with broken images — re-importing
regenerates the assets but creates new boards, so redlines would not reattach.
Windows Task Scheduler, running npm run backup daily with the working
directory set to this repo. Missed runs (machine asleep) are caught up on the
next login.
- The password is the only boundary. Anyone who knows it can read, edit, and delete every board. That is the intended model for a small trusted group; it is not multi-tenant.
- No end-to-end encryption. The server reads your drawings — that is the trade that makes authoritative reconciliation possible. Do not put this on the public internet without TLS in front.
- No undo across page switches. Switching pages calls
updateScenewithCaptureUpdateAction.NEVER, so the switch itself is not an undo step, but history is not partitioned per page either. - Cross-page element links are not implemented. Pages are independent scenes.
- No offline mode. A disconnected client keeps drawing locally but its changes are not queued for reconnect.
- The vendored editor tracks master, not a release. That is the point, but
upstream can rename APIs between syncs.
npm run typecheckis the guard; run it after everysync:editor.
SketchSpace was first built in 2012 as a real-time whiteboarding plugin for Etherpad, the core collaboration tool on the OpeningDesign platform. That codebase is preserved at OpeningDesign/SketchSpace_Old_Version.
This is the same idea rebuilt: multi-page, model-aware, version-controlled.
MIT — see LICENSE. The Excalidraw editor it builds on is also MIT.