Figma-style spatial canvas. The components editor has exactly one mode: canvas. There is no separate "drilled-in single-component" view. "Focusing" on one component is a sub-state of canvas called isolation — same surface, just with the …
Figma-style spatial canvas. The components editor has exactly one mode: canvas. There is no separate "drilled-in single-component" view. "Focusing" on one component is a sub-state of canvas called isolation — same surface, just with the other components hidden.
TbBoxModel2) puts you here. Two sub-states:
props.type === "component" containers visible at once, positioned by their saved props.custom.canvasPos. Drag handle above each shows the name + grip + an "isolate" icon (TbFocus2). Toolbar top-right: component count, add label / add title, zoom.Entry points to isolation:
componentConverters.tsx, formerly CloneHelper) — also switches to canvas if you were in page modeComponentSelector dropdownComponentSelector or EditorEmptyState — creates the component and opens it in isolation| Concern | File |
|---|---|
| View mode union | packages/sdk/src/utils/atoms.ts — EditorCanvasViewMode = "page" | "preview" | "canvas" |
| Canvas isolation atom + ROOT-visibility helper | packages/sdk/src/utils/component/componentIsolation.ts — CanvasIsolateAtom, applyCanvasVisibility |
| Canvas singleton + helpers | packages/sdk/src/utils/component/componentCanvas.ts |
| Pan/zoom hook | packages/sdk/src/chrome/hooks/useCanvasPan.ts |
| Canvas viewport (overlay shell + isolation pill) | packages/sdk/src/chrome/viewport/ComponentCanvasViewport.tsx |
| Per-component slot + drag handle + isolate button | packages/sdk/src/chrome/viewport/ComponentCanvasItem/ |
| Floating text annotations | packages/sdk/src/chrome/viewport/CanvasAnnotationLayer.tsx |
| Pan + zoom atoms | packages/sdk/src/chrome/viewport/atoms.ts — ComponentCanvasPanAtom, ComponentCanvasZoomAtom |
| Mount point | packages/sdk/src/chrome/viewport/Viewport/ — enabled && viewMode === "canvas" && <ComponentCanvasViewport /> |
| View switcher button | packages/sdk/src/chrome/viewport/ViewportTopBar/ — toggles "page" ↔ "canvas"; calls applyCanvasVisibility to flip ROOT-child hidden flags |
| Components dropdown (canvas breadcrumb row) | packages/sdk/src/chrome/viewport/ComponentSelector.tsx |
| Edit Linked Instance walk-up | packages/sdk/src/chrome/toolbar/primitives/componentConverters.tsx |
| Drop-zone gate (canvas accepts drops) | packages/sdk/src/chrome/shell/CustomEventHandlers.tsx — isPointerInsideViewport accepts [data-canvas-zoom-component-canvas] too |
IsolateAtom (in lib.ts) — page isolation. Drives PageSelector, NodeBreadcrumb, useEditorActivePage, etc. Always either EDITOR_ALL_PAGES_STORAGE or a type === "page" node ID.CanvasIsolateAtom (in componentIsolation.ts) — canvas component isolation. Either null (list mode) or a type === "component" container ID. Cleared when canvas viewport unmounts.These are intentionally separate. Overloading IsolateAtom to point at component containers would break every consumer that assumes it's a page node.
props.custom.canvasPos = { x, y } on each type === "component" Container. Rides the existing site-save pipeline.props.custom.canvasSize = { w, h? } on each type === "component" Container. w defaults to CANVAS_SLOT_W (480). h is optional — when undefined the slot auto-fits the live component's natural rendered height; once the user drags the bottom edge, h is locked to that value. Read via getComponentCanvasSize (mirrors getComponentCanvasPos).props.type === "componentCanvas" and props.annotations: CanvasAnnotation[]. Created lazily on first annotation. Hidden in all view modes except canvas (Container.tsx visibility branch).canvasPos is missing, ComponentCanvasItem computes a 4-col grid slot from the component's index in the ROOT children list, then writes it back to props.custom.canvasPos on first mount so the drag handle and the live DOM line up.The CraftJS Frame mounts inside #viewport which lives deep in the editor's flex chain (sidebar + canvas + settings panel are all flex-1 siblings). This setup falls apart in canvas mode — see ./layout-collapse.md for the full pathology and the workaround. Short version:
overflow: hidden and transform on every ancestor of #viewport when entering canvas mode, restore on exit. Done in ComponentCanvasViewport's mount effect by walking parents.position: fixed + slot's screen bbox. The component DOM stays inside the CraftJS Frame (so React/CraftJS reconciliation isn't broken) — only its visual position is overridden imperatively.transform: scale(zoom) on the component to scale inner content. Width is CANVAS_SLOT_W (480px) at natural size; visual width is 480 * zoom.pointer-events: none so clicks pass through to the live component below. Only the handle bar and the floating toolbar carry pointer-events: auto.Pan and per-item drag are NOT routed through React state. They drive the DOM directly via CSS variables + a custom event. The atoms are still the source of truth for persistence, but they're written once on pointerup, not 60 times per second.
| Var | Set on | Read by |
|---|---|---|
--ph-pan-x, --ph-pan-y, --ph-zoom | The canvas surface ([data-canvas-zoom-component-canvas]), in a useLayoutEffect when the pan/zoom atoms change | Each item's wrapper transform (calc-based) |
--ph-item-x, --ph-item-y | The wrapper of each ComponentCanvasItem | That wrapper's transform |
--ph-ann-x, --ph-ann-y | The wrapper of each annotation | That wrapper's transform |
Wrapper transform shape (in CSS):
translate3d(
calc(var(--ph-pan-x, 0px) + var(--ph-item-x, 0px) * var(--ph-zoom, 1)),
calc(var(--ph-pan-y, 0px) + var(--ph-item-y, 0px) * var(--ph-zoom, 1)),
0
) scale(var(--ph-zoom, 1))
So changing --ph-pan-x on the surface recomputes every item's transform via CSS — zero React renders.
The live component DOM (position: fixed) can't follow via pure CSS — it lives outside the surface and needs left/top updated imperatively. The viewport dispatches a ph-canvas-tick event on the surface every time pan/zoom changes; each item's position effect listens for it and re-runs apply() to read the slot's new screen bbox and write the new left/top on the live DOM.
During drag (handle or annotation), the pointermove handler:
--ph-item-x/y (or --ph-ann-x/y) directly via refapplyPinRef.current() to re-pin the live DOM in the same frameactions.setProp / onCommitPos only fire on pointerup. A 60Hz drag is 60 DOM mutations and zero React renders.
The position-pinning effect uses useLayoutEffect (not useEffect) so it commits before paint — otherwise the wrapper would move one frame ahead of the live DOM and you'd see jitter.
The componentConverters.tsx (formerly CloneHelper) "Edit Linked Instance" button walks up to 6 levels from the clone master node until it finds a props.type === "component" ancestor (older code only looked one level up, which broke for components converted from sections). Once found, it sets viewMode to "canvas" and CanvasIsolateAtom to that container ID — no separate "drilled-in" mode.
props.custom.canvasPos on pointerup, no snap-to-grid; drops where you let go).TbFocus2) on the right of the handle — enters isolation for this component (setCanvasIsolate(containerId)).ew-resize (right edge), ns-resize (bottom), or nwse-resize (bottom-right corner). Drag to resize; React-bypassed (refs + DOM) during the drag, only actions.setProp on pointerup. Min width 200, min height 80, no snap. Hidden when the component is isolated (it's full-canvas in that mode). Right-only drag preserves any prior committed h; bottom drag locks h (exits auto-fit).CanvasAnnotationLayer renders each { id, x, y, text, kind: "label"|"title" } as an absolutely-positioned contentEditable div inside the canvas surface. Single-click selects (showing a delete X), drag moves, double-click edits, Enter/Escape commits, Delete removes when selected. Annotations scale with zoom via transform: scale.
useCanvasPan listens globally for Space keydown, then catches pointerdown on the canvas surface and translates pointer deltas into pan. wheel with Ctrl/Cmd zooms; plain wheel pans. All writes batched via requestAnimationFrame to avoid 1000Hz atom thrashing on high-DPI mice.
isPointerInsideViewport() in CustomEventHandlers.tsx was originally el.closest("#viewport"). Drops landing on the canvas surface (which is OUTSIDE #viewport) were cancelled with drop-cancelled-outside-viewport. Selector now also accepts [data-canvas-zoom-component-canvas] so drops onto the canvas surface (and onto components moved into its visual area) land normally.
zoom only.