Canvas Chrome — Direct-Manipulation Controls

The "canvas chrome" is the layer of editor-only UI that overlays selected nodes inside #viewport: edge resize, rotation handle, gap drag, padding overlay. None of it ships to the runtime; it lives entirely under packages/sdk/src/chrome/c…

The "canvas chrome" is the layer of editor-only UI that overlays selected nodes inside #viewport: edge resize, rotation handle, gap drag, padding overlay. None of it ships to the runtime; it lives entirely under packages/sdk/src/chrome/canvas/.

This doc covers how the four controls are wired, the conventions they share, and the coordination pattern that keeps them from fighting each other for the same cursor zone.

Controllers (top-level)

All four are mounted once at the top of the editor tree (next to DropZoneIndicator in editor.tsx ~L470) except GapDragControl and PaddingOverlay, which mount per-node inside Container.

ControllerFileMountingPurpose
BorderResizeControllerBorderResizeController.tsxglobalRight/bottom edge drag → w-[Npx] / h-[Npx]
RotateHandleControllerRotateHandleController.tsxglobalBottom-left corner drag → rotate-[Ndeg]
GapDragControlGapDragControl.tsxper-ContainerGap region drag → gap-N
PaddingOverlay(see Container chrome)per-ContainerPadding region drag → p[trbl]-N

The two global controllers read the currently-selected node from @craftjs/core and pull its dom ref, so they don't need to be siblings of the node they're operating on.

The four conventions

Everything below is non-obvious and load-bearing. Don't break these without reading the file headers — every controller's docstring explains why it deviates from "just hit-test the rect."

1. Detection is pure math, not overlay strips

There are no invisible hit-zone divs for edge resize or rotation. Both controllers attach a global mousemove listener and run detectEdge() / isInCornerZone() against the selected element's geometry on every move. If the cursor is in zone, a body[data-ph-edge] / body[data-ph-rotate] data attribute is set; a global CSS rule with !important flips the cursor.

Why no overlays: invisible hit divs end up trapping clicks meant for child elements, and they don't track CSS rotations without a parallel rotation wrapper. Pure math + cursor flip is simpler and rotation-aware for free.

2. Cursor flip via body attribute + descendant !important

/* packages/sdk/src/css/editor-partials/canvas-interaction.css */
body[data-ph-edge="right"],
body[data-ph-edge="right"] * {
  cursor: ew-resize !important;
}

The descendant selector + !important is non-negotiable. Setting cursor on the element doesn't override children with their own cursor: pointer (every .btn, every link, etc.). Body-level descendant rule with !important wins unconditionally.

Mirror in styles/editor.css — the same rule lives in two files (SDK partial + app-level editor.css). When you change one, change both.

3. Rotation uses CSS rotate, not transform

(el.style as any).rotate = `${next}deg`;

Tailwind v4's rotate-[Ndeg] writes to the individual CSS rotate property, not transform. If the live drag wrote to transform, after release it would compound with the className rotate-[Ndeg] and the element would rotate twice.

Read sites that need to know the current rotation must check both rotate and transform (some legacy sources still emit transform: rotate(...)). getNodeGeometry sums them — never read either property in isolation.

4. Geometry is rotation-aware

getBoundingClientRect() returns the axis-aligned bounding box of a rotated element — bigger than the element, edges in the wrong place. For rotated elements you need the un-rotated rect plus the angle, then transform cursor positions into the element's local frame.

const g = getNodeGeometry(el);  // { cx, cy, w, h, angle, toLocal }
const lp = g.toLocal(e.clientX, e.clientY);  // origin at center, +x along element's rotated x-axis
if (Math.abs(lp.x - g.w/2) <= BORDER_BAND) // … cursor is on the right edge

projectToLocalAxis(dx, dy, angle) is the same idea for drag deltas — projects a screen-space mouse delta onto the rotated element axes so dragging the visually-rotated right edge grows width along the rotated x-axis instead of the screen x-axis.

Shared helpers

All in packages/sdk/src/chrome/canvas/:

  • nodeGeometry.tsgetNodeGeometry(el) and projectToLocalAxis(dx, dy, angle). Reads rotation from BOTH rotate and transform CSS properties.
  • nodeOverlayPosition.tsgetNodeOverlayPosition(el, portalTarget). Returns a wrapper rect in portal-local pre-zoom px for affordances that need to render inside #viewport. Handles the screen-px ↔ portal-px ↔ rotated-frame conversions.
  • edgeResizeState.ts — module-level "is edge resize hovering or dragging?" flag with subscribe. PaddingOverlay reads it to suppress its own hover when the resize controller owns the cursor.
  • rotateActiveState.ts — same shape, separate flag for "rotation drag in flight." BorderResizeController reads it to bail out of hover/mousedown while the user sweeps the cursor across edge bands during rotation.

CSS zoom + portal-local px (the footgun)

#viewport sits inside a parent with CSS zoom. This means:

  • getBoundingClientRect() returns post-zoom screen pixels.
  • offsetWidth / offsetHeight return pre-zoom CSS pixels.

If you mix them inside an overlay wrapper sized with offsetWidth-style values, the wrapper position will drift with the zoom level. That's exactly what getNodeOverlayPosition solves — read the parent zoom, divide screen coords by it, hand back portal-local pre-zoom pixels that play nicely with offsetWidth-sized children.

const zoom = parseFloat(getComputedStyle(portalTarget.parentElement).zoom) || 1;
const localCx = (g.cx - portalRect.left) / zoom + portalTarget.scrollLeft;

Use this whenever you're rendering an overlay inside #viewport. Use plain position: fixed with raw clientX/Y for cursor-following floats (e.g. the rotation degree pill) so you skip the conversion entirely.

