Canvas Drag-and-Drop
July 1, 2026 · View on GitHub
How drag-and-drop works in the visual editor: dropping new modules from the picker / library, moving existing nodes around the page tree, wrap-to-container, multi-select moves, and the drop-zone overlay.
Built on @dnd-kit/core. The canvas owns its own DnD context (separate from the dashboard's DnD); the same patterns apply on both sides.
TL;DR
@dnd-kit/coreis the only DnD library. Noreact-dnd, no native HTML5 drag-and-drop.- The canvas's
<DndContext>lives atCanvasRoot.tsx. EveryBreakpointFrameis inside it. - Drop targets are drop-zones — rectangles between nodes ("before X", "after X", "into X"). Computed per-frame from node geometry.
- Drop resolution:
resolveCanvasDropTarget(...)insrc/admin/pages/site/canvas/canvasDnd.tsmaps(activePoint, frameGeometry) → { parentId, index, axis }. - New insert sources that are not moving an existing node use
resolveCanvasPointerInsertionDrop(...)insrc/admin/pages/site/canvas/canvasInsertionDrop.tsso module-picker and media-library drops share viewport lookup, target resolution, and preview geometry. - Mutation:
mutateActiveTree((tree) => moveNode(tree, nodeId, parentId, index))— page-mode and VC-mode both work.
The DnD topology
<DndContext> ← canvas owns this
<BreakpointFrame breakpoint="mobile">
<ModuleSandboxFrame> ← <iframe sandbox>
<NodeRenderer ...> ← each node is a useDraggable
</ModuleSandboxFrame>
</BreakpointFrame>
<BreakpointFrame breakpoint="desktop">...</BreakpointFrame>
<ModulePicker> ← sources: 'picker:<moduleId>'
<SiteExplorer> ← sources: 'tree:<nodeId>' (DOM panel rows)
<DragOverlay> ← shared overlay
</DndContext>
<ModuleInserterDialog> ← pointer drag to insertion target
<MediaExplorerPanel> ← image/video pointer drag to insertion target
Drag sources:
| Source | Origin | Drop result |
|---|---|---|
node:<nodeId> | A node on the canvas / DOM panel | Move the node to the drop target |
| Module inserter item | Module picker / inserter dialog | Insert a new node of the picked module at the drop target |
| Media Explorer asset | Site editor Media panel | Image asset inserts base.image; video asset inserts base.video |
tree:<nodeId> | The DOM panel tree | Same as node: (DOM panel ⇄ canvas parity) |
Existing-node moves are DnD-context drags. Insert sources that start outside the frame tree use pointer listeners and resolveCanvasPointerInsertionDrop(...) because they need the same drop zones but do not carry an existing node id.
Drop zones
A drop zone is a thin rectangle that resolves to "insert at this position". There are three kinds:
┌────────────────────────────┐
│ ─── before sibling A ───── │ ← "insert at index 0"
│ ┌────────────────────────┐ │
│ │ node A (container) │ │ ← node A's "into" zone
│ │ │ │
│ │ child 1 │ │
│ │ child 2 │ │
│ └────────────────────────┘ │
│ ─── between A and B ───── │ ← "insert at index 1"
│ ┌────────────────────────┐ │
│ │ node B (text) │ │
│ └────────────────────────┘ │
│ ─── after sibling B ────── │ ← "insert at index 2"
└────────────────────────────┘
| Zone kind | Position | Resolves to |
|---|---|---|
| Before | Top edge of a sibling | { parentId, index } (sibling's index) |
| After | Bottom edge | { parentId, index + 1 } |
| Into | Body of a canHaveChildren node | { parentId: target.id, index: target.children.length } (append) |
The axis ('vertical' | 'horizontal') depends on the parent's layout — vertical for normal block flow, horizontal for display: flex; flex-direction: row.
resolveCanvasDropTarget
resolveCanvasDropTarget({
activePoint: { x, y }, // pointer in canvas-space coordinates
frameGeometry, // measured rects for every node in the frame
activeNodeId, // for 'node:' drags — exclude self
moduleRegistry, // to ask `canHaveChildren`
}): CanvasDropResolution // { target?: CanvasDropTarget; invalid?: CanvasInvalidDropTarget }
The resolver:
- Walks
frameGeometryfrom the leaves up. - For each rect, checks if
activePointfalls in a zone. - Picks the innermost match — a point inside a child wins over the parent's "into" zone.
- Rejects invalid drops (self into self, cycle, locked node).
getCanvasDropZone(rect, point, axis) is the helper that classifies a single rect's hit ('before' | 'into' | 'after' | null).
Drop overlay
The overlay highlights the resolved drop position. Geometry comes from the resolver or, for insert sources, from canvasInsertionDrop.ts's fixed preview helpers:
- Before / After — a thin sky-tinted line (
--accent-3at 0.6 alpha) at the zone position. - Into — a sky-tinted dashed outline inset 4px from the target's bounding box.
- Invalid — a danger-tinted outline (
--danger) + a tooltip explaining why ('cannot drop into self','target is locked').
The overlay is a separate React tree positioned with absolute coordinates derived from the canvas zoom/pan transform.
DOM panel ⇄ canvas parity
The DOM panel uses the same drop resolution as the canvas — except its geometry comes from row positions, not canvas geometry. A node dragged in the DOM panel resolves the same { parentId, index } as a node dragged on the canvas.
The two surfaces share <DndContext> so a drag can start in one and end in the other.
Mutation
After the resolver picks a target, the page-level onDragEnd:
onDragEnd: (event) => {
const { active, over } = event
if (!over) return
const sourceKind = parseSourceKind(active.id)
const target = resolveCanvasDropTarget({ /* ... */ })
if (!target.target) return
if (sourceKind.kind === 'picker') {
// Insert new module
useEditorStore.getState().insertNode(
createNode(sourceKind.moduleId),
target.target.parentId,
target.target.index,
)
} else if (sourceKind.kind === 'node') {
// Move existing node
useEditorStore.getState().moveNode(
sourceKind.nodeId,
target.target.parentId,
target.target.index,
)
}
}
Both insertNode and moveNode go through mutateActiveTree — they work in page-mode and VC-mode the same way. See docs/reference/page-tree.md.
Multi-select drag
The DOM panel + canvas support multi-select via shift / cmd-click. When the user drags one of the selected nodes:
- All selected nodes move together.
- They're moved to the drop target via
moveNodes(tree, nodeIds, parentId, index)— the mutation preserves relative order. - If any selected node can't be moved (locked, would create a cycle), the whole drop is invalid.
moveNodes is the multi-version of moveNode. Both live in src/core/page-tree/mutations.ts.
Wrap-to-container
A common drag pattern: select two nodes, drag them onto a "wrap in container" affordance, and they become children of a new container at the original position.
Implemented as wrapNodes(tree, nodeIds, 'base.container') in mutations.ts. The drag source is the multi-select group; the drop target is a "wrap" affordance (shown in the toolbar / context menu, not as a canvas drop zone).
Gated by task414-wrap-to-container.test.ts and multiWrapDefaults.test.ts — wrapper nodes are created with module defaults and keep the wrapped tree structure valid.
Cookbook
Drop a new module from the picker
const drop = resolveCanvasPointerInsertionDrop({
canvasPage,
clientX,
clientY,
label: 'Drop',
})
if (drop) insertModule(module, drop.location)
The module inserter keeps its own pointer drag state, but target resolution and preview geometry are shared through canvasInsertionDrop.ts.
Drop media from the Site editor Media panel
mediaCanvasInsertionForAsset(asset) maps image assets to base.image defaults ({ src: asset.publicPath }) and video assets to base.video defaults ({ videoUrl: asset.publicPath }). Other asset types are not canvas insert sources. The drag hook then calls useInsertModule(mod, drop.location, { defaults }), so the created node is selected and routed through the same page/Visual Component insertion path as module-picker drops.
Drop an existing node
The canvas's NodeRenderer registers each node as useDraggable({ id: node:${nodeId} }). The drag is initiated by clicking the node's drag handle. The same drop-zone resolution applies.
Drop INTO a container
Drop zones for canHaveChildren nodes include an "into" zone covering the body. The resolver picks it when the pointer is inside the body (and not on a child's before/after zone). The new node is appended as the last child.
Disable drops on a node
Set locked: true on the node. The resolver rejects drops on locked nodes (and drops of locked nodes themselves).
base.slot-instance nodes are always locked — the user can edit their contents but not move / delete the instance itself.
Inserting a node programmatically
useEditorStore.getState().insertNode(
createNode('base.text', { content: 'New text' }),
parentNodeId,
0, // index — at the start
)
Bypasses DnD entirely. Same mutation as a drop.
Listening for drop events
Don't add raw dragstart / dragend listeners — @dnd-kit owns those. If you need to react to a drop, put the logic in onDragEnd (in the page that owns the <DndContext>).
Forbidden patterns
| Pattern | Use instead |
|---|---|
Native HTML5 drag-and-drop (draggable={true}, onDragOver, etc.) | @dnd-kit/core everywhere |
react-dnd | @dnd-kit/core — only DnD library in this codebase |
| Computing drop targets ad-hoc per surface | resolveCanvasDropTarget(...) — same logic everywhere |
Skipping the cycle check on a moveNode | moveNode already guards. Use it. |
| Inserting into a locked node | Resolver rejects. Don't bypass. |
Reading from the iframe's document to find drop targets | Use the canvas-space geometry (frameGeometry) |
| Dispatching a different mutation per drag source kind, deeply | Two cases in onDragEnd: picker → insertNode, node → moveNode. Keep it that simple. |
Related
- docs/editor.md — canvas overview
- docs/reference/page-tree.md —
moveNode,moveNodes,wrapNode,wrapNodes,insertNode - docs/features/visual-components.md — slot-instance is locked
- docs/features/dashboard.md — separate DnD topology for the dashboard
- Source-of-truth files:
src/admin/pages/site/canvas/canvasDnd.ts—getCanvasDropZone,resolveCanvasDropTargetsrc/admin/pages/site/canvas/canvasInsertionDrop.ts— pointer-to-canvas insertion target + fixed preview geometry shared by module and media insert sourcessrc/admin/pages/site/canvas/CanvasRoot.tsx—<DndContext>mountsrc/admin/pages/site/canvas/useCanvasReorderDrag.ts— drag-state hooksrc/admin/pages/site/panels/MediaExplorerPanel/mediaCanvasInsertion.ts— media asset → base module/defaults mappingsrc/admin/pages/site/store/insertLocation.ts—InsertLocationshapesrc/core/page-tree/mutations.ts—insertNode,moveNode,moveNodes,wrapNode
- Gate tests:
src/__tests__/architecture/task414-wrap-to-container.test.tssrc/__tests__/architecture/canvas-aware-selectors.test.ts