Change the canvas
Adding to the canvas means answering three questions before writing code: which layer owns the input, what the document records, and what peers see while the gesture is in flight. The three procedures below follow that order.
The module map is in canvas.md. The document rules are in architecture.md.
Add a gesture
Section titled “Add a gesture”Decide who owns the pointer or key first. A gesture that shares a surface with another one needs an owner decided at press time, or the two will both act on the same event.
- Pick the owner. A press on a card, the pane, the marquee, or the wheel belongs to React Flow, so the
handler goes on the
<ReactFlow>element inapps/canvas/src/canvas/board/BoardSurface.tsx(onNodeDragStart,onNodeDragStop,onMoveEnd,onEdgeDoubleClick). A press on a section goes inapps/canvas/src/canvas/sections/SectionLayer.tsx, because a section is not a node. A press on a connector dot goes throughapps/canvas/src/canvas/NodeConnectors.tsx. A key goes inapps/canvas/src/canvas/board/useBoardKeyboard.tsas a named action, not as another branch in the dispatcher. - Read the capability first. Layout writes check
canEditLayout, content writes checkcanEditContent, and creation checkscanCreateContent; the flags come from theuibundle (board/useBoardUi.ts). Returning early on a missing flag is the whole check, because the same flags gate the panel and library paths. - Keep the live value out of the document. While the gesture runs, hold the position, size or path
fraction in the hook’s state or a ref.
board/useBoardRefs.tsis where shared per-gesture refs live. Write through@suite/graphonce, on release, in the command that owns the record (board/useBoardCommands.ts,board/useBoardSections.ts,board/useBoardLinks.ts). - Publish it if a peer has to see it. An in-flight gesture is a presence field, not document data; see the presence procedure below. Publish in flow coordinates, so a peer at a different zoom draws the same thing.
- Add an Escape layer if the gesture can be abandoned.
useEscapeLayerfromapps/canvas/src/shell/escapeLayers.tsregisters the cancel and orders it by when it started; an entry pushed last takes the key first.
Tests:
- Behaviour with React:
apps/canvas/src/canvas/CanvasBoard.test.tsxmounts the board and fires pointer and key events. Add one case for the gesture and one for the capability it needs. - Geometry or a plan: a pure module beside the code, such as
canvas/arrangeCommand.test.tsorcanvas/localEasing.test.ts, withpnpm exec vitest run <path>. - The real pointer path:
e2e/tests/input/for modifier and purpose gestures,e2e/tests/boards/for board and section gestures.e2e/tests/fixtures/marquee.tshas the drag helpers.
Add a node surface
Section titled “Add a node surface”A node surface is a card face with its own chrome. It is still a card, still placed by a placement, and still edited by Tiptap for its text.
- Model the marker in
packages/graph. The existing surfaces use a card type or a property, which is howwhiteboardandboardportals are recognised; a new record class needs a schema change and a decision record first. - Add the predicate beside the others in
apps/canvas/src/cards/.cards/whiteboard.tsexportsWHITEBOARD_TYPE_IDandisWhiteboardCard;cards/portal.tsexportsportalTargetId. - Write the component in
apps/canvas/src/canvas/, next toNoteNode.tsx,PortalNode.tsxandWhiteboardNode.tsx. It receivesCardNodeDatafromcanvas/nodeData.ts: the card, its placement, its type and the derivedmeta. - Register the node type.
nodeTypesis defined incanvas/NoteNode.tsxand extended inboard/BoardSurface.tsx, which is also where the placement chooses it: thetypefield inboard/useBoardNodes.tsmaps a card towhiteboard,portalornote. - Decide how it paints at distance.
canvas/silhouette.tssets the outline andcanvas/surfacePaint.tsresolves the paint roles; a face that ignores the zoom band (canvas/zoomRange.ts) will look wrong at 0.1 scale. - Add a story and a test. A visual surface gets a
*.stories.tsxbeside the component, and behaviour that is not pure paint gets a*.test.tsxsuch asPortalCard.test.tsx.
NodeCard opens a card for editing on double click. Do not move that to a single click: the canvas
interaction tests and the library press both depend on the double-click path.
Add a presence channel
Section titled “Add a presence channel”Presence is one schema with typed fields, not one channel per feature. Adding a channel means adding a field to that schema in five places.
- Declare the payload in
apps/canvas/src/collab/presence.ts: an exported interface in flow units, one entry inAWARENESS_FIELDS, and a field onPeerPresence. Document what it means and which board it belongs to. - Project it in
apps/canvas/src/collab/peerProjection.ts: read the field intoPeerPresence. A gesture that must expire goes inGESTURE_FIELDS, which drives the TTL check, the signature and the clear-out; a field left out of that list survives the sender’s disconnect as a ghost. - Publish it from
apps/canvas/src/collab/usePresenceStore.ts. A gesture that can be abandoned usespublishGesture, which starts the keep-alive beat; standing state such as a purpose usespublish, which does not. - Sanitize anything a peer sends that another client will index or loop over.
sanitizeDraggedNodeIdscaps a dragged set atMAX_DRAG_NODE_IDSand drops malformed entries; a payload that names records needs the same treatment. - Draw it in
apps/canvas/src/canvas/presence/. Peer pointers, selections and marquees are inPresenceLayer.tsx; previews of gestures that would otherwise hide a real edge are inPeerReconnectLayer.tsx. Read peers throughhooks/useBoardPeers.tsso a cursor from another board is filtered out, and draw inside aViewportPortalso the layer shares the camera.
Tests: apps/canvas/src/collab/peerProjection.test.ts covers projection, sanitizing and the TTL, and
apps/canvas/src/collab/usePresenceStore.test.ts covers the publish path. Two clients in
e2e/tests/collab/collab.spec.ts are the only place a real round trip is checked.
Before you open the pull request
Section titled “Before you open the pull request”pnpm exec vitest run apps/canvas/src/canvas/<your test>pnpm --filter @suite/canvas typecheckpnpm biome check apps/canvas/src/canvasnode scripts/check-shape.mjscheck-shape.mjs fails on a second WorkspaceDocument.observe call site. Text and record changes reach
a surface through store/workspaceSnapshotStore.ts, which owns the one subscription and exposes the card
change stream to consumers.