From a0de2f5e3efb125dbbe2bf30c1b4904e04161e9f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 13:33:03 -0700 Subject: [PATCH] [eric] workflows: window drag gets the same lost-release seal as the cards --- .../pages/Workflows/app/WorkflowsAppCard.tsx | 26 +++++++++++++++---- .../Workflows/app/WorkflowsAppContent.tsx | 2 ++ frontend/src/app/pages/Workflows/app/types.ts | 2 ++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 1e138d7d..a66b7eea 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice'; import { TILE_ZONES, useTiledStyle } from '@/app/pages/Dashboard/cards/tileZones'; +import { useDragEndBackstops } from '@/app/pages/Dashboard/hooks/interaction/useDragEndBackstops'; import { useWC } from './uiKit'; import WorkflowsAppContent from './WorkflowsAppContent'; @@ -70,6 +71,7 @@ const WorkflowsAppCard: React.FC = ({ // ---- Drag (title bar is the handle) ---- const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); + const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); const [isDragging, setIsDragging] = useState(false); const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); const didDrag = useRef(false); @@ -99,6 +101,7 @@ const WorkflowsAppCard: React.FC = ({ const rawDy = e.clientY - dragState.current.startY; if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; didDrag.current = true; + lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; const cs = getCanvasState(); const z = cs.zoom; const panDx = (cs.panX - dragState.current.startPanX) / z; @@ -109,20 +112,20 @@ const WorkflowsAppCard: React.FC = ({ onDragMove?.(dx, dy, e.clientX, e.clientY); }, [onDragMove, getCanvasState]); - const onHeaderPointerUp = useCallback((e: React.PointerEvent) => { + const finalizeDrag = useCallback((clientX: number, clientY: number, shiftKey: boolean) => { if (!dragState.current) return; const cs = getCanvasState(); const z = cs.zoom; const panDx = (cs.panX - dragState.current.startPanX) / z; const panDy = (cs.panY - dragState.current.startPanY) / z; - const dx = (e.clientX - dragState.current.startX) / z - panDx; - const dy = (e.clientY - dragState.current.startY) / z - panDy; + const dx = (clientX - dragState.current.startX) / z - panDx; + const dy = (clientY - dragState.current.startY) / z - panDy; if (didDrag.current) { justDraggedRef.current = true; setTimeout(() => { justDraggedRef.current = false; }, 0); let finalX = dragState.current.origX + dx; let finalY = dragState.current.origY + dy; - if (!e.shiftKey) { finalX = Math.round(finalX / 24) * 24; finalY = Math.round(finalY / 24) * 24; } + if (!shiftKey) { finalX = Math.round(finalX / 24) * 24; finalY = Math.round(finalY / 24) * 24; } dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY })); } onDragEnd?.(dx, dy, didDrag.current); @@ -130,9 +133,20 @@ const WorkflowsAppCard: React.FC = ({ didDrag.current = false; setLocalDragPos(null); setIsDragging(false); - (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); }, [dispatch, onDragEnd, getCanvasState]); + const onHeaderPointerUp = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + finalizeDrag(e.clientX, e.clientY, e.shiftKey); + try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* capture already gone */ } + }, [finalizeDrag]); + + const abortDrag = useCallback(() => { + if (!dragState.current) return; + finalizeDrag(lastPointerRef.current.clientX, lastPointerRef.current.clientY, true); + }, [finalizeDrag]); + useDragEndBackstops(isDragging, finalizeDrag, abortDrag); + // ---- Resize ---- const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); const [isResizing, setIsResizing] = useState(false); @@ -233,6 +247,8 @@ const WorkflowsAppCard: React.FC = ({ onPointerDown: onHeaderPointerDown, onPointerMove: onHeaderPointerMove, onPointerUp: onHeaderPointerUp, + onPointerCancel: abortDrag, + onLostPointerCapture: abortDrag, dragging: isDragging, }} onTileZone={(zone) => { diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index 8f228a8f..63f1d91c 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -70,6 +70,8 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: st onPointerDown={header.onPointerDown} onPointerMove={header.onPointerMove} onPointerUp={header.onPointerUp} + onPointerCancel={header.onPointerCancel} + onLostPointerCapture={header.onLostPointerCapture} style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: header.dragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }} > {/* macOS traffic lights: the whole window gets close / minimize / full size view like every card. */} diff --git a/frontend/src/app/pages/Workflows/app/types.ts b/frontend/src/app/pages/Workflows/app/types.ts index 6340614b..66dded24 100644 --- a/frontend/src/app/pages/Workflows/app/types.ts +++ b/frontend/src/app/pages/Workflows/app/types.ts @@ -8,6 +8,8 @@ export interface CardHeader { onPointerDown: (e: PointerEvent) => void; onPointerMove: (e: PointerEvent) => void; onPointerUp: (e: PointerEvent) => void; + onPointerCancel?: () => void; + onLostPointerCapture?: () => void; dragging: boolean; }