fix(canvas): cancel-nest restores position; un-nest shrinks parent

Two follow-up polish items for drag-and-nest:

1. Cancelling the "Extract from team?" dialog now snaps the
   dragged card back to where the drag started. Before, a user
   who dragged a child out, saw the confirm dialog, then clicked
   Cancel ended up with the card stranded outside the parent at
   its drop-point position — which also got persisted via
   savePosition on drag-stop. Now onNodeDragStart captures the
   pre-drag position + parent, and cancelNest restores both the
   RF node position and fires savePosition with the absolute
   pre-drag coords so reload matches.

2. Un-nesting now clears the ex-parent's explicit width/height
   in the nodes array. growParentsToFitChildren is grow-only so
   it could never shrink the parent back down after a child
   left; the card stayed at its auto-grown size with empty
   space. Stripping width/height lets React Flow re-measure from
   the card's own min-width / min-height CSS, so the parent
   visually shrinks to fit whatever children remain.

923 canvas tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 20:52:28 -07:00
parent 512fdfd59d
commit 09053dfdeb
2 changed files with 68 additions and 2 deletions

View File

@ -56,6 +56,16 @@ export function useDragHandlers(): DragHandlers {
alt: false,
meta: false,
});
// Remember where the dragged node started so we can put it back on
// cancel. React Flow tracks only the current position during drag;
// if the user drags out → "Extract?" dialog → Cancel, we want the
// card to go back inside its parent at its original coords rather
// than stay dangling at the cancel-time position.
const dragStartStateRef = useRef<{
nodeId: string;
parentId: string | null;
position: { x: number; y: number };
} | null>(null);
const [pendingNest, setPendingNest] = useState<PendingNestState | null>(null);
// Absolute-bounds hit test. Tiebreakers in order: highest zIndex
@ -102,11 +112,16 @@ export function useDragHandlers(): DragHandlers {
);
const onNodeDragStart: OnNodeDrag<WorkspaceNode> = useCallback(
(event) => {
(event, node) => {
dragModifiersRef.current = {
alt: event.altKey,
meta: event.metaKey || event.ctrlKey,
};
dragStartStateRef.current = {
nodeId: node.id,
parentId: node.data.parentId,
position: { x: node.position.x, y: node.position.y },
};
},
[],
);
@ -196,6 +211,7 @@ export function useDragHandlers(): DragHandlers {
// showToast, so `void` is the right pattern here.
const pending = pendingNest;
setPendingNest(null);
dragStartStateRef.current = null;
const state = useCanvasStore.getState();
if (
state.selectedNodeIds.size > 1 &&
@ -207,7 +223,44 @@ export function useDragHandlers(): DragHandlers {
}
}, [pendingNest, nestNode, batchNest]);
const cancelNest = useCallback(() => setPendingNest(null), []);
const cancelNest = useCallback(() => {
// Restore the dragged card to wherever it started. Without this,
// a user who drags a child out of a parent then clicks Cancel
// leaves the card stranded outside the parent with no visual
// parent link — a state that doesn't match any save-backed
// truth (the DB position was already written on drag-stop).
const start = dragStartStateRef.current;
if (start) {
const { nodes } = useCanvasStore.getState();
useCanvasStore.setState({
nodes: nodes.map((n) =>
n.id === start.nodeId
? { ...n, position: start.position }
: n,
),
});
// Write the restore back to the DB so a reload shows the same
// position. Convert the stored relative position back to absolute
// via the parent's absolute origin before saving.
const parent = start.parentId
? nodes.find((n) => n.id === start.parentId)
: null;
const parentInternal = start.parentId
? getInternalNode(start.parentId)
: null;
const parentAbs = parentInternal?.internals.positionAbsolute ?? {
x: parent?.position.x ?? 0,
y: parent?.position.y ?? 0,
};
savePosition(
start.nodeId,
start.position.x + parentAbs.x,
start.position.y + parentAbs.y,
);
}
dragStartStateRef.current = null;
setPendingNest(null);
}, [getInternalNode, savePosition]);
return {
onNodeDragStart,

View File

@ -653,6 +653,14 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
}
}
// When a child leaves its parent, clear the parent's explicit
// width/height. growParentsToFitChildren is grow-only so it can't
// shrink on its own; without this, a parent that auto-grew to
// contain the dragged child stays at that size after un-nest,
// leaving a large empty frame. React Flow then measures the new
// size from the card's own min-width/min-height CSS.
const shrinkOldParent = !!currentParentId && targetId !== currentParentId;
set({
nodes: nodes.map((n) => {
if (n.id === draggedId) {
@ -664,6 +672,11 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
data: { ...n.data, parentId: targetId },
};
}
if (shrinkOldParent && n.id === currentParentId) {
const { width: _w, height: _h, ...rest } = n;
void _w; void _h;
return rest as typeof n;
}
if (movedIds.has(n.id) && depthDelta !== 0) {
return { ...n, zIndex: (n.zIndex ?? 0) + depthDelta };
}