From 09053dfdeb0558c94e2c4c549d1953f5e44f7e19 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 20:52:28 -0700 Subject: [PATCH] fix(canvas): cancel-nest restores position; un-nest shrinks parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/canvas/useDragHandlers.ts | 57 ++++++++++++++++++- canvas/src/store/canvas.ts | 13 +++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/canvas/useDragHandlers.ts b/canvas/src/components/canvas/useDragHandlers.ts index 8bcd8304..85e63bca 100644 --- a/canvas/src/components/canvas/useDragHandlers.ts +++ b/canvas/src/components/canvas/useDragHandlers.ts @@ -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(null); // Absolute-bounds hit test. Tiebreakers in order: highest zIndex @@ -102,11 +112,16 @@ export function useDragHandlers(): DragHandlers { ); const onNodeDragStart: OnNodeDrag = 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, diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 7559ec8b..d13758c9 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -653,6 +653,14 @@ export const useCanvasStore = create((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((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 }; }