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.
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.
| Controller | File | Mounting | Purpose |
|---|---|---|---|
BorderResizeController | BorderResizeController.tsx | global | Right/bottom edge drag → w-[Npx] / h-[Npx] |
RotateHandleController | RotateHandleController.tsx | global | Bottom-left corner drag → rotate-[Ndeg] |
GapDragControl | GapDragControl.tsx | per-Container | Gap region drag → gap-N |
PaddingOverlay | (see Container chrome) | per-Container | Padding 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.
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."
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.
!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.
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.
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.
All in packages/sdk/src/chrome/canvas/:
nodeGeometry.ts — getNodeGeometry(el) and projectToLocalAxis(dx, dy, angle). Reads rotation from BOTH rotate and transform CSS properties.nodeOverlayPosition.ts — getNodeOverlayPosition(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.#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.
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:
| Flag | Set by | Read by |
|---|---|---|
setEdgeResizeActive | BorderResizeController (hover OR drag), RotateHandleController (hover OR drag) | PaddingOverlay (suppresses its own hover) |
setRotateActive | RotateHandleController (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).
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.
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).
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.
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"]);
If you add a new section primitive, add it here too.
When you render a hover/drag affordance inside #viewport (the line marker on
edge resize, the gap fill, etc.), the pattern is:
#viewport so the overlay scrolls and zooms with the canvas.getNodeOverlayPosition to get a portal-local pre-zoom rect.right: -1, bottom: -1, etc.pointer-events: none on visual-only sub-elements; only the click
target is interactive.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).
All four controllers follow the same drag→commit shape:
dragRef.el.style.width, el.style.gap,
etc.) — fast visual feedback, no React round-trip.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:
w-[Npx] / h-[Npx] (no Tailwind-spacing snap).rotate-[Ndeg], with Shift = snap to 15°.pixelsToGapClass.PaddingOverlay).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.SKIP_DISPLAY_NAMES / SKIP_TYPES in both global
controllers. (Yes, two lists. Worth unifying eventually.)