Coordination flags

Without coordination, hover detection from one controller fires while another is mid-drag — the cursor flickers, a stale affordance lingers, or two controllers fight to own body[data-...]. The flag pattern:

  • Each "owner" controller writes a module-level flag when it has the cursor (hover or drag).
  • Other controllers read the flag and short-circuit their own hover/mousedown handlers.
FlagSet byRead by
setEdgeResizeActiveBorderResizeController (hover OR drag), RotateHandleController (hover OR drag)PaddingOverlay (suppresses its own hover)
setRotateActiveRotateHandleController (drag ONLY)BorderResizeController (suppresses hover + mousedown)

Note the asymmetry: edgeResizeState is set on hover or drag (both fight for the same edge band), but rotateActiveState is drag-only (the bottom-left corner is unique to rotation; only the active drag needs to lock out the resize bands the cursor sweeps over).

Effect-cleanup gotcha

RotateHandleController learned this the hard way: when dragging flips to true, the React hover effect's deps change, the cleanup fires, and the cleanup deletes body[data-ph-rotate] — the body attr the mousedown handler set microseconds earlier. The custom rotate cursor disappears the instant the drag starts.

Fix: in cleanup, check isRotateActive() before mutating shared state. If a drag is in flight, leave it alone — the active-drag effect owns the cursor and clears it on mouseup. Same shape works any time a hover effect's cleanup needs to coexist with a drag handoff.

Cursor SVG (rotation only)

The rotate cursor is a runtime-generated data URL with the SVG path inlined. It spins with the element's current rotation:

// _baseCursorSvg is a hand-written SVG string (path data extracted from
// react-icons CgCornerDoubleDownRight, scaled 0.8 inside a 24×24 viewBox,
// with a white stroke halo via paint-order: stroke).
const rotated = _baseCursorSvg
  .replace(/<svg([^>]*)>/, `<svg$1><g transform="rotate(${angle} 10 10)">`)
  .replace(/<\/svg>$/, "</g></svg>");
const url = `url("data:image/svg+xml;base64,${btoa(rotated)}") 10 10, grab`;
document.documentElement.style.setProperty("--ph-rotate-cursor", url);

The CSS rule references the var:

body[data-ph-rotate="true"],
body[data-ph-rotate="true"] * {
  cursor: var(--ph-rotate-cursor, grab) !important;
}

Updates are throttled to integer-degree changes (we only regenerate the data URL when Math.round(angle) actually moves).

Excluding nodes from drag detection

Two opt-out attributes the controllers all respect on child elements:

  • data-node-control="true" — emitted on every overlay/affordance the chrome itself renders. Excluded from "visible children" checks.
  • data-exclude-gap-detection — same idea, for things that should sit inside a gap area (or anywhere) without being treated as a sibling for gap math.

Always emit both on any new chrome you add inside #viewport so the gap detector + drop indicator + outline don't trip on it.

Skip lists

Both global controllers (BorderResizeController, RotateHandleController) gate on the same skip list:

const SKIP_DISPLAY_NAMES = new Set(["Container", "Background"]);
const SKIP_TYPES = new Set(["page", "header", "footer"]);
  • Containers auto-size to children — resizing them by drag breaks layout.
  • Background / page / header / footer are section-level layout primitives; drag-resize them and you fight the parent.

If you add a new section primitive, add it here too.

Affordance render conventions

When you render a hover/drag affordance inside #viewport (the line marker on edge resize, the gap fill, etc.), the pattern is:

  1. Portal into #viewport so the overlay scrolls and zooms with the canvas.
  2. Use getNodeOverlayPosition to get a portal-local pre-zoom rect.
  3. Wrap in a rotation transform matching the element's angle — children inside the wrapper can then use plain right: -1, bottom: -1, etc.
  4. pointer-events: none on visual-only sub-elements; only the click target is interactive.
  5. zIndex: 9998 for hover/drag overlays, 9997 for passive markers.

For cursor-following floats (degree pill while rotating), portal to document.body with position: fixed and raw clientX/Y. The pill should not rotate with the element — it stays upright and follows the mouse with a small offset (currently 14px down-and-right).

Snapping / commit pattern

All four controllers follow the same drag→commit shape:

  1. Mousedown captures starting geometry into a dragRef.
  2. Mousemove writes inline styles directly (el.style.width, el.style.gap, etc.) — fast visual feedback, no React round-trip.
  3. Mouseup clears the inline style, snaps the final value, computes a Tailwind class (with view + dark prefix via buildVariantPrefix), and commits via actions.setProp(id, p => p.className = twMerge(...)).

The intermediate inline style is always cleared before the className commit, otherwise the inline value would override the class indefinitely.

Snapping rules per controller:

  • Edge resize: arbitrary w-[Npx] / h-[Npx] (no Tailwind-spacing snap).
  • Rotation: arbitrary rotate-[Ndeg], with Shift = snap to 15°.
  • Gap: snapped to nearest Tailwind gap scale via pixelsToGapClass.
  • Padding: snapped to nearest Tailwind spacing scale (handled in PaddingOverlay).

Where to extend

  • New drag-driven control? Mount alongside the existing four. Use nodeOverlayPosition for visuals, follow the body-attr cursor pattern, add a coordination flag if you'll fight any existing controller for the same cursor zone.
  • New skip case? Add to SKIP_DISPLAY_NAMES / SKIP_TYPES in both global controllers. (Yes, two lists. Worth unifying eventually.)
  • New cursor? Generate the SVG → data URL → CSS var pattern; reference via a body-attr-keyed rule in canvas-interaction.css and styles/editor.css.