Components Editor — Canvas Mode

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.

User-facing model

  • Page mode (default) — normal page editor, components hidden.
  • Canvas mode — clicking the View switcher (TbBoxModel2) puts you here. Two sub-states:
    • List — all 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.
    • Isolation — only the targeted component is visible (others hidden via the Container visibility branch). The canvas pans/zooms to show it centered. An "Exit isolation" pill appears top-left with the component name. Annotations and the label/title toolbar buttons are hidden during isolation.

Entry points to isolation:

  • click the isolate icon on the drag handle (or double-click the drag handle)
  • click "Edit Linked Instance" on a clone (componentConverters.tsx, formerly CloneHelper) — also switches to canvas if you were in page mode
  • click a component in the ComponentSelector dropdown
  • "Create New Component" from ComponentSelector or EditorEmptyState — creates the component and opens it in isolation

File map

ConcernFile
View mode unionpackages/sdk/src/utils/atoms.tsEditorCanvasViewMode = "page" | "preview" | "canvas"
Canvas isolation atom + ROOT-visibility helperpackages/sdk/src/utils/component/componentIsolation.tsCanvasIsolateAtom, applyCanvasVisibility
Canvas singleton + helperspackages/sdk/src/utils/component/componentCanvas.ts
Pan/zoom hookpackages/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 buttonpackages/sdk/src/chrome/viewport/ComponentCanvasItem/
Floating text annotationspackages/sdk/src/chrome/viewport/CanvasAnnotationLayer.tsx
Pan + zoom atomspackages/sdk/src/chrome/viewport/atoms.tsComponentCanvasPanAtom, ComponentCanvasZoomAtom
Mount pointpackages/sdk/src/chrome/viewport/Viewport/enabled && viewMode === "canvas" && <ComponentCanvasViewport />
View switcher buttonpackages/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-uppackages/sdk/src/chrome/toolbar/primitives/componentConverters.tsx
Drop-zone gate (canvas accepts drops)packages/sdk/src/chrome/shell/CustomEventHandlers.tsxisPointerInsideViewport accepts [data-canvas-zoom-component-canvas] too

Two atoms, two scopes — don't confuse them

  • 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.

Persistence — no schema changes

  • Component position: props.custom.canvasPos = { x, y } on each type === "component" Container. Rides the existing site-save pipeline.
  • Component size: 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).
  • Annotations: singleton ROOT child Container with props.type === "componentCanvas" and props.annotations: CanvasAnnotation[]. Created lazily on first annotation. Hidden in all view modes except canvas (Container.tsx visibility branch).
  • Auto-layout fallback: if 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.
  • No new Mongo collections. No new API routes.

Architecture — the load-bearing weirdness

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:

  1. Lift 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.
  2. Pin each component to its slot via 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.
  3. Apply transform: scale(zoom) on the component to scale inner content. Width is CANVAS_SLOT_W (480px) at natural size; visual width is 480 * zoom.
  4. Drag handle wrapper is pointer-events: none so clicks pass through to the live component below. Only the handle bar and the floating toolbar carry pointer-events: auto.

React-bypass for pan + drag (the hot path)

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.

VarSet onRead by
--ph-pan-x, --ph-pan-y, --ph-zoomThe canvas surface ([data-canvas-zoom-component-canvas]), in a useLayoutEffect when the pan/zoom atoms changeEach item's wrapper transform (calc-based)
--ph-item-x, --ph-item-yThe wrapper of each ComponentCanvasItemThat wrapper's transform
--ph-ann-x, --ph-ann-yThe wrapper of each annotationThat 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:

  1. Writes the wrapper's --ph-item-x/y (or --ph-ann-x/y) directly via ref
  2. For component items, calls applyPinRef.current() to re-pin the live DOM in the same frame

actions.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.

Edit Linked Instance — walk-up depth

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.

Drag handles — interaction model

  • Click + drag on the grip area — moves the component (writes props.custom.canvasPos on pointerup, no snap-to-grid; drops where you let go).
  • Click the focus icon (TbFocus2) on the right of the handle — enters isolation for this component (setCanvasIsolate(containerId)).
  • Double-click anywhere on the grip area — same as the focus icon.
  • The handle vanishes when this component is the isolated one (you're already focused; nothing to drag).
  • Resize handles on the slot edges — invisible 8px bands flip the cursor to 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).

Annotations — minimal floating text

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.

Pan + zoom

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.

Drop into canvas

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.

What this is NOT

  • Not a full Figma clone. There's no constraint solver, no auto-layout, no collaborative cursors.
  • Not a cross-site library view. Positions and annotations are per-site, on the site's CraftJS tree.
  • Not multi-user safe. Last-write-wins, same as the rest of the site editor.
  • Not multi-breakpoint. Components render at their natural width × zoom only.