fix(canvas): keep tracking org bounds during deploy after first fit
Symptom: org import zoomed to fit the parent + first child, then froze at that framing while the remaining children kept materialising off-screen. The user had to manually pan/zoom to see the new arrivals. Two stacked bugs in useCanvasViewport's deploy-time auto-fit: 1. The user-pan-respect gate stamps userPannedAtRef on EVERY pointerdown that lands inside .react-flow__pane. That fires for ordinary clicks (deselect, click-near-a-card, modal-close-bubble from the import dialog) — not just for actual pan gestures. One accidental pre-import click was enough to lock out every fit for the rest of the deploy. Wheel is the canonical unambiguous pan/zoom signal; drop pointerdown. 2. Even with a real pan during deploy, when more children land the org's bounds grow and the user has lost context — the new arrivals are off-screen and the deploy is the primary thing they want to watch right now. The guard had no growth awareness, so one pan cancelled all follow-up fits unconditionally. Now we track the subtree size at the last fit (per root), and if the current subtree is larger we force the fit through regardless of the user-pan timestamp. When the subtree size hasn't changed (status updates on already-positioned nodes), the user-pan respect still applies — so post-deploy exploration isn't yanked back. The Map keyed by root id supports back-to-back imports of different orgs without one's growth count blocking the other's first fit. 985 canvas tests pass; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec7ecd5461
commit
e900a773ac
@ -59,9 +59,19 @@ export function useCanvasViewport() {
|
||||
// RF is behind a Suspense boundary) AND keeps clicks on the
|
||||
// toolbar / modals / side panel from stamping user-pan-intent.
|
||||
// Capture phase runs before target-phase `stopPropagation` so a
|
||||
// handler elsewhere can't swallow the signal. `pointerdown` covers
|
||||
// mouse + touch + pen on every modern browser — no separate
|
||||
// `touchstart` listener needed (would double-stamp on mobile).
|
||||
// handler elsewhere can't swallow the signal.
|
||||
//
|
||||
// Wheel only — NOT pointerdown. A pointerdown on the pane fires for
|
||||
// ordinary clicks (deselect, click-near-a-card, modal-close-bubble)
|
||||
// as well as the start of a drag-pan. Treating every pointerdown as
|
||||
// "user wants to override auto-fit" meant a single accidental click
|
||||
// before/during an org import locked out every subsequent fit, so
|
||||
// the viewport stuck at whatever the first fit landed on while
|
||||
// children kept materialising off-screen. Wheel is the canonical
|
||||
// unambiguous gesture: scroll-to-pan and pinch-zoom both surface as
|
||||
// wheel events. Drag-pans without an accompanying wheel are rare
|
||||
// enough that letting them be overridden by a follow-up auto-fit is
|
||||
// the right tradeoff.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const stamp = (e: Event) => {
|
||||
@ -70,14 +80,9 @@ export function useCanvasViewport() {
|
||||
userPannedAtRef.current = Date.now();
|
||||
};
|
||||
const opts: AddEventListenerOptions = { passive: true, capture: true };
|
||||
const targets: Array<keyof DocumentEventMap> = ["wheel", "pointerdown"];
|
||||
for (const ev of targets) {
|
||||
document.addEventListener(ev, stamp, opts);
|
||||
}
|
||||
document.addEventListener("wheel", stamp, opts);
|
||||
return () => {
|
||||
for (const ev of targets) {
|
||||
document.removeEventListener(ev, stamp, opts);
|
||||
}
|
||||
document.removeEventListener("wheel", stamp, opts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -189,17 +194,19 @@ export function useCanvasViewport() {
|
||||
// circuits: if the user moved after our last auto-fit, we never
|
||||
// fit again this deploy.
|
||||
const pendingFitRootRef = useRef<string | null>(null);
|
||||
// Subtree size at the moment of the last successful auto-fit, keyed
|
||||
// by root id. When a new event arrives and the subtree has GROWN,
|
||||
// we re-fit even if the user has panned in the meantime — they've
|
||||
// lost context, the new arrivals are off-screen, and the org-deploy
|
||||
// animation is the user's primary reason for being on the canvas
|
||||
// right now. When the subtree hasn't grown (status update on an
|
||||
// already-positioned node), the user-pan respect still applies.
|
||||
const lastFitSubtreeSizeRef = useRef<Map<string, number>>(new Map());
|
||||
useEffect(() => {
|
||||
const runFit = () => {
|
||||
const rootCandidate = pendingFitRootRef.current;
|
||||
pendingFitRootRef.current = null;
|
||||
if (!rootCandidate) return;
|
||||
if (
|
||||
userPannedAtRef.current !== null &&
|
||||
userPannedAtRef.current > lastAutoFitAtRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const state = useCanvasStore.getState();
|
||||
// Climb to the true root — the event's rootId is the just-
|
||||
// landed child's direct parent, which may itself be nested.
|
||||
@ -221,6 +228,21 @@ export function useCanvasViewport() {
|
||||
}
|
||||
}
|
||||
if (subtree.length === 0) return;
|
||||
|
||||
// Growth check: if the subtree hasn't expanded since the last
|
||||
// fit, fall back to the user-pan respect gate. If it HAS
|
||||
// grown, force the fit through — the user's previous pan no
|
||||
// longer reflects what's on screen and the deploy is the
|
||||
// primary thing they want to watch.
|
||||
const lastSize = lastFitSubtreeSizeRef.current.get(topId) ?? 0;
|
||||
const grew = subtree.length > lastSize;
|
||||
if (
|
||||
!grew &&
|
||||
userPannedAtRef.current !== null &&
|
||||
userPannedAtRef.current > lastAutoFitAtRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
fitView({
|
||||
nodes: subtree.map((id) => ({ id })),
|
||||
// Short animation — server paces children ~2s apart, so a
|
||||
@ -244,6 +266,7 @@ export function useCanvasViewport() {
|
||||
minZoom: 0.25,
|
||||
});
|
||||
lastAutoFitAtRef.current = Date.now();
|
||||
lastFitSubtreeSizeRef.current.set(topId, subtree.length);
|
||||
};
|
||||
const handler = (e: Event) => {
|
||||
const { rootId } = (e as CustomEvent<{ rootId: string }>).detail;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user