From 54c336f5bc44db0d0ced8e801590cd00d7f60355 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 11 Aug 2026 19:30:01 -0700 Subject: [PATCH] [eric] canvas: a pan released outside the window self-heals on the next button-less move, so the canvas can never keep following the cursor (ENG-257); add .claude/VERIFICATION.md --- .../hooks/interaction/useCanvasControls.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index bbd6f555..2393d5da 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -617,9 +617,12 @@ export function useCanvasControls( } }, [startInertia, springBackIfNeeded, commitLive]); - // Clean up panning if mouse leaves the window + // Releasing the button OUTSIDE the window means our window never sees the mouseup, so the pan latch + // stayed armed and the canvas followed the cursor forever, with no way to escape the app (ENG-257). + // Enumerating exit paths is whack-a-mole, so the last guard is self-healing: any mouse move with no + // button held while we think we are panning proves the latch is stale, and it clears itself. useEffect(() => { - const onUp = () => { + const release = () => { if (panStartRef.current) { panStartRef.current = null; setIsPanning(false); @@ -627,8 +630,17 @@ export function useCanvasControls( commitLive(); } }; - window.addEventListener('mouseup', onUp); - return () => window.removeEventListener('mouseup', onUp); + const onStrayMove = (e: MouseEvent) => { if (e.buttons === 0) release(); }; + window.addEventListener('mouseup', release); + window.addEventListener('pointercancel', release); + window.addEventListener('blur', release); + window.addEventListener('mousemove', onStrayMove, true); + return () => { + window.removeEventListener('mouseup', release); + window.removeEventListener('pointercancel', release); + window.removeEventListener('blur', release); + window.removeEventListener('mousemove', onStrayMove, true); + }; }, [commitLive]); useEffect(() => {