From 512fdfd59d1d88ddbcf32355ff124c7fecdbc169 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 20:48:38 -0700 Subject: [PATCH] fix(canvas): plain drag out of parent un-nests again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-nest used to require holding Alt (or Cmd to force-detach). That was too conservative — when a user dragged a child clearly outside its parent's bbox, nothing happened on release, because the default branch soft-clamped back and only the Alt branch actually opened the "Extract?" confirm. Matches the exact bug the user just flagged ("I can put agents in other agent, but when I drag it out, it does not move out"). New rules: * Past the 20 % hysteresis → confirm un-nest. Plain drag, no modifier. This is what most users expect (Miro / Figma behave the same way — drag outside the frame and the shape leaves it). * Inside or within 20 % of the edge → soft-clamp back inside. Guards against twitchy releases that momentarily overshoot the edge by a few pixels. * Cmd / Ctrl → force un-nest regardless of overlap. Escape-hatch for when the user dragged within the hysteresis zone but really wants out. * Dropping onto a different parent → nest there (unchanged). Alt is no longer a required modifier for un-nesting. Keeps it as a non-gesture modifier only; no meaning unless we re-bind it later. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/canvas/useDragHandlers.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/canvas/src/components/canvas/useDragHandlers.ts b/canvas/src/components/canvas/useDragHandlers.ts index 9f7e8f26..8bcd8304 100644 --- a/canvas/src/components/canvas/useDragHandlers.ts +++ b/canvas/src/components/canvas/useDragHandlers.ts @@ -138,25 +138,22 @@ export function useDragHandlers(): DragHandlers { const nodeName = node.data.name; const currentParentId = node.data.parentId; - const altHeld = event.altKey || dragModifiersRef.current.alt; const forceDetach = event.metaKey || event.ctrlKey || dragModifiersRef.current.meta; const droppingIntoAnotherParent = !!dragOverNodeId && dragOverNodeId !== currentParentId; - - // Soft clamp (plain drag, no modifier, not re-parenting): snap - // the child back inside its current parent. Alt or Cmd bypass. - if ( - currentParentId && - !altHeld && - !forceDetach && - !droppingIntoAnotherParent && - shouldDetach(node.id, currentParentId, getInternalNode) - ) { - clampChildIntoParent(node.id, currentParentId, getInternalNode); - } + // Past the 20 %-overlap hysteresis? Treat the gesture as a + // deliberate drag-out. Below that threshold we soft-clamp the + // child back inside so a twitchy release doesn't un-nest + // accidentally (same intent as before, just: plain drag works + // without a modifier now). + const pastHysteresis = + !!currentParentId && + shouldDetach(node.id, currentParentId, getInternalNode); if (droppingIntoAnotherParent) { + // Explicit drop onto another workspace always wins over + // clamp/detach — the user pointed at a new target. const targetNode = allNodes.find((n) => n.id === dragOverNodeId); const targetName = targetNode?.data.name || "Unknown"; setPendingNest({ @@ -165,11 +162,9 @@ export function useDragHandlers(): DragHandlers { nodeName, targetName, }); - } else if ( - currentParentId && - (forceDetach || - (altHeld && shouldDetach(node.id, currentParentId, getInternalNode))) - ) { + } else if (currentParentId && (forceDetach || pastHysteresis)) { + // Dragged past the edge (or Cmd-held as a force override): the + // user wants out of the parent. Confirm the un-nest. const parentNode = allNodes.find((n) => n.id === currentParentId); const parentName = parentNode?.data.name || "Unknown"; setPendingNest({ @@ -178,6 +173,11 @@ export function useDragHandlers(): DragHandlers { nodeName, targetName: parentName, }); + } else if (currentParentId) { + // Still inside parent but the drag ended slightly past the + // edge (under 20 % outside). Snap back in so the card doesn't + // visually spill — Miro frame behaviour. + clampChildIntoParent(node.id, currentParentId, getInternalNode); } const internal = getInternalNode(node.id);