fix(canvas): plain drag out of parent un-nests again

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 20:48:38 -07:00
parent f2a4b6e0d3
commit 512fdfd59d

View File

@ -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